The server has no height, so everyone fought through the bridge
Reported from play, and once you see it you cannot unsee it: an agent walks across one of the wooden bridges, a Looter wades into the river underneath, and the two of them fight — through the planks. The agent swings at the water. The Looter swings at the underside of the deck. Damage is exchanged, correctly, by two bodies that are eighteen units apart vertically and can't see each other.
The world only has two dimensions
The simulation on the server is flat. Every body is an (x, y) pair, and there is no third number anywhere in it. Height is something the browser decides: it samples the terrain, checks the standable boxes it has built, and puts the mesh at whatever altitude the ground turns out to be.
For a world made of hills, that's not a shortcut — it's the right call. Terrain height is a pure function of position, so both ends agree without the server ever storing it, and every fight in the game happens on ground where "how far apart are these two" is honestly answered by their (x, y).
A bridge is the one place in this world where two floors stack. It is the only geometry where one (x, y) means two different places, and everything that broke is downstream of that single fact.
A chase is a straight line to the mark. If the mark is standing on the deck, the straight line walks the enemy off the bank, into the channel, and parks it under the bridge — where the distance check says you are right next to each other and the melee gate agrees.
The renderer already knew
The tell was that the browser was never fooled. Enemies pick their floor through a rule that only allows a short step up:
const ENEMY_STEP = 1.4;
A body may step onto a standable box whose top is within 1.4 units of the floor it is currently on. The deck is eighteen units up, so the browser refused it, correctly, and drew the Looter standing in the river.
That mismatch is the symptom, not the bug. The sim believed two bodies were touching; the renderer knew one of them was in the water. Any time your simulation and your view disagree about where something is, the interesting question isn't which one to fix — it's which one is holding a fact the other one doesn't have. Here the browser held the fact, and it held it in a place the combat code could never reach.
One bit of height
The fix is three pieces, all server-side, and the middle one is the whole idea.
A deck is reached by its ends. deckApproach() intercepts any goal that lands inside a bridge footprint and, for a walker that isn't already up, returns the foot of the ramp on the walker's own side instead of the mark. Once you're past the foot, you get the real goal back and the ramp is simply the next thing in front of you.
The "past the foot" test has to be directional, and that took a try to learn. My first cut was "near the foot", which deadlocks beautifully: a body just inside the foot reads as near it, turns for the mark, walks until it's no longer near it, turns back for the foot, and shudders on that line forever. If a condition can flip by a body acting on it, it isn't a state — it's an oscillator.
The only height in the simulation is one boolean, and it is decided by which edge you crossed to get in:
_deckState(e, prevX, prevY) {
const b = bridgeAt(e.x, e.y);
if (!b) { e.onDeck = false; return; }
if (e.onDeck) return; // already up, and still on the span
e.onDeck = Math.abs(prevX - b.x) > b.hw // came in through an end…
&& Math.abs(prevY - b.y) <= b.hd; // …rather than around a corner
}
The footprint cannot tell planks from water — that's the original problem restated — but the boundary can. The ends are the x edges; the channel is the y edges. Enter across an end and you walked up the ramp; enter across the side and you waded in underneath. Six lines, and they are the entire vertical axis of this game.
And nobody swings through a floor they aren't on. One predicate, used by enemy melee, enemy ranged, and the player's own attack, in both directions:
function _deckSplit(enemy, p) {
const b = bridgeAt(enemy.x, enemy.y);
return !!b && !enemy.onDeck
&& Math.abs(p.x - b.x) <= b.hw && Math.abs(p.y - b.y) <= b.hd;
}
Two things the detour broke on the way
Adding a route that isn't a straight line quietly violates assumptions made by everything that measures straight lines.
The leash. An enemy gives up when its mark gets too far away. On a span 2300 units long, a body that started under the middle is 1400 units from either end — so a mark that is reachable reads as too far the moment the detour starts, and the enemy spends the whole fight walking out and giving up, walking out and giving up. It now measures the smaller of "distance to the mark" and "distance to the waypoint I'm currently walking to", so committing to a detour doesn't count against you.
The push-apart. Bodies shove each other so two enemies never stand on the same ground. A mark directly overhead is zero units away horizontally, so the shove fires at full strength and the body rattles back and forth under the bridge forever. A body on a funnel leg now skips the spacing pass.
Both of these are the same bug as the original: a rule that was written when (x, y) distance meant what it says.
What I'd do differently
I didn't add height, and I'd make that call again. The tempting fix is a z on every body, and it would have been wrong — a third coordinate on every entity in the world, forever, to describe one piece of geometry that exists four times on the map. What the problem actually needed was a name for which of the two things this rectangle is right now, which is one bit, set at the moment you enter.
Look for the place where one coordinate means two things. That is the real generalisation. Not "the server needs height" but: my position key stopped being unique, and every system built on it — chasing, reach, spacing, the leash — broke in its own dialect at the same moment. When you find one of those, expect the fallout in four systems, not one.
It needed tests, because you cannot eyeball it. Reproducing this by hand means getting an enemy to wade to the right patch of river while something stands on the planks above it. There's now a bridgeDeck suite, and it is the only reason I believe the directional test doesn't deadlock.
The accepted loss: at the far end of a very long span, the walk round to the ramp exceeds the leash and the enemy loses interest and goes home. That's fine. An enemy giving up on someone standing across a bridge is a thing the world is allowed to do. Swinging through a floor isn't.