The code was correct and the game felt wrong

USURPENT is a real-time multiplayer snake game. The server runs the simulation at 20 ticks a second and streams snapshots over a WebSocket. The browser predicts your own serpent between snapshots and draws everything. The README states the split plainly: "The backend simulation is the single source of truth; the frontend is a thin renderer."

On 2026-08-24, between 15:13 and 17:34, eight of the day's 23 commits were corrections to how the game looked or felt. Six of them are the subject of this note. None was reported by an error, and none broke a test, because the repository had no tests yet. The first test file arrived at 19:22 that evening, with a performance change.

What set it off

At 14:43 came a performance fix, e3705ec, "Slow girth and length growth so the sim keeps its tick". With bots on a leaderboard running to 30,000 points, snapshots were going out every 101 ms against a 50 ms budget: "The sim was running at half its tick rate." The biggest bodies carried about 8,000 points each, and every head was checked against all of them every tick. So growth slowed down. A serpent now reached full girth at a score of 10,000 instead of 90, and grew in length a tenth as fast.

That was measured, and it was right. And over the next three hours the game started to feel wrong in places the commit never touched. Most serpents now spent most of their lives thin, which is exactly where an old body-spacing bug lived. The first hundred points of growth became nearly invisible: girth went from 6.00 to 6.18. And when the growth curve was fixed, it brought a pickup bug back into reach.

Six fixes, before and after

Each panel draws one fix from that afternoon, before in grey and after in blue, computed from the constants and formulas in the code on either side of its commit. Red is the gap a player could see. Hover a panel to read its values.
The six fixes as a table
commitwhat changedfixed onbeforeafter
754c77f 17:34pickup reachserverpellet eaten within r + 10 at every girthwithin r + the larger of 10 and girth
6ea6a5a 17:19merge distanceserver0.8 × (r1 + r2): 28.8 for a crumb in a radius-34 blobthe larger of that and the big radius: 34
20a9d74 15:59body point spacingbothone point per update: bodies up to 2.0x their length walking, 3.6x boostinga point every spacing: 1.0x
aee8ba3 16:05correctionsclienta 4-unit correction shown in one frameshown as an offset that decays with a 50 ms time constant
5d905d2 15:13pellet gravityservera flat 0.70 u/s at any distance1.60 u/s at point-blank, 0.32 at the rim
dad19e1 16:09growth curveservergirth 6 + 0.0018 × score: 6.18 at score 100a log curve: 8.70 at score 100

The six are not one kind of fix. Sorted by which side was wrong, they make four.

The picture was right, and the rule was wrong

The last two of the afternoon are the clearest. The pickup test was:

limit = food["r"] + config.FOOD_PICKUP_PAD

The head is drawn as a circle with a radius of girth, so head and pellet meet at girth plus the pellet's radius. The rule only ever reached the pellet's radius plus 10. In the commit's words, the pad "was silently doubling as 'how big is a head'", which is true at the starting girth of 6 and wrong at every size above 10. At full girth a pellet could sit 14 units inside the drawn head and stay there. The fix takes whichever is larger, the pad or the girth.

The rule had been girth-blind since girth was added on 2026-08-20, and for most of those four days a serpent passed girth 10 by a score of 20. The slower growth at 14:43 hid it, by moving girth 10 out to a score of 2,222. The log curve at 16:09 brought it back in to 179, and 85 minutes later it was fixed.

The merge rule had the same shape in the other direction. Two pellets fused when their centres were closer than 0.8 times the sum of their radii. That is right for pellets of a size and wrong when one is much bigger: a crumb of radius 2 against a blob of radius 34 fused at 28.8, five units inside the blob's own drawn edge, so it sat there, visibly inside, until gravity dragged it the rest of the way.

Seven minutes after the pickup fix, 36995e2 pulled the three "how close is close enough" calculations into one place, each taking both radii, with a diagnosis worth keeping: "two of the three left out one of the two radii involved. Neither looked like a missing term; both looked like the collision detection being vaguely off".

In both of these the screen was telling the truth about the geometry. The server's rule was out of step with it, and the fix treated the picture as the specification.

Both sides were wrong, differently

