Skip to main content

Hybrid entity system

· 5 min read
Admer456
Lead developer; resident fox

Elegy's entity system could've directly copied Half-Life's, having a class hierarchy and a base entity class that supports a lot of stuff out of the box. However, this path has a few issues:

  • cannot mix'n'match traits (e.g. door + breakable)
  • is cache-unfriendly
  • potential of bloat by dozens of unused member variables

So, I've decided to research a bit and it seems with some clever design and an ECS library, we can reap the benefits of both approaches.

Entity system requirements

Fundamentally, entities can undergo the following interactions:

  • Spawned:
    • From level data
    • Procedurally
  • Saved/loaded
  • Sent/received across the network
  • Events:
    • Used by the player
    • Interacted with by other entities
  • Updates:
    • Simulated on the server
    • Interpolated/predicted on the client

Entities can have various relationships:

  • Trigger-target
  • Parent
  • Custom relationships defined by different kinds of entities

So let's see how to satisfy these.

Overview - concepts

The concepts we're working with are:

  • Entities - hosts to components, they generally do nothing
  • Components - composable entity traits
  • Systems - events and functionality for components
    • In code, these can be part of component structs as static methods quite simply

Spawning

The level data would look like this:

{
"classname" "func_door_breakable"
"origin" "250 0 64"
"Door.StartAngle" "0"
"Door.Attrib1" "25"
"Door.Attrib2" "40"
"Door.Attrib3" "50"
"Breakable.Health" "100"
"Breakable.Material" "glass"
"Breakable.Attrib1" "2"
"Breakable.Attrib2" "100"
}

One possible approach is to use code generation to generate a parsing method:

switch ( key )
{
case "Door.StartAngle": entity.GetComponent<Door>().StartAngle = ParseFloat( value );
...
case "Breakable.Health": entity.GetComponent<Breakable>().Health = ParseFloat( value );
...
}

It beats runtime reflection-based loading for sure.

Components may sometimes need to initialise some stuff once they spawn, or perform checks or other things. For this, a Spawn method can be introduced:

[MapComponent]
public struct Door
{
public static void OnSpawn()
{
// Query all Door components, perform angle corrections/adjustments
}
}

TODO: write about procedural entity creation and components requiring other components

Saving/loading, sending/receiving, transitioning

The end user would simply mark fields with attributes:

public struct Door
{
[Saved]
[Networked]
public float DoorAngleMin { get; set; } = 0.0f;

[Saved]
[Networked]
public float DoorAngleMax { get; set; } = 0.0f;

[Saved]
[Networked]
public float DoorAngle { get; set; } = 0.0f;
}

Similarly to the idea behind spawning, code could be generated to network & save this.

If manual de/serialisation is needed, it is possible to expose it to the system:

public struct Door
{
public static void OnGameSave( ByteBuffer buffer, in Door self )
{
buffer.WriteFloat( self.DoorAngle );
...
}

public static Door OnGameLoad( ByteBuffer buffer )
{
Door result = new();
result.DoorAngle = buffer.ReadFloat();
...
return result;
}
}

Events

Events are probably the most complex here. In the map editor, you can give entities names and set up links between entities, e.g. a button could open a door:

"!Button.OnPress" "room1_dr.Door.Open"
...
"!Trigger.OnTouch" "room2_dr.Door.Open 2.0"

(! is an event hint, 2.0 denotes a time delay)

In essence, you'd need to find an entity with the name room1_dr or room2_dr for example, get its Door component and call the Open event. This is an example of the trigger-target relationship.

Entity names and events could be encapsulated by a component in and of itself: MapEntity for example.

// There are plenty of opportunities to optimise this, however, it's 
// worth nothing that these events don't get called every frame.
// They are occasional at best, if we can trust level designers...
public struct EntityEvent
{
// Button.OnPress
public string Input { get; set; } = string.Empty;
// room1_dr
public string TargetName { get; set; } = string.Empty;
// Door
public string ComponentName { get; set; } = string.Empty;
// Open
public string ComponentEvent { get; set; } = string.Empty;

public string Delay { get; set; } = 0.0f;
}

public struct MapEntity
{
public string Name { get; set; } = string.Empty;

public List<EntityEvent> Events { get; set; } = new();
}

Each entity spawned from level data will have a Name and potentially populated Events. So it makes sense to perform queries on them.

Populating Events should be automatic:

[MapComponent]
public struct Door
{
[MapEvent]
public static void OnOpen( Entity self, Entity activator, Entity caller )
{
self.GetComponent<Door>().State = DoorState.Opening;
self.GetComponent<Audio>().PlayNew( "sounds/dooropen.wav" );
}
}

caller is the entity which called the event, e.g. a button calling the door.
activator is the entity which initially started the chain, e.g. a player using the button.

Updates

This one is pretty simple. Systems can have an OnUpdate method for example:

public struct Door
{
public static void Update( float deltaTime )
{
// Query all Door components and update their angles and transforms
}
}

TODO: write about entity parent relationship