Building an ECS in TypeScript
Deeper Dive: Components
As with Entity
, our Component
implementation is also quite minimal.
/**
* A Component is a bundle of state. Each instance of a Component is
* associated with a single Entity.
*
* Components have no API to fulfill.
*/
abstract class Component { }
This deceptively simple interface can drive a considerable number of designs when put inside an ECS. Its uses are best shown by example.
So, letās check out three Components I wrote for Fallgate. For each Component, Iāll give example code that uses it, and discuss some design tradeoffs that arise. After the example components, weāll bend the notion of a Component by adding code to the classes instead of only storing data. Iāll cover three ways that I added code to Components.
Just to be clear, the example usage code below all lives in Systems, not the Components. But I figured itād be easiest to understand what these Components do if you see how theyāre used.
Example Components
Here are three example Components, ordered from simple to complex. They are the CameraFollowable
, Spawnable
, and Armed
Components.
CameraFollowable
This simple Component contains no data. Itās just a marker to tell a camera System to follow this Entity.
class CameraFollowable extends Component {}
Usage
This Component is used by a System called FollowCamera
, which looks for Entities with Position
and CameraFollowable
Components, and contains logic to center them in the screen. Only one object (usually the player) had CameraFollowable
on it at a time, so we just tracked the first Entity we found with this marker.
Spawnable
This contains the position at which an Entity should be respawned.
class Spawnable extends Component {
public position: Point
constructor(position: Point, public angle: number = 0) {
super();
this.position = position.copy();
}
}
For reference, a Point
is a utility object that stores an (x, y)
coordinate.
You might notice this looks a little verbose. Why not just specify the position
directly as a public member in the constructor?
class Spawnable extends Component {
constructor(public position: Point, public angle: number = 0) {
super();
}
}
The reason is that the Point
object would now be shared between this Spawnable
Component, and whatever code passed in the Point
(probably from some kind of Position
Component). Then, later updates to underlying Point
object would also change the position stored by the Spawnable
Component! I discovered this from seeing weird bugs where Entities would respawn where they were last located, rather from (what I thought was) their spawn point.
This is one of many areas where a more sophisticated design of either the Point
object, or the Component
s themselves, could potentially have mitigated these issues, by ensuring that objects were always copied rather than reused. But this was beyond my pay grade (i.e., $0). Instead, I just tried to remember to always copy()
Point
s when they were passed to new Components.
Usage
The Spawnable
Component had pretty straightforward usage. When an Entity is created during level building, its spawn point is marked as its initial position. This works for both the player and enemies.
// If respawn marked, also make that component.
if (props.has(Respawn) && (props.get(Respawn).val() as boolean)) {
ecs.addComponent(entity, new Spawnable(new Point(x, y)));
}
The player can hit checkpoints (which are cauldrons), which updates their respawn position. We set it to the cauldronās position, plus a little offset so they donāt respawn right on top of it.
let spawnable = playerComps.get(Spawnable);
spawnable.position.copyFrom_(cauldronPos.p).add_(SPAWN_OFFSET);
Also, when the player hits a checkpoint, this saves their progress of how many enemies they have killed. We do this by simply removing the Spawnable
Component from all dead enemies when the player hits a checkpoint.
// make all dead enemies no longer able to be respawned
for (let enemy of enemySelector.latest()) {
let enemyComps = this.ecs.getComponents(enemy);
// if it's not dead, let it keep any spawnable prop it has
if (!enemyComps.has(Dead)) {
continue;
}
this.ecs.removeComponentIfExists(enemy, Spawnable);
}
There are a couple of new things in this code snippet:
-
enemeySelector
ā instance of a System that, you guessed it, selects all enemies. Itās a bit of a weird pattern, because that System is then used by other Systems to get a list of Entities. But itās also super easy to implement, because Systems track sets of Entities out-of-the-box. Weāll discuss this more in the post diving deeper into Systems. -
ecs.removeComponentIfExists()
ā just likeremoveComponent()
, but no error if the Component isnāt there. I could totally imagine a game engine that just always does this behavior, in which caseremoveComponent()
would be all youād need.
Armed
This Component stores the set of weapons that an Entity has, and tracks which one is currently equipped.
class Armed extends Component {
public active: Weapon
public inventory = new Array<Weapon>()
public activeIdx = -1
/**
* Elapsed is the time in ms that has been spent in this.state. This
* is used for display purposes (e.g. for flashing tints during
* various stages of charging). It is set by the Swing system to
* match its internal state and should only be observed by other
* systems.
*/
public elapsed = 0
constructor(
active: Weapon,
/**
* The state is set by the Swing system to match its
* internal state and should only be observed by other
* systems.
*/
public state: SwingState = SwingState.Idle
) {
super();
this.active = cloneWeapon(active);
this.inventory.push(active);
this.activeIdx = 0;
}
}
A couple of observations here:
-
You might notice some potential weakness in the ECS design here, with the comments on the
elapsed
andstate
public variables. The comments tell you which System (i.e.,Swing
) should write its state. This is somewhat helpful, but it is error-prone. Thereās nothing stopping us from accidentally updating its state from another System. And thereās no way to understand why I wrote those comments without reading the code in theSwing
System. What is going on here is that these properties are set as the result of complex logic in theSwing
System, but must be read by other Systems, like the renderer. In short, itās a totally normal usage of Components to share data between Systems, but lacks any programmatic enforcement of invariants, like who is allowed to update what. This is one area you might consider modifying your ECS design: specifying contracts to prevent accidental variable misuse. -
You can see we do
cloneWeapon(active)
for the same reason that we didposition.copy()
in theSpawnable
Component above: avoiding accidentally sharing object references between Components. I end up doing this kind of defensive copying many places, and it is a hazardous potential source of bugs. Another design extension you might consider would be some way to automatically make state copied by default. I chose to keep the design simpler and the syntax cleaner, at the cost of having to remember to make copies. This was an okay tradeoff for me to make, since I was the only one programming, and we had no pressure to make things perfect. -
This Component actually stores a lot of state! You canāt quite tell because I havenāt shown you whatās in the
Weapon
type.
To drill into that last point, hereās the Weapon
type.
/**
* Data class for what comprises a weapon.
*/
type Weapon = {
timing?: CharacterTiming,
swingAttack?: AttackInfo,
quickAttack?: AttackInfo,
comboAttack?: AttackInfo,
partID?: PartID,
}
The CharacterTiming
looks like this:
/**
* Timing info for a weapon's swing stages. This is character-centric.
*/
type CharacterTiming = {
idleCooldown: number,
minChargeDuration: number,
swingDuration: number,
sheatheDuration: number,
/**
* How long after the quick attack state was entered (i.e., not
* including the quickAttackAttackDelay) must the entity wait before
* beginning the next quick attack (or combo). Should be less than
* quickAttackDuration.
*/
quickAttackNextWait: number,
/**
* How long a quick attack lasts before returning to idle. Starts
* from when state was entered (i.e., not including the
* quickAttackAttackDelay).
*/
quickAttackDuration: number,
/**
* From from when the swing state was entered until the "Attack"
* entity is spawned.
*/
swingAttackDelay: number,
/**
* Time from when the quick attack state was entered until the
* "Attack" entity is spawned.
*/
quickAttackAttackDelay: number,
/**
* Total duration for the combo state.
*/
comboDuration: number,
/**
* Time from when the combo state is entered until the attack
* object (collision box) is spawned.
*/
comboAttackDelay: number,
}
ā¦ and the AttackInfo
looks like this:
/**
* Info (including timing) for the attack. (This is attack-centric.)
*/
type AttackInfo = {
// NOTE: cboxDims and cboxOffset should exist on normal attacks;
// they should not exist for static damage definitions, because then
// the attack takes the collision box of the parent object. We might
// want to make a separate spec for this.
cboxDims?: Point,
cboxOffset?: Point,
movement: AttackMovement,
damage: number,
attackType: AttackType,
cTypes: CollisionType[],
/**
* Denotes that this attack cannot be blocked. Useful for, e.g.,
* environmental attacks that are long-lived and shields shouldn't
* protect you from.
*/
unblockable?: boolean,
/**
* How much force is applied to the victim if this attack causes a
* knockback.
*/
knockbackForce: number,
/**
* How much force is applied to the victim if this attack causes a
* stagger.
*/
staggerForce: number,
/**
* How much force is applied to the attacker to move it forward.
*/
lungeForce: number,
/**
* Time in ms before the attack is stopped. -1 for no limit
* (e.g., arrows that go until they hit something).
*/
duration: number,
/**
* If this attack is blocked (e.g., by the player's shield), this is
* the amount of time in ms that the `Blocked` state will be
* applied to the attacker. If not provided, uses
* Blocked.DEFAULT_DURATION.
*/
blockedDuration?: number,
/**
* Only relevant for AttackMovement.Launch; speed it flies.
*/
velocity?: number,
/**
* Only relevant for AttackMovement.Launch; how to draw the attack
* itself (e.g., an arrow).
*/
animDatas?: Map<Anim.Key, Anim.Data>,
/**
* Sound effects to play based on different situations (e.g., swing,
* hit).
*/
sounds?: Sound.Attack,
}
Phew! Boy, even simple games are complicated. So many details of timing and state to keep track of.
Usage
Iāve already overwhelmed you with so much code describing the state that the Armed
Component keeps track of. Instead of providing more code, Iāll give you a brief rundown of the places it is used.
where | how Armed is used |
---|---|
Level loading | Giving Entities their weapons |
Activity System |
Figuring out overall state of how an Entity should be rendered |
Several AI Systems | Changing behavior based on what weapon the player has equipped, and what state of attacking the enemy is currently in |
Body System |
Figuring out detailed parts of how an Entity should be rendered (e.g., overlaying the body with the equipped weapon, synchronized to the right frames) |
Defend System |
Checking whether something is attacking to figure out whether it can defend (canāt do both at same time) |
Movement System |
Slowing down movement when attacking |
PlayerHUDRenderer System |
Displaying the equipped weapons in the HUD, and highlighting the active one |
Swing System |
Managing the process of attacking: charging up, swinging, cooldown, combos, that kind of thing |
Extending Components
According to what I know about the design philosophy of ECS,01 Components should be āpure state.ā Theyād might as well be JSON. No code.
Buuuuuut, hey, weāve already decided that Components are going to be classes in our code. And we already have some basic code running, like defensibly copying data passed into the constructors so that callers donāt have to remember to. What if we bent the rules in a few situations and added some code to our Components?
The three main ways I modified Components were for debugging, sharing behavior, and optimizing updates. Letās dive in.
Debugging with Setters
At some point during development, I had a problem where Entities were being thrown way off into space, or would disappear entirely. As usual, this bug was pretty funny for a while, but eventually I had to buckle down and figure out what the hell was going on.
Using a live Component viewer tool I had built, I saw that the positions were being set to NaN
. The question was, where? It took a little bit of refactoring, but I replaced all the places where code would modify an Entityās position to instead go through a setter:
class Position extends Component {
// old code:
// public p = new Point()
// new code:
private _p = new Point()
private _revealP = new Point()
public get p(): Point {
return this._revealP.copyFrom_(this._p);
}
public set p(v: Point) {
if (isNaN(v.x)) { throw Error('Tried to set x to NaN!'); }
if (isNaN(v.y)) { throw Error('Tried to set y to NaN!'); }
this._p.copyFrom_(v);
}
// Omitted: angle (i.e., facing direction)
}
If youāre not familiar with getters/setters, they automatically run under-the-hood when you try to get or set a value, like:
let foo = position.p; // runs getter
position.p = new Point(0, 0); // runs setter
You can see in the setter (set p()
), we check the coordinates passed in for NaN
, and immediately throw an error when this happens. Could you do this check by setting a breakpoint in the debugger instead? Absolutely. But the nice thing is that you donāt have to go and do that when problems ariseāthis way, your invariants are checked at all times. Despite how silly this error is, these NaN
checks actually found errors several times after I fixed this particular bug.
Also, you might notice that thereās another trick: we have a _revealP
Point
which we return in the getter (get p()
). This protects users of the code from getting the underlying Point
and modifying it. Itās not a perfect fix, because callers still shouldnāt use this _revealP
object; if multiple callers got it and tried to modify it, they could still confuse each other. Still, it does protect the Position
Component itself, while letting calling code easily read the Point
, and without allocating a new Point
for every access.
Sharing Behavior with Timebomb
If you thought writing Components that ran code was sacrilegious, wait until you hear this one: I wrote a bunch of Components that used inheritance!
The name of the game here is restraint. I was really cautious of using inheritance, but it seemed like a great fit.
Hereās the situation. I kept running into cases where I wanted a Component that would last a handful of frames, then disappear:
Attack
objects (the collision boxes generated by attacking)Bleeding
state (emitted blood streaks)Block
objects (the collision boxes generated by defending)Blocked
state (when someoneās attack was blocked; they might have a delay before they can attack again, or have to recoil backwards)DamagedFlash
state (flashing from taking damage)Immobile
state (temporarily canāt move)- ā¦ (there are about six more)
To accomplish this, I implemented a Timebomb
Component, and then had other Components subclass it. I also had a Timebomb
System that I would subclassāyes, I subclassed a System as well š±. The Timebomb
System would automatically apply the the common timing and destruction logic, and the particular subclass would add any specific behavior (e.g., for Bleeding
, actually emit blood).
Hereās what the Timebomb
Component looks like:
/**
* What to do when the timebomb goes off.
*/
enum Destruct {
/**
* Remove the component.
*/
Component = 0,
/**
* Remove the entire entity.
*/
Entity,
}
class Timebomb extends Component {
/**
* When created (set on first Timebomb System pass).
*/
public startTime: number = -1
constructor(
/**
* Total time in ms this will last (doesn't change).
*/
public duration: number,
/**
* What to do upon destruction.
*/
public destruct: Destruct,
/**
* A fuse, to allow others to request that the destruction is
* activated.
*/
public fuse: boolean = false,
/**
* An optional function that will be called upon destruction.
*/
public lastWish?: (
ecs: Engine.ECS, entity: Engine.Entity
) => void,
) {
super();
}
}
As an example Component extending this, hereās the Attack
Component. This Component is added to Entities that represent an āattackā collision box. It tracks the parameters of the attack by storing the AttackInfo
, passed in from the weapon that created it. It extends Timebomb
and passes in the particular attackās duration.
class Attack extends Timebomb {
public info: AttackInfo
/**
* Whether the attack has hit something (used for combo logic).
*/
public hit: boolean = false
/**
* Used to limit heavy-duty effects (like pause and flash) shown per
* attack.
*/
public heavyEffectsShown: boolean = false
constructor(public attacker: Engine.Entity, info: AttackInfo) {
// This is where we pass the critical information into our
// Timebomb super class: how long does this last, and what
// should happen when time runs out.
super(info.duration, Destruct.Entity);
// (Also storing some state for the Attack itself.)
this.info = cloneAttackInfo(info);
}
}
The Timebomb
is also useful for objects that exist semi-indefinitely, like a āblockā object. Blocks exist until the shield is lowered, or something else happens. For those cases, we just pass -1
as the duration, and let managing code trigger the destruct action by setting the fuse
attribute.
I wonāt go into the details of the Timebomb
System, which actually handles counting down the destruct time and triggering the destruct action. But to briefly show how new Systems are made that extend it, hereās the corresponding System for the Attack
component:
/**
* I've been omitting namespaces for brevity so far, but this is the
* first time we've had a name collision. In reality, the classes above
* were in the `Component` namespace, so they would be the
* `Component.Attack` extending the `Component.Timebomb`. These are
* Systems, so they'd be `System.Attack` extending `System.Timebomb`.
*/
class Attack extends Timebomb {
/**
* The Timebomb System requires this attribute, and uses it to
* perform the common timing and destruction logic.
*/
tbComp = Component.Attack
public componentsRequired = new Set<Function>([
Component.Attack,
])
}
Optimizing Updates with Dirty Flags
This final example of adding code to Components was the messiest to implement, but also provided a huge performance optimization.
The feature allows Components to track whether their state has changed. Then, Systems can take the information about changed Components into account to only update a subset of the Entities. When you have Systems running on a large number of Entities, but with few changes every frame, this performance gain can be considerable.
However, describing this change is quite involved. It touches not only Components, but also Systems, and the ECS itself. As such, it warrants a post of its own.
Footnotes
Full disclosure, my understanding of ECS wasnāt from learning about it in industry, or even reading about it online (I couldnāt find that much good stuff?), it was basically from talking to my friend Alex. So when I write stuff like, āthe design philosophy of ECS,ā please take that with a grain of salt. ā©ļø