You're totally right that you still have to check the discriminator often, particularly because---within this model---entities are considered as being certain "kinds", or "sub-types", and those "kinds" are used as ways to refer to bundles of behavior. So, when making sure that certain codepaths light up for certain bundles (entities), and don't light up for other bundles, you have to perform a check on the discriminator (or several checks, but more on this in a bit).
But I'd say it's not quite true that you always have to check the discriminator regardless of which situation you're in. In some cases, you don't actually need to, because you
do have the guarantee that any data manipulations you make to entity data will not be destructive to other important data in the entity. Or, similarly, any data reads you make assuredly map to a single interpretation. This guarantee is not true with
unions
because of the data layout, so the data layout is not irrelevant.
For these reasons, with a naive "fat struct" equivalent to a
union, it isn't actually logically wrong to access or mutate data that isn't explicitly considered as being part of a "kind". The discriminator is used more of a way to guard certain codepaths behind certain curated bundles of features, but not to enforce that model across
all codepaths that might read or write entity data.
The aspect of this that leads to more code
is the strict enforcement of the model of sub-types across all such codepaths. When you do not have that strict enforcement, certain codepaths end up being freed up, purely because they can make assumptions about the format of any
Entity they see, instead of having nearly everything guarded behind a kind check (which was necessary with the
union). With the
naive ("naive" meaning I don't think it's the fully correct option) "fat struct" equivalent to a discriminated union, it's much easier to have data flow freely in-and-out of being relevant for certain features, and it's much easier to write code
irrespective of whether or not certain data is considered "logically wrong" or not. I think, what you'll find within this
slight, naive adjustment to the discriminated union model, that what you end up with is not actually a "type tree" shape at all.
But, I don't actually think any of that matters, because I think "entity kinds" is the wrong model to begin with. The idea of a "type discriminator" does not seem helpful for entities, in my opinion/experience. This is because you're not actually dealing with "types" of entities, you're dealing with bundles of behaviors, or more specifically, bundles of codepaths. Before, entity "kinds" were ways of grouping certain features together.
Let's say you have N possible features in your game. These features correspond directly to important codepaths that implement useful features you want in your game world. Let's also say that these features are
orthogonal, meaning they don't overlap at all. Each feature is unique and is only written once (which seems obviously preferable). Finally, to express any effect you need, you just bundle these features together. So, a few toy examples:
| Player = { RenderSprite, ControlledByUser, HasPhysicsShape }
Tree = { RenderSprite, HasPhysicsShape, IsStatic }
Jelly = { RenderSprite, ChasePlayer, HasPhysicsShape, Hostile }
PartyMember = { RenderSprite, ChasePlayer, HasPhysicsShape }
|
On the left, you can see what we would've called "entity kinds" before. But this mapping is explicitly stating that those "kinds" were really just features we wanted matched together---codepaths that we wanted to be turned on for certain instances of stuff in the world.
What we can appreciate here is that N features leads to 2^N possible combinations. So, for example, in the case where N = 4:
So 16 cases, not that bad. But most games don't have 4 features, they have a much larger number than that. And 2^20, let's say, is already in the millions.
What I think is a much more productive model for entities, which naturally follows from the "fat struct" approach, is this: Instead of checking "entity kinds" to guard against certain codepaths, or trigger certain codepaths, just check a bit that turns that codepath on or off. So now, there is a
direct mapping from a single piece of data---one bit---to a particular codepath running
or not, for any given entity. And, now, you have a way to bundle these codepaths together, because you'd just store these in flags:
| typedef U64 EntityProperties;
enum
{
EntityProperty_RenderSprite = (1<<0),
EntityProperty_ChasePlayer = (1<<1),
EntityProperty_ControlledByUser = (1<<2),
EntityProperty_HasPhysicsShape = (1<<3),
// etc.
};
|
or in a bit array if you want a lot:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18 | enum EntityPropertiy
{
EntityProperty_RenderSprite,
EntityProperty_ChasePlayer,
EntityProperty_ControlledByUser,
EntityProperty_HasPhysicsShape,
// etc.
EntityProperty_COUNT
};
struct Entity
{
U64 properties[(EntityProperty_COUNT + 63) / 64];
// other data...
};
B32 EntityHasProperty(Entity *entity, EntityProperty property);
void SetEntityProperty(Entity *entity, EntityProperty property);
|
This doesn't mean you can't still talk about "kinds", but they're just not the thing you're storing. An "entity kind" is actually just a helper function:
| Entity *MakePlayer(void)
{
Entity *entity = AllocateEntity();
SetEntityProperty(entity, EntityProperty_ControlledByUser);
SetEntityProperty(entity, EntityProperty_RenderSprite);
entity->sprite_info = ...;
// etc.
return entity;
}
|
And now, you have one type, one way of interpreting that type, and an entire 2^N-size space of feature combinations you can explore as a designer. You can even build a UI for exploring that 2^N-size space quite trivially (just loop through the
EntityProperty values), which could be useful.
Because there's only one way to interpret the type, you can write codepaths that are actually not specific to
any entity "kind" at all, but still work with all of the data of an entity. The entity "kind" just doesn't
really matter in many codepaths, while the data often still does. This is where the code savings comes from, as well as the fact that the work you have to do managing these "bundles" doesn't scale like O(2^N), and instead scales like O(N).
---
Now, a couple extra points that I'll make in anticipation of objections (not responding to anyone in particular, just for people who are reading who may have these common questions/objections):
---
How do you prevent non-meaningful bundles of features? Isn't the point of entity kinds to curate them?
In my opinion this depends on the use-case. In many games, there's just no point to preventing meaningless bundles. In an authored game, for example, you just don't make meaningless bundles, just like you don't make nonsensical maps where the player falls to their death immediately. In procedurally generated games, the first thing I'd say is that suddenly it starts being a lot more obvious
how you'd procedurally generate anything (by coloring certain combinations in the 2^N-size set as either valid or invalid). But, obviously, that coloring work needs to be done, but doing it one "kind" at a time is asking for trouble, because now you're doing O(2^N) work.
---
What about the size of entities?
Three points here.
The first point is just a practical one; in more-or-less every case, this pales in comparison to the size of other things you're storing, so whatever.
The second is this: When all entity data is living side by side, in a plain struct, together, and when features are truly being orthogonalized, it's much easier to start collapsing data down, and compressing it by using the same data to encode a variety of things (while not increasing the number of interpretations). So when using this style in a number of problem-spaces (entities, but also UI, parsers/ASTs, etc.) I've noticed things actually getting
quite small, much smaller than you might expect when you first encounter an initial naive "fat struct" transformation from the traditional inheritance-tree-like discriminated union model.
The third: Having a single type, with only a single interpretation, matters a
lot, particularly because you can start building data structures quite easily out of them. In the context of entities, this means building a
structure of entities to get the effects you want, instead of just
one entity per thing in the world. For example:
| struct Entity
{
Entity *first_child;
Entity *last_child;
Entity *next;
Entity *prev;
Entity *parent;
// other data ...
};
|
So, the meaning of "entity" can shrink to be something a bit more granular, which lets you start piecing entities and combining features that way. This also means that a single
Entity is actually small. It's the
entire data structure of them---for complex entities---that gets meaningfully large, but for those cases, you needed the size anyways.
Note that getting rid of the idea of "kinds"---which assumes a particular granularity---is necessary here. The "fat struct", mapping single bits to single codepaths
directly, is exactly how you'd start doing this. It doesn't make any sense to say a
Tree has a
Player parent (unless it's used for simple things, like transform hierarchies). But when you ditch "kinds", it starts being much more useful to start building structures out of entities.
I used precisely this idea in a small-scope game I've been working on (when I find the time). You can see some of the effects here:
https://handmade.network/snippet/351
https://handmade.network/snippet/336
---
Anyways, that's what I mean, hopefully it cleared things up. I'm still trying to figure out how to communicate these ideas more clearly (my theory on this stuff, which came about largely because of working on Dion, goes fairly deep and is hard to condense/compress well), so forgive any weird explanations or lack of clarity. It's also an extremely dense subject that's hard to compress into language, but I'm hoping I sort of got the idea across.