Skip to content
Crowd Control
← Devlog

Building my new EC

· 6 min read
netngine ecs physics architecture

A confession to open with. For months I’ve been telling people — and myself — that NetNgine has an ECS at its core. Entities, components, the works. Then I sat down to test a new demo, a bomb-dropping, blow-up-the-grid thing called Bomberlike, and realised something awkward: I had the E and the C. The S had quietly gone missing somewhere along the way, and I hadn’t noticed because nothing had needed it yet. I also, it turns out, had no physics.

This is the story of the demo that called my bluff, and the two engine layers it made me finally build.

The comfortable lie

An ECS is Entities, Components, and Systems. I had entities and components — data, laid out nicely. But the logic didn’t live in systems. It lived in each game module’s OnTick: a growing method that reached into the world and did whatever that demo needed. It worked. Ping-pong, a shooter, a two-zone world — none of them were complex enough to punish me for it.

“Physics” was in the same state: whatever ad-hoc collision each demo hand-rolled. There was no shared notion of a collider, a layer, or a step. It didn’t matter — until it did.

The demo that called the bluff

Bomberlike is different in kind. A grid full of things that interact: players that shouldn’t walk through walls (but should walk through each other), bombs on timers, explosions that propagate down rows and columns, destructible bricks, and — the fun part — chain reactions when one blast catches another bomb. You cannot fake that with a bigger OnTick. It needs real collision, and it needs the logic to be made of small, ordered pieces.

It needed the S.

Building the S

So I built the Systems layer for real, on top of Arch: an ISystem contract, a SystemRegistry wired straight into the server’s tick loop, and — the important bit — phases. Every tick runs Initialize → BeforeUpdate → Update → AfterUpdate, and a system declares which phase it belongs to (and, when it needs to, that it runs After<AnotherSystem>()). A small GameSystem base does the one boring cast into the world so no system has to; a DelayedSystem<T> drives any timer-shaped component down to zero and fires once.

ONE SERVER TICK Initialize BrickGeneration BeforeUpdate PlayerVelocity Update PhysicsSystem the one true step AfterUpdate PendingDestruction PlayerReadback after<PhysicsSystem>

Order comes from the phase, not from where the code happens to sit.

The payoff of phases is subtle but big: ordering stops being an accident of where code sits in a method and becomes something you declare. That matters in about two paragraphs.

Building physics

Then the physics I’d been pretending I had. NetNgine.Physics is a swappable provider behind a contract — an IPhysicsProvider, a deterministic grid/AABB implementation, and collision layers and rules so a game states what collides with what. Bomberlike uses that to exclude players from their own collision mask, so they pass through each other but not through walls. A companion NetNgine.MapAsset loads the static map — walls, floor, spawns — from JSON, instead of a hardcoded 13×13 grid baked into the demo.

Physics is now a module you plug in, not something each demo reinvents. Same principle as everything else in the engine: a small contract, a swappable implementation.

Migrating Bomberlike

With both layers in place, Bomberlike got smaller. The server simulation is now a handful of systems, each doing one thing, ordered by phase. Bombs stopped being a special list tracked by hand and became plain entities:

// A bomb is just an entity with a Bomb component (its fuse). Nothing more.
world.Create(new Bomb { FuseTicks = 120 });

// Its entire behaviour is one small system:
sealed class BombFuseSystem : DelayedSystem<Bomb> {
    protected override void OnElapsed(Entity bomb) => Detonate(bomb);
}

And chain reactions fell out almost for free: a bomb caught in a blast just gets force-detonated one tick later through the same fuse machinery, instead of waiting on its own unrelated timer. The old bespoke bomb-tracking code got deleted outright — which is the tell that a refactor was worth it. You remove more than you add.

The bug it surfaced (of course)

If you read the last post, you know how this goes. Wiring up the system that reads player positions back out of physics, I noticed players moving at exactly double speed. The cause: physics was being stepped twice a tick — once by the old hand-rolled call still sitting in OnTick, and once by the shiny new PhysicsSystem I’d just registered.

// BEFORE — two steps a tick:
void OnTick() {
    _physics.Step(dt);   // old hand-rolled step, still here
    // ...
}
// ...and the newly-registered PhysicsSystem ALSO calls _physics.Step(dt)
//    => every entity moved at 2x speed

The fix wasn’t to delete one line and move on. It was to pull velocity into its own BeforeUpdate system, so the order — velocity, then one physics step, then readback — is guaranteed by the phases, not by which line happens to run first. The demo made me look, again.

And then I tried to run two of them

Here’s where I’ll leave it, because it’s the good part. Systems: done. Physics: done. Bomberlike running clean with four players connecting and disconnecting. So I opened two lobbies to watch two matches at once — and both of them landed in the same game world. Two separate matches, one shared session, players from one game wandering into the other.

That’s not a bug in the lobby, and it’s not a bug in the physics. It’s a much more interesting hole: a NetNgine server hosts exactly one world per process, and nothing — not the server registry, not the signed connection token — carries an id that says which match you belong to. The matchmaker’s honest stopgap right now is to refuse to put a second match on a busy server at all, which works right up until you actually want two matches on one machine.

So it turns out the missing letter wasn’t the only thing hiding in this engine. But giving a match an identity — and letting one process hold more than one world — is the next post.