The body is a list of points along the path the head has travelled, one every spacing units. Both sides added at most one point per update. On the server the head covers 4 units a tick (7.2 boosting), so whenever the wanted spacing was less than that, points landed a full step apart instead. At girth 6 the server laid a body twice as long as its own length said, and 3.6 times as long while boosting. The client ran the same rule once per frame, at 60 frames a second, and laid its points about 2.67 units apart where the server laid them 4 apart. Neither was the 2 units asked for. So the two sides described the same path with two different sets of points, and the client swapped one for the other twenty times a second. That was the shimmer.

The commit's table checks girths 6, 12 and 24, and reports the last two as correct. They are, and they are almost the only ones. At girths 12 and 24 the wanted spacing happens to sit just under a multiple of the four-unit step, which is the only place the old rule comes out right. At girth 13 the body was 1.85 times its length. The third panel above is the whole curve.

This is the one fix of the six that a test would plainly have caught. The server's body disagreed with the server's own length.

The client lies, on purpose

Even with both sides agreeing on the path, a correction from the server was still applied in a single frame. With the camera locked to the head, a 4-unit correction moved the whole scene three times as far as a normal frame does.

aee8ba3 is the only one of the six that changed only the client, and it does it by drawing something slightly untrue. The state still takes the server's position exactly, so error cannot pile up. But the difference is drawn as an offset that fades with a 50 ms time constant, one snapshot interval. The comment says it in a line: "Take the server's truth into the state, but not into the picture all at once." The commit's own measurement, while steering hard: 0 frames of 966 stepping more than 3 units.

The repo's rules said the client "renders what the server sends". For about a quarter of a second after each correction, it now shows the serpent somewhere the server says it isn't. That is the right lie. The player can't see the server.

Nothing disagreed, and it still felt wrong

The other two are not about the server and the screen at all. The screen drew the simulation exactly. The simulation was the problem.

Pellet gravity summed a mass-weighted pull and then normalised it to a direction, so distance cancelled out of the speed entirely: "a crumb at the rim of the pull range crept in at exactly the pace of one already touching a blob. It read as a conveyor belt." It now ramps with closeness, from 1.60 units a second at point-blank to 0.32 at the edge of the pull range, against a flat 0.70 before. That is harder up close and also weaker far out, and the second half matters as much as the first.

Growth had been linear in score, which the commit describes from the player's side: "The first hundred points moved a serpent one percent along it and felt like nothing at exactly the moment a new player is deciding whether the game is doing anything". On a log curve, a serpent's first twenty pellets take its length from 16 units to 94, instead of 16 to 20.

Which side you correct

It is tempting to say the client is the one lying, because the client is where the player sees the problem. That turned out to be true for one fix in six. Four of the six changed only server code. The pattern that holds is narrower:

  • When the screen is right about the geometry, fix the rule. The player sees a pellet inside a head. The picture is the requirement, and the simulation is an implementation of it.
  • When the two sides disagree, make them follow the same rule, and then check that rule against what it claims.
  • When the picture has to be smooth, let the client lie a little, and keep the state honest underneath so the lie can't accumulate.
  • When nothing disagrees and it still feels wrong, the model is the bug. No error will ever point at it.

Why nothing caught them

The obvious answer is that there were no tests. The more useful one is in what got tested that same evening. The repository gained its first three test files after 19:00, all for performance changes: an interest grid, delta-encoded food, binary snapshots. A performance change comes with an oracle, because the new path has to produce what the old path did. A feel fix has no oracle but the screen.

But every one of the six messages carries numbers: a table, a measured speed, a frame count. Each fix was measured by a harness that was then thrown away. Several of them could have been kept as tests, once the invariant is stated in terms of the picture rather than the code. A pellet drawn overlapping the head is eaten. A crumb whose centre is inside a blob fuses. A body's drawn length is its claimed length. No frame moves the head more than three units. None of those is exotic. They just weren't anyone's idea of a test until the screen showed what breaking them looks like. As of this writing, none of them is in the suite.

There is an irony in the timing. That morning, the repo's agent instructions gained a warning about the food grid: size a cell too small and "a head can sit on a pellet and not eat it", with "no error, just a pellet that won't pick up." It was written about a different cause. The pickup rule itself had been producing exactly that symptom for four days, and kept producing it until 17:34.

That is the category: a defect with no error message, found by someone playing and noticing. The fix is the easy part. The part worth keeping is the measurement that turned "this feels off" into a number, because it is the only test this kind of bug will ever have.