Skip to content
Crowd Control
← Devlog

Vector3, a true friend

· 5 min read
netngine netcode architecture

I’ve started expanding NetNgine past its first minimal version, and the very first expansion — moving the engine’s Position from a two-axis vector to a three-axis one — did something I didn’t expect. It didn’t break anything by itself. If anything, it did me a favour.

Adapting the demos to the new model made me look closely at the snapshot pipeline, and there I found a bug that had been quietly wrong for a long time. This is the story of that bug, because it’s a good one, and because the fix taught me something I keep having to re-learn: where you put a decision decides what it’s allowed to know.

Where I was

NetNgine’s job is to keep a server and its clients agreeing about a shared world, cheaply, over UDP. Every tick, each client gets a snapshot: only the entities in its area of interest, and only the parts that changed since last time (delta encoding).

There’s a catch. A UDP packet has a ceiling, and a busy scene can have more visible entities than fit. So the server has to cap how much it sends per tick. In the first version that cap was blunt and lived in the demo host: take the first N visible entities, hand those to the sync layer, done.

For top-down demos where Position was a Vector2, that held up fine — including a headless load test with a few hundred bots.

Why I touched it

The expansion I’m doing has a concrete goal: a future 2D platformer needs a real synced vertical axis for gravity and jumps, and the old two-axis Position had nowhere to put it. So step one was moving Position.Value from Vector2 to a new Vector3 — additively (I kept Vector2 for the things that are genuinely 2D, like raw input direction), with every construction site stating its third axis explicitly instead of defaulting it.

That change compiled and passed the tests. The interesting part came next, while retrofitting the demos onto a new shared server host and porting that entity cap into the engine proper.

The bug

Two distinct problems, both in the capping logic.

Trimmed entities were reported as removed. The sync layer computes “what left your view” by comparing what you know about to what’s in this snapshot: anything known but absent is marked Removed. But the cap ran before the sync layer ever saw the list. So when an entity was trimmed purely for lack of room, it reached the sync layer as “absent” — indistinguishable from “genuinely left the area.” The client was told it was gone. Worse: an entity that never once won a spot under the cap would never appear at all — permanently invisible, silently.

Updates could be lost forever. Change-detection recorded a component as “sent” before knowing whether the entity would fit the tick’s budget. Skip that entity for space, and its real change was marked as already delivered — dropped, and never retried unless it happened to change again on its own.

Here’s the shape of the mistake:

// The cap lived in the demo host, OUTSIDE the sync layer:
var visible = interest.GetVisibleEntities(client)
                      .Take(MaxEntitiesPerSnapshot);     // trim first...
var snapshot = sync.GenerateSnapshot(client, visible);   // ...then diff
interest --> [ trim to N ] --> GenerateSnapshot
                                   |
                                   +-- removed = known - received
                                       a trimmed entity looks removed   <-- bug

And here’s the friend part. None of this was caused by Vector3. The migration didn’t introduce the bug — it did me the favour a good friend does, walking me over to the thing I’d been avoiding and pointing at it. It gave me a reason to re-read code I’d stopped looking at, and there the bug was, in plain sight. That’s most of what the reworked demos are worth right now: not that they’re fun yet, but that they make me look.

The fix

The real problem wasn’t the trimming; it was where the trimming happened. Capping outside the sync layer looked harmless and saved a parameter — but it destroyed the one distinction the sync layer needed: trimmed versus genuinely gone.

So the cap moved inside. GenerateSnapshot now always receives the full, uncapped interest list plus a real budget, and does the trimming itself, where it still knows everything:

// Hand over the FULL list and a real byte budget; trim inside.
var visible = interest.GetVisibleEntities(client);            // uncapped
var budget  = transport.GetMaxSinglePacketSize(client, chan); // real per-connection ceiling
var snapshot = sync.GenerateSnapshot(client, visible, budget);
interest --> GenerateSnapshot(full list, byte budget)
                |
                +-- ComputeRemoved       from the FULL list -> real exits only
                +-- SelectEntitiesToSend  trim to budget, by priority
                +-- UpdateKnownEntities   mark sent only what actually got sent

A few things fell out of that:

  • A real budget, in bytes. “N entities” was a guess. Now it’s an actual payload budget derived from the transport’s true single-packet ceiling, with size estimates measured against the real serializer version rather than hardcoded numbers that quietly rot across a library upgrade.
  • A trim priority that means something. When something has to be dropped, drop by importance, not by creation order: entities the client doesn’t know about yet come first (an unsent entity skipped forever is a permanent loss), then, among known ones, whichever changed the most since it was last sent.
  • Smaller pieces. GenerateSnapshot broke into single-purpose steps (ComputeRemoved, SelectEntitiesToSend, UpdateKnownEntities), which is what let me write a regression test for each failure directly: trimmed-not-removed, genuine-exit-still-removed, priority-by-change, and the lost-update case.

The lesson

The bug and its trigger had nothing to do with each other, and that’s the part worth keeping. The fix wasn’t clever — it was moving a decision to the place that had the context to make it. Capping is a sync-layer concern, because only the sync layer knows what “gone” means; making that call one layer too early is what blinded it.

That’s the same principle NetNgine leans on everywhere: small, fixed contracts, and each decision made at the boundary that actually has the information for it. The core never knows a game’s rules; the sync layer should never have been robbed of what it knew about its own snapshots.

So: thanks, Vector3. A true friend tells you what you didn’t want to hear. Onward — more demos, more places to look.