Entity Component System: Entity
2017-03-17
Entity, the first pillar of the ECS model and the simplest of the three. In a classic ECS an entity is represented by a single unique 32-bit integer. But this simplicity belies a hidden complexity.
The simplest entity id allocator would be something like:
struct trivial_id_allocator {
uint32_t next_id = 0;
uint32_t alloc() {
return next_id++;
}
};
This may work fine for a simple single player game but consider what happens when you add entity replication in a multiplayer game. Replication needs to be able to match an entity on the server to its corresponding entity on the client.
We can accomplish this by generating all ids on the server. When a client wants to create an entity is asks the server for an id and can create the entity when it gets a response. This solution:
- adds at least RTT latency to entity creation on the client
- creates unnecessary network traffic
Imagine a typical multiplayer first person shooter. If a player shoots his gun into a wall there should be a bullet hole decal created at the point of impact. The local game client has all the information necessary to create and display this decal entity immediately. Remote clients need only to know when and where the shot occurred to recreate the impact and spawn the decal. In fact the server never needs to know about the decal at all.
But now we want to create an entity on the client. If we use the trivial_id_allocator on the client then client and server ids are no longer synchronized. Consider:
- client alloc decal » id = 0
- server alloc rocket » id = 0
- server replicate rocket entity to client » uh oh; id mismatch
We can solve this in a couple of ways.
Let’s try to partition our id namespace. Reserve the lower half of ids for replicated entities. Then we can create something like:
struct partitioned_id_allocator {
uint32_t next_replicated_id = 0;
uint32_t alloc_replicated() {
return next_replicated_id++;
}
uint32_t next_local_id = 0x7fffffffu;
uint32_t alloc_local() {
return next_local_id++;
}
};
Our allocator interface has gotten more complicated but assuming we spawn less than ~2 billion entities we will have no mismatched ids. All replicated ids will be generated on the server and local ids can be generated anywhere.
It may be too early to celebrate. Consider a case where we may want to locally predict the creation of a replicated entity. Going back to our FPS example, have a player fire a rocket launcher. In older games like Team Fortress 2 the client would immediately play a firing animation on the weapon and hands model but the rocket itself would not appear until the server spawned it and replicated that entity to the client. In newer games like Overwatch in addition to playing animation the client actually spawns the rocket. The server will still spawn the rocket and replicate it to the client. This means the rocket is both predicted and replicated.
This presents a conundrum for our last solution. Which alloc routine should the client call to create the predicted rocket? Calling alloc_replicated leads us right back to the id desync situation. So we must call alloc_local but how do we handle the replicated rocket when it finally arrives from the server?
Do we destroy the predicated rocket and instantiate the replicated one? This is perhaps both wasteful and jarring. Jarring in the sense that the rocket may suddenly jump in position if our prediction doesn’t perfectly match the server and also we may lose crucial visual information like the rocket trail or the particle effect may pop as the random state is reset. A better solution is to treat the rocket like any other moving entity that gets a server correction and quickly interpolate from the current state to the desired state.
This means there are 2 entity ids representing the same entity. The client generated one id for the rocket and the server generated another. One possible solution is to reassign the id of the client’s rocket once we learn the server id. This works but it has some potential issues. For example if components stored in order by entity id then all the components of the rocket may suddenly be in the wrong order. Do we want to commit to having components that are allowed to relocate in memory just to support client-side prediction of entity creation?
There is an alternative. We can return to something as simple as the trivial_id_allocator and layer atop a translation stage to map a replicated entity’s id from the server id namespace to the client’s id namespace. This adds some complexity but the footprint is conceptually pretty simple. The serialization/deserialization of an entity_id could do the translation automatically. This can potentially enable some other cool tricks. A client could have a pool of rocket entities that could be recycled by reassigning their id in the translation map to a new rocket.
This pooling may expose another issue, one there was the potential for all along. Imagine you have a laser turret that locks on to a rocket and tracks its movement so the beam always follows the rocket. It will likely store the target entity id in a behavior components. When this rocket is destroyed it is returned to the pool and the rocket’s id may be immediately reassigned to a new rocket. It is an easy mistake to mismanage entity references and cause the laser turret to now immediately track this new rocket instead of running its proper new target acquisition logic.
Certainly a dangling reference to an id was bad before, but hopefully it would do nothing or crash immediately. Now we must worry about the code appearing to work correctly while subtly doing the wrong thing.
A potential solution is to add a version to the entity id (perhaps stored in the high order bits) that gets incremented each time an id is assigned. We add complexity to our entity id but gain some semblance of safety from dangling references.
an entity id is the minimal state required to uniquely identify an entity
In the future we will explore more Entity related ideas including:
- live editing of entity definitions
- entity reference management
- entity prefab instantiation with internal references
- multithreading concerns with a shared id namespace resource
- deferred or journaled operations
- grouping entity ids by archetype
- entity id set operations
- PVS, fog of war, and cheat prevention
Finally here are like to previous ECS posts: