bcarruthers / garnet

F# game composition library
MIT License
164 stars 15 forks source link

Component markers #13

Closed B-Reif closed 3 years ago

B-Reif commented 4 years ago

Currently, it's possible to add a component of any type:

// Registering an event
c.On<MyEvent> <| fun e ->
    c.Create().With(0) // no problem!

In practice, a component of type int is not helpful. Generally the domain specifies certain types for use as components:

// components
[<Struct>] type Vec2 = { x : float32; y : float32 }
[<Struct>] type Position = { p: Vec2 }

// Registering
c.On<MyEvent> <| fun e ->
    let pos = { p = { x = e.x; y = e.y }}
    c.Create().With(pos) // create an entity with a position component

When the framework accepts any type, this can cause subtle bugs:

// Registering
c.On<MyEvent> <| fun e ->
    let pos = { x = e.x; y = e.y }
    c.Create().With(pos) // oops! added a component of type Vec2 and not Position!

In this case the compiler will not complain, and the bug emerges only in runtime. It might be appropriate to expose a marker interface and constrain the component types:

// in garnet
type IComponent = interface end

type Entity =
    // for example:
    member c.With<'a when 'a :> IComponent> (x: 'a) = c.Add x; c

// consumer types
[<Struct>]
type Position = 
    { p: Vec2 }
    interface IComponent

// Trying to register a Vec2:
c.On<MyEvent> <| fun e ->
    let pos = { x = e.x; y = e.y }
    c.Create().With(pos) // compiler error: expected pos to be an IComponent

This is how Unity constrains component types in its ECS. If the compiler can help detect correct usage of component types, that seems like a win to me.

bcarruthers commented 4 years ago

Thanks for the suggestion B-Reif.

Pros:

Cons:

Serialization libraries often use attributes or other markers on serializable types, although typically it's opt-in. The component marker here would be required, which could be an issue if using a type from another library directly as a component (although the recommended way may be to wrap the type like your example shows). An alternative is calling some form of registration for the types, but then it requires more code and is just a runtime check.

Another option is explicitly including generic type parameters for certain methods involving a component or an event, e.g. entity.Add(..). This is just an opt-in convention, but it adds readability and protects against a variety of bugs.

Some scenarios to consider:

I'll try this approach with some of my code and see how it looks.

B-Reif commented 4 years ago

Agreed on all counts. The syntax for this kind of thing can be annoying and quite verbose.

I was previously wondering if there was some way to do this with attributes instead of interfaces, but I couldn't really locate anything. If we can use an attribute, it might be possible to tag at the module level instead? I don't think the language supports compile-time attribute checks for custom attributes though.

bcarruthers commented 3 years ago

I gave this more thought and plan to keep components as-is for now. For components like transforms, I sometimes use types defined in assemblies with no dependency on Garnet. In some cases I'll just wrap the type in another type, although that adds some verbosity with each use.