Entity + Component + System
If you're looking for a programming project that's as far as possible from building a React app, may I suggest handcrafting a tiny game engine and setting a universe in motion?
That's what I was after when I came across a handful of blog posts about the Entity-Component-System pattern, or ECS. First it was the Starmancer devblog, which didn't reference ECS directly but gave a feel for the pleasure of building a radically extensible game. Then I found Maxwell Forbes' magisterial sequence on building an ECS in Typescript, and decided to do the same on a much smaller scale.
Proponents say that ECS manages the complexity of interacting game systems by keeping them decoupled as single-responsibility systems. If I'm less worried about what breaks when I add or edit a game system, then I'm closer to an open-ended, additive process – my ideal of recreational programming.
There are lots of ECS engines out there, and lots of optimization tricks to make it work at scale. This was an attempt to do it dead-simple and get a sense of the appeal, so I made mine up as I went along and didn't do any optimization. Within my ad-hoc framework, having never made a game before, I noodled my way to something pretty fun.
The Core
Entities are just bags of data. These data are called components, and they store the state of the game. They change over time thanks to systems. A system is concerned with only a few components. At every tick of game time, each system takes its turn processing the entities relevant to it. Here's the core loop in my implementaton – all other logic is in individual systems:
const tick = () => {
for (const system of systems) {
system(entities);
}
};
It really is miles from a React app, where we wait for user input and cascade through all the outcomes as quickly as possible. This is a continuous hum of processing. It's refreshing.
The canvas below is rendering three entities, and each of them is using some combination of three components:
appearance
: How does the entity look in terms of width, height and fill-color?position
: Where is the entity in 2D space; where should we draw it?velocity
: How does its position change before the next tick?
The momentumSystem
sees that Red and Green have position and velocity, so it updates their position by their velocity at each tick.
(About these code sandboxes: they're made with the wonderful Sandpack, and I usually hide some files to avoid clutter. Click "Open Sandbox" to see the rest.)
Notice how there's not a 1-to-1 mapping of systems to components. Systems generally act as causal links between bits of state. More than once I added a system with a matching component to hold its data, then renamed one or the other when I realized the component naturally served multiple systems.
One thing missing from my implementation is a way for systems to efficiently query for entities they should act on. This is a basic affordance of any real ECS engine. I just rummage through all my entities, which are simple objects, and ignore the entities without the requisite properties. Could be much faster, but I thought the simplicity of that core loop was pretty neat, and I didn't encounter any performance issues to convince me otherwise.
Universe Input/Output
The renderSystem
is an odd one because it knows about non-gameworld things like the DOM and the canvas API. Similarly, the inputSystem
(newly added below) is mainly code for translating input events to player intent. I kept platform details isolated to these systems so it would be easier to write tests or port to another platform. And on the theme of isolation, I find something satisfying and a little mysterious about accessing the game world through these thin channels. It feels like I've built a terrarium and all I can do is look through the glass and tap on it.
The inputSystem
only looks at entities with velocity
and playerControlled
, and updates their velocity based on keypresses, affecting them a bit like a strong wind. I've also updated the momentumSystem
below to look at the new friction
component, which slows entities over time.
At this point you can click the canvas and use your arrow keys or WASD to move the red square.
Already fun! Students of our universe might notice that all of these systems treat the velocity's X and Y aspect independently, so a diagonal movement will be faster than a cardinal one, and friction might bring an entity to a stop on one axis before the other, imparting a curved path. Somehow it doesn't feel weird, so that's where I left it.
Collisions
Collision-detection is the basis of all interaction between entities. On my first pass I thought collision and rebound would be a single system, but then I considered game conditions triggered by an invisible box and decided to keep them separate.
I'm used to thinking of collision as a broadcast event, like the browser's mouseEnter
, but event APIs differ between platforms, and in any case the event would have to be saved for other systems for an entire tick, likely as a component. A broadcast-free pattern emerged:
The collisionSytem
compares the left, right, top, and bottom edges between every collisionBox
entity. If there's overlap on both axes it knows they're occupying some of the same space. If so, it pushes a new collision to collisionBox.collisions
, containing:
otherEntId
: The other driver's ID. This lets us get data for rebounds and more.xOverlap
: Overlap on the X axis.yOverlap
: Overlap on the Y axis.
But first, it clears old collision events created in the previous tick. Because systems always run in the same order, any systems "subscribed" to these transient-component "events" have already had a chance to use them.
I'm still on the fence about whether the overlap values should be included there. The collisionSystem
has the information handy just in proving a collision happened, and it would require more loops in another system to derive the same data. On the other hand, it's only used for rebounds and strains the single-responsibility of collisionSystem
.
Anywho, below I've added a real-time display of the overlap data on the red player-controlled entity:
ReboundSystem
Physical collisions are relatively easy to model in this game because everything is a rectangle and nothing rotates. Entities are also perfectly smooth and elastic, trading velocities on the impact axis and leaving the other axis unchanged.
Getting that impact axis is a little counterintuitive. If you explore collisions above, you'll see that the overlap with the smaller absolute value generally indicates the axis. It's not a perfect heuristic – very fast entities could end up overlapping in any arrangement by the time the collision is detected – but it works well enough.
The overlap value also turns out to have another use: it lets the reboundSystem
immediately move entities safely outside of collision bounds. Multiple bugs just vanished when I figured this out.
There are a couple of interesting loops in this system. First of all, it doesn't set the new position/velocity values as soon as it discovers them. If it did, entities that get checked later wouldn't be responding to their counterpart's velocity at the time of impact, but to the velocity they themselves had imparted! So it collects all the updates and applies them in a separate loop.
There's also a loop over multiple collisions accrued on a single entity, like when an entity runs directly into a corner formed by 2 walls. Though the collisions are simultaneous, we treat them as if they're sequential: Wall-X reverses X velocity, then wall-Y reverses the Y velocity, and the entity flies back in the opposite direction.
Walls are such an important element to the game, but I wasn't thinking about them when I first made the reboundSystem
. They fell into my lap with just a little tweak: when an entity crashes into something flagged as stuck
, it just reverses its velocity on the impact axis. Such a nice surprise.
I mentioned earlier that systems always run in the same order, and I built most of this thinking the specific order didn't matter. In fact, the physics can feel slightly different or quite broken with different orders. For instance, I like how inputSystem
before momentumSystem
lets the player drive right up against a wall without bouncing off, by canceling bounce velocity before it's applied to position. The alternative doesn't feel broken, but you have less control.
The most broken sub-sequence was this:
collisionSystem
→ momentumSystem
→ reboundSystem
If entities overlap even further after a collision is recorded, the reboundSystem
can't accurately cancel the overlap and send them off again.
You Win!
Games have lots of state outside of their physics, and win/loss conditions seemed like a great final test for the framework. Here's the game: Three pink squares are hiding in a broom-closet while zombies lurch around outside. You (red) win if you can bounce one of them into the yellow safe-zone. You lose if zombies get them all. You are immune to zombies and can safely shove everyone.
I needed lots of entities for this, so I added some utils to reduce boilerplate, makeSquare
and makeWall
. There are also three new systems in play:
infectionSystem
: Convert pink squares to green on contact with zombies.lurchSystem
: Give zombies a bit of random movement when they run out of momentum.gameOverSystem
: Detect win/loss conditions and show a message. Updatedappearance
andrenderSystem
with some very basic text rendering to support this.
I also needed some new components. I could handle all of the logic above using appearance.color
, but better to be explicit:
infectious
: A flag to indicate a zombie.infectable
: A flag to indicate that a square can become a zombie.goal
: A flag to indicate the goal box.
A few nice surprises showed up when everything was working together. Zombie movement looks good and feels unpredictable – you wait and hope they won't lurch your way. Bouncing off walls is a free solution to keeping zombies in the mix without any directional guidance. Higher-level, there seem to be two main approaches you can take to the game: billiards-style, focused on shooting a survivor into the goal quickly, or bodyguard style, inching them along and shoving zombies away. And I like that you can stand guard in the broom-closet doorway while you get a sense for how zombies move.
These things fell out of the interacting systems without advance planning. Maybe that's just the magic of game-dev, but I think the ECS pattern did make it easier to throw new systems into the mix and trust that they'd work together.
At no point did I feel like the complexity was getting out of hand. The collision and rebound systems gave me some trouble, but it never felt like their issues or compromises were spilling into other systems. There are some projects that you shove across the finish line and never want to look at again, but I feel excited to keep playing with this thing.
If you do too, check out Episode 2: More Zombies in a Bigger Room or the project repo.
Thanks to those who helped this project along with pair-programming, writing advice and gameplay ideas: Stella Choi, Michelle Bernstein, Samuel Selleck, Reed Spool, Joseph Jorgensen and Zach Rose.