Public: anyone can read this; the crew can change it.

forked from trevato/cs

Your own body stops waiting for the network: your movement is predicted and reconciled against the server's word #383

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

In a room, your own body now moves on the frame you press instead of a round trip later. Each snapshot puts it back on the server's word and replays the inputs the server hasn't seen yet on top. Only movement is predicted: shots, hits, damage, armour, ammunition, the weapon, money, score and the round stay the server's word.

shipped production is serving it deploy ae07c2d

opened this

In a room, your own body now moves on the frame you press instead of a round trip later. Each snapshot puts it back on the server's word and replays the inputs the server hasn't seen yet on top. Only movement is predicted: shots, hits, damage, armour, ammunition, the weapon, money, score and the round stay the server's word.

The wire gets one more number (#316 asked for this to be called out). A snapshot's tick counts the room's steps, but an input is stamped with the member's own count of frames sent, and the two clocks never line up. So the room now records the stamp of the last input it applied to each seat (room.applied, set in driveSeat, cleared on leave). snapshotFor adds that stamp as a 4-byte u32 after every other field. A page still open on the previous deploy reads the same frame it always did. A frame without the stamp is read as ack: null, and the page then takes the server's position as before. The pinned snapshot hex is unchanged.

Same physics, by construction and by test.

  • wishOf is pulled out of applyInput in world.ts, so the server's tick and the replay build their movement from the same function. The replay then calls the engine's move with CS_MOVE and CS_HULL.
  • The input the replay uses is the sent bytes read back through the room's own readInput, so both ends use the same 16-bit yaw and 7-bit stick values.
  • A weapon switch is resolved with the server's resolveSwitch, because a switch changes run speed on the tick it's pressed.
  • Both ends build the level with levelNamed (new, in map.ts, the only client file allowed to name DE_FORGE). A test asserts the level built from hello equals the room's, for de_forge and a generated map.

src/client/predict.ts is plain data in and out:

  • keep stores an input and steps your body by it.
  • reconcile rewinds to own.pos/own.vel, drops inputs at or before the ack and replays the rest.
  • place writes position, velocity, floor contact and crouch onto world.me, and nothing else.

The stated numbers:

  • History: capped at 36 ticks, a 500 ms worst round trip (the point where the page already says the connection is behind) plus one snapshot interval.
  • Corrections: under 32 units (the hull's width) they're eased out over 100 ms; over that the body snaps.
  • When prediction is off: before hello, before a snapshot that carries the ack, and while dead or spectating. The other nine bodies are untouched.
  • Where it plugs in: session.ts holds the predictor, and main.ts passes the frame's tick count to press and calls session.place(now) before the camera reads your position.

What to look at: the first test in src/client/predict.test.ts. It steps a real Room headless on a 3-second script (running, strafing while turning, a jump, a crouch, a walk, a knife switch, a half stick) with 8 ticks of latency each way, a 250 ms round trip. The predictor is fed that room's snapshots:

  • Measured: the page's body matches the room's within 1.1e-4 units at every one of 196 ticks, and the largest correction over 51 snapshots is 1.4e-4 units. The limit is 0.01.
  • It catches mistakes: a replay that ignores the weapon switch misses by 1.2 units, and a 1% speed change misses by 0.77. Both fail.

Other tests cover:

  • non-movement fields staying the snapshot's even with a switch in the replay
  • dropping acknowledged inputs and the history cap
  • prediction being off before an ack and while dead
  • other bodies staying the snapshot's
  • easing versus snapping
  • the trailer's round trip and backward compatibility
  • the room's ack
  • the session end to end

src/budget.test.ts measures a 20-tick replay at 0.063 ms against a 0.5 ms ceiling.

Chosen not to do:

  • What the replay doesn't know: the slow from being hit (tag, not on the wire) and a round starting or ending inside the round trip are left to the next snapshot's correction.
  • No interpolation: other bodies still move in 62 ms steps; that's its own issue. The README's "Not yet" list loses the movement line and says what stays the server's.
  • Tests: no existing test was changed.

How to try it: open two tabs on the preview and join the same room. Your own movement should answer the keys immediately, and other bodies should still step at 16 Hz. bun run test passes: typecheck and 1553 tests.

🤖 Generated with Claude Code

Closes #316

commented

In a room, your own body now moves on the frame you press instead of a round trip later. Each snapshot puts it back on the server's word and replays on top the inputs the server hasn't seen yet. Only movement is predicted: shots, hits, damage, armour, ammunition, the weapon, money, the score and the round stay the server's word. This update merges main (#378, #379, #381), which conflicted in the README and in src/client/session.ts. Both sides are kept. In the README, the movement line leaves "Not yet" and main's new sentence stays: a room's people are only visible from inside it. In session.ts, the prediction state sits beside main's here/side for the room line.

The wire gets one more number (#316 asked for this to be called out). A snapshot's tick counts the room's steps, but an input is stamped with the member's own count of frames sent, and the two clocks never line up. So the room now records the stamp of the last input it applied to each seat (room.applied, set in driveSeat, cleared on leave). snapshotFor adds that stamp as a 4-byte u32 after every other field. A page still open on the previous deploy reads the same frame it always did. A frame without the stamp is read as ack: null, and the page then takes the server's position as before. The pinned snapshot hex is unchanged.

Same physics, by construction and by test.

  • wishOf is pulled out of applyInput in world.ts, so the server's tick and the replay build their movement from the same function. The replay then calls the engine's move with CS_MOVE and CS_HULL.
  • The input the replay uses is the sent bytes read back through the room's own readInput, so both ends use the same 16-bit yaw and 7-bit stick values.
  • A weapon switch is resolved with the server's resolveSwitch, because a switch changes run speed on the tick it's pressed.
  • Both ends build the level with levelNamed (new, in map.ts, the only client file allowed to name DE_FORGE). A test asserts the level built from hello equals the room's, for de_forge and a generated map.

src/client/predict.ts is plain data in and out:

  • keep stores an input and steps your body by it.
  • reconcile rewinds to own.pos/own.vel, drops inputs at or before the ack and replays the rest.
  • place writes position, velocity, floor contact and crouch onto world.me, and nothing else.

The stated numbers:

  • History: capped at 36 ticks, a 500 ms worst round trip (the point where the page already says the connection is behind) plus one snapshot interval.
  • Corrections: under 32 units (the hull's width) they're eased out over 100 ms; over that the body snaps.
  • When prediction is off: before hello, before a snapshot that carries the ack, and while dead or spectating. The other nine bodies are untouched.
  • Where it plugs in: session.ts holds the predictor, and main.ts passes the frame's tick count to press and calls session.place(now) before the camera reads your position.

What to look at: the first test in src/client/predict.test.ts. It steps a real Room headless on a 3-second script (running, strafing while turning, a jump, a crouch, a walk, a knife switch, a half stick) with 8 ticks of latency each way, a 250 ms round trip. The predictor is fed that room's snapshots:

  • Measured: the page's body matches the room's within 1.1e-4 units at every one of 196 ticks, and the largest correction over 51 snapshots is 1.4e-4 units. The limit is 0.01.
  • It catches mistakes: a replay that ignores the weapon switch misses by 1.2 units, and a 1% speed change misses by 0.77. Both fail.

Other tests cover:

  • non-movement fields staying the snapshot's even with a switch in the replay
  • dropping acknowledged inputs and the history cap
  • prediction being off before an ack and while dead
  • other bodies staying the snapshot's
  • easing versus snapping
  • the trailer's round trip and backward compatibility
  • the room's ack
  • the session end to end

src/budget.test.ts measures a 20-tick replay at 0.063 ms against a 0.5 ms ceiling.

Chosen not to do:

  • What the replay doesn't know: the slow from being hit (tag, not on the wire) and a round starting or ending inside the round trip are left to the next snapshot's correction.
  • No interpolation: other bodies still move in 62 ms steps; that's its own issue.
  • Tests: no existing test was changed.

How to try it: open two tabs on the preview and join the same room. Your own movement should answer the keys immediately, and other bodies should still step at 16 Hz. After the merge, bun run test passes: typecheck and 1576 tests.

🤖 Generated with Claude Code

Sign in to join this conversation.

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs