Netcode
Server-authoritative with client-side prediction for the local car and interpolation for everyone else, exactly as Technical specs §6 describes. The implementation is in packages/net-client (no DOM, unit-tested against a second in-process "server" world) and apps/server/src/game-server.ts.
Rates and sizes
| What | Rate | Size |
|---|---|---|
| Client → server | 60 Hz | a frame of the last inputs (codec.ts' kind 7: 10 bytes, the acknowledged snapshot tick and the newest input's number, then 11 bytes per input, one over a WebSocket and four over a transport that loses frames; the single input of 16 bytes, kind 1, is still read) per tick: each input's axes, flags, the aim as a 16-bit turn, the target, cover flags, the grenade's throw, the item keys and the selected slot (the weapon held is the server's to work out from it), a garage's service, the phone in two spare flag bits) |
| Server → client | 20 Hz | 12 bytes + 87 per entity + 1 + 26 per event (full snapshot, f32; a person's last bytes are what it does with its hands, Animation, and its life under its id, gen); as deltas, a change is 4 bytes and the groups that changed, its mask carrying the entity's tier (bits 11–12) and, over a transport that loses frames, a pose's age (bit 13, a byte) |
| Remote render lag | — | 6 ticks (100 ms) behind the newest snapshot, plus the entity's tier's delay: nothing within 80 m, 3 ticks to 160 m, 9 ticks to 250 m (TIER_DELAY_TICKS), so a far entity is drawn between two of its measurements |
| Extrapolation cap | — | 9 ticks (150 ms) using the last velocity, for an entity in motion whose next measurement is late; one at rest is held where it was measured |
A join. The server welcomes a player on its loop, between two steps, so whatever a join costs holds every player's next snapshot: the step histogram never sees it (the steps stay short), the loop's late steps and the snapshots' spacing do. On Riverbend a join used to hold the loop for 60 to 150 ms (the level's 6.4 MB of JSON stringified into the welcome, or made with its hash for the first client that asked for the level by hash), a 120 to 155 ms gap in everyone's snapshots at every join. Now a room makes its level's JSON and hash as it opens (level-files.ts), a welcome that carries the level splices that JSON in (encodeWelcome), the bots and the netcode recorder join as the web client does (the level by its hash, tests/load/server.ts' joinUrl), and a join costs the loop one to three milliseconds, the rest of addClient (the player's car and character, the roster) included; apps/server/src/join.integration.test.ts holds it to 25 ms, half a snapshot interval, for the step a join lands in and the twelve after, and holds the newcomer's first snapshot to two snapshot intervals after its welcome. The first player into a room is held to the same: a room's world used to run its first step as that player joined (130 to 175 ms of Rapier's broad phase over Riverbend's 8,000 colliders), then fill its streets in steps of 30 to 45 ms for a second (a car a tick, a person every two or three), and the first snapshot came 200 ms after the welcome, with nobody else in the room to see it. Now a room is warmed as it opens (GameServer.warmUp, called by RoomHost.open: the default room at boot, a level's or the platform's room before its request is answered): it ticks with a stand-in for the players at their first spawn point until the traffic and the crowd are out and the steps have been under the 25 ms for a snapshot interval's worth of ticks, on a whole half second of ticks so every periodic job keeps its phase, at most ten seconds of ticks, and logs warmed in 150 ticks, 1352 ms (Riverbend with its game; a small test level warms in 30 to 60 ticks, 100 to 200 ms). The room clock then starts at the game's time, as after a level reset, and the welcome's tick is the warmed one: a room's tick never starts at zero. The first player's steps then read 6 to 13 ms, like anyone's.
The mirror
A client is a character; it drives a car when seated on its driver's seat. The player's inputs always go to the character (the server forwards them to the car it drives), and the entity the player controls follows the snapshots: the character on foot, the car while driving, nothing while riding as a passenger.
Mirror.applySnapshot(s) keeps a physics twin of every snapshot entity in the local world (packages/net-client/src/mirror.ts): twins are spawned when an entity first appears, set to their snapshot pose on every snapshot (class and seat changes included), and removed when the entity leaves. The server hands the ids of the entities that left out again, so one snapshot's person can be the next one's car under the same id (a level reset, a mission started again, the traffic and the crowd coming and going): a twin is of its entity's kind, made anew when the id comes back as another, and the entity the player controls is followed by its twin, not only by its id. Before that, a car whose id had been a person's stayed a person's capsule in the local world: at its wheel, the keys walked it where they point and it turned to the mouse, every snapshot putting it right again (E2E-525). The controlled entity is the one twin that is predicted instead of placed. Because every other car and person has a body in the local world, the predicted car collides with them where the server will make it collide, instead of driving through them until the correction arrives; that was the "entangled cars" of the first POC.
The client simulates only what it predicts
A crowded room lists a few hundred people and cars, and the local world steps once a tick and again for every input a reconciliation replays: simulating every twin in full cost more than the frame had. So on every snapshot, before the replay, the mirror sorts the twins (Mirror.assignProxies):
- Simulated in full: the controlled entity (with its trailer), the player's own character, and every twin that may touch the controlled entity (or the player, riding along) before the next snapshot: within
NEAR_MARGIN(9 m, the longest body's reach) plus what both cover at their snapshot speeds over the replay and the wait for the next snapshot (mayTouch). A car the player rams is pushed, a person it brushes is shoved, as the server will. - Proxies, everything else (
@foi/sim'ssetProxy,proxy.ts): a kinematic body that stays where its snapshot put it. It is still solid to the simulated bodies (in the collision groups its twin is given: the proxy of a ghost blocks nothing) and still found by rays (the line of fire, a tracer's end), but never integrated: the vehicle and character systems pass it over (no controller, no wheel raycasts, no walking, no unstick), a step reads none back from Rapier, and a resting kinematic body costs Rapier's step nothing (a proxy car does not look ahead for what it will hit either: no soft CCD, and the step's decision on how many physics steps to take leaves out the proxies far from what is simulated). Where it goes until the next snapshot is no concern of the prediction's: it cannot reach the controlled entity before then, or it would not be a proxy.
The cost of a step, and of every replayed input, grows with what is simulated, not with how many people and cars the snapshots list: in the chaos benchmark (64 cars, 60 pedestrians, 26 chasers, the police) the twins went from all simulated (0.8 fps) to a handful (see Performance). The server never makes a proxy: its world, and the determinism gates, run as before. Which twins are drawn at the local time is a wider set, decided on the same snapshot from a longer horizon (below): a proxy in it is drawn at its measurement carried on by its velocity for the ticks the prediction runs ahead.
The controlled entity
Predictor.applyLocal(input) quantizes the input, applies it to the controlled twin, steps the local world and remembers the input with its sequence number and the state it produced (the pose, the state that integrates over time, and the light pieces lying loose near it). Predictor.reconcile(mine, lastSeq) on every snapshot holds the server's word against what the prediction had for the tick the snapshot acknowledges, and drops the inputs the server has applied; then one of three things (An impact is the prediction's). The prediction stands: the server agrees with it on the car and on every light piece near it, and nothing else near it is simulated, so nothing is rewound and nothing replayed. It agrees among simulated twins: the car is set to the server's state and the inputs in flight replayed with the twins brought up to the present, the prediction's own present kept if the replay comes out otherwise. Or it differs: the rewind, the twin set to the server's state (vehicle class, position, rotation, velocities, steering, and the state that integrates over time: the boost meter and its spent flag, the landing stun timer, the two grips, the tyres, the health) and the inputs in flight replayed. Getting in or out and carjacking (E), a class change (V), a reset (R) and a level reset are sent but not predicted: the server decides them, under a half-second cooldown that is not in the snapshot. A carjacker's half-second beat at the driver's door travels as the snapshot's jacking flag, which holds the twin still (no walk is predicted off the door) until a snapshot seats it. The mirror switches the controlled entity (dropping the pending inputs, which were meant for the old one) when the next snapshot shows a change of seat, and the reconciliation adopts the class and the pose the snapshot shows, about half a round trip later. Predicting them went wrong: a snapshot taken before the server saw the request undid it, the replay redid it only once the local cooldown had run out, and the car flipped between the two classes for a moment, sometimes one class too far. It returns the correction vector, which the renderer feeds to SmoothingOffset: an offset drawn on top of the true pose that decays with a 50 ms time constant, so a 10 cm correction is never visible as a jump. A correction larger than the decay could hide at the car's pace is taken at once instead (snapsAt). The decay moves the drawn car by twenty times the offset a second, so the bound is a third again the car's pace (a quarter of a metre a second at the least) over twenty: 1.25 cm at rest, 37 cm at 80 km/h, the share the remotes' blend goes by. Above 3 m a correction is taken at once whatever the pace (a reset should look like a teleport). Before, a 40 cm correction at a crash was eased over 150 ms on a car that had stopped, and the drawn car moved on after its stop.
An impact is the prediction's
The player's car is predicted in the local world with the level's colliders, and soft CCD makes one physics step a tick solid against the level at any speed, so the prediction stops the car at a wall on the tick the server does. Measured in the browser by the impact probe (npm run test:netcode -- impact, tests/netcode): the page's car is driven straight into something at 80 km/h on the keys and window.__impact() is read every frame, three runs each at local latency and at a simulated 150 ms of round trip, on test-collisions, test-breakables, test-traffic-junction, Riverbend and the live platform. What made the crash not happen at once was not the stop:
- What the player heard and saw of it was the server's. The crash's sound (
weapon.hit) and the hit log's line came with the server'shitevent, a trip across the network and up to a snapshot after the stop: 117 to 133 ms (seven or eight frames) at local latency, 233 to 283 ms at 150 ms. The camera shook for blasts only. Now the prediction tells the renderer of a crash as it feels one (Predictor.takeFelt: the local world's ownhitwith no weapon on the controlled entity, the tick after the stop), and it is heard, shaken (CameraShake.impact, by the hit points) and shown in the log on that frame, with the local world's number. The server'shit, when it comes, is matched to it (a crash on the player's car that the snapshot's acknowledgement reaches): it sounds and shows nothing again, and its number replaces the shown one if they differ. A crash the server never confirms (the prediction met something the server's car did not) has been heard, and its line stays; the damage is the server's, whose health the next snapshot puts back. A crash the server tells of that the prediction missed (a twin's driver did what its coasting guess did not) is heard when its word comes, as before. - The correction at the crash came from the replay, not from a difference. At 150 ms the car was corrected 34 to 76 cm at the crash, eased over 100 ms on the stopped car. A replay is not a pure function of the snapshot and the inputs: Rapier keeps a contact's manifold and the impulses of its last step (the solver's warm start) across a pose set back, and this build gives no way to switch that off. Every snapshot set the car back from the crash it had already run, and the replay met the wall on the manifold of its own future; the blow landed on another point, and the car came off the wall spun where the server's did not.
packages/net-client/src/crash-correction.test.tsreproduces it: 8 cm at one input in flight, under a centimetre at five and eight (the first snapshot after the crash came before any replay crossed far into it), 46 cm at fifteen, 116 cm at twenty-one, and 95 cm at a crash at 110 km/h that killed the driver. A rollback that drops the contacts first (a new collider, a kinematic ghost walked along the confirmed path) is another history, and the next crash came out as far off. So a prediction the server confirms is never run again: the first pass through a crash, from a world that never touched the wall, is the server's to the bit, and it stands. Now nothing is corrected at any of those leads, and nothing is rewound through the crash. - A light piece stood on screen while the car went through it. A hydrant or a lamp the car knocks loose is predicted by the same energy rule as on the server (
breakables.ts), but it was drawn from the snapshots, a lead and the interpolation delay behind. It stood untouched for 13 frames (217 ms) after the prediction knocked it loose at local latency and for 19 to 21 at 150 ms, the car drawn 2.3 to 4.2 m on through where it stood by then, and the knock made no sound at all (the server never sends itsbreak). And every snapshot from before the knock planted it again, for the replay to knock loose on new colliders: the car came off it 13 to 55 cm from the server's. Now a loose piece near the player is drawn where the local world has it (PredictedPieces, the cars' principle), from the tick it comes loose until it rests out of reach, blended with its snapshots' drawing at the cars' pace bound when it goes onto or off that timeline. The knock is heard on its frame (vehicle.knock, the local world's ownbreakof the player's car). And the pieces near the car are part of what the prediction stands on (Predictor.standing: a piece's word is compared within a step of its quantized pose), so the snapshots leave them alone while the server agrees. Now the knock is heard on its frame and the piece drawn flying one or two frames after it, the car drawn touching it, with nothing rewound through it. - The camera kept going. It looked 0.45 s ahead along a velocity filtered over a quarter of a second, 7.5 m ahead of the car at 80 km/h, and followed that point an eighth of a second behind: at the stop the view panned on past the car and then back onto it at up to 18.5 m/s for over a second. Now a blow (a change of velocity within a tick over
IMPACT_MIN, the simulation's own rule for a crash) makes the look-ahead take the new velocity at once and hold the lead it loses (LookAhead.impact) for a fifth of a second, then let it go at 6 m/s. And the follower is carried by the velocity before it eases (CameraFollower), so it trails a car at a steady speed by nothing. The picture stops within one to three frames of the car and comes back onto it at under 7 m/s.
What stays, and the rules at its edges:
- The prediction stands only where nothing else near the car is simulated, since the other drivers' and walkers' next moves are no part of the snapshot. Among traffic or a crowd the replay runs. The confirmed prediction is kept over it only where the car met no other car or person in the ticks in flight (
kept: the replay's difference can then only be its own contacts, a manifold of the future). Where it met one, the replay carries the server's fresher word on it (a driver who braked where its twin coasted) and is taken, a crash it finds felt as news. Kept regardless, it threw that word away: two crashes in three with a car braking at the junction went unfelt by the prediction and were heard with the server's word, seven frames late. Now three in three are heard on the crash's frame. - What the prediction stands on is the whole of what the crash changes: the car, the light pieces near it, and the player's character at its wheel (its hit points: a crash that wrecks the car kills the driver in the seat, and a driver dead in the seat parks the car). A snapshot from before the crash put the dead driver back to full health in the present, and the car the server had parked was not parked here: the server's word on the crash's own tick then disagreed, by 0.3 m/s, in one run in three, a 15 cm correction. Now the driver's hit points are compared, and a prediction that stands keeps them.
- A server's crash hit is matched to the one felt on the same entity (the car, the driver, a person run down), and any other crash hit of the blow (the other car's, which nobody is charged with) is heard already. The fire of a burning wreck and its blast are the server's alone by the rules, and its word on them corrects the prediction when it comes.
- The drawn car stops within a tick of the prediction (
alpha). - At a simulated 150 ms of round trip, the server ran four to nine of the page's ticks on a guess (inputs that came too late) in every six-second run: each is a tick driven on the last input held, a correction when the player's input changed on it.
/healthcounts them for the room (net.late).
window.__impact() reads all of it frame by frame: the predicted car and its contacts, the drawn car and the offset on it, the camera, the crash's and the knock's sounds and the hit log's lines, the hits told by the prediction and by the server, the last snapshots' word with their corrections, the last predicted ticks, the light pieces near it (local and drawn), and the predictor's counts of the snapshots it stood on, kept over their replay and rewound. /health counts the server's ticks by their physics steps (substepTicks). E2E-642 to E2E-644, E2E-646 and E2E-647 hold the impact to it.
What is not replayed: the wheelspin and the suspension state (they live inside Rapier's controller or follow from it), and the other twins' inputs between snapshots (the near ones coast, their drivers holding the steering and the handbrake their car's snapshot shows, since the throttle is not in it; the proxies rest where their snapshot put them). They converge within a few ticks, and the integration test shows corrections under 0.5 m at 150 ms RTT, including during drifts. Anything that integrates over time must travel in the snapshot instead, because the replay re-steps it on every snapshot: before the boost meter did, the client's meter drained up to four times faster than the server's, its boost ended after half a second and every snapshot corrected the car by up to 12 cm for the rest of the boost. The integration test pins boosting at 150 ms RTT to corrections under 5 cm.
Shots are the server's: fire is sent but never applied in the prediction world, so nothing is fired twice by a replay (the client applies the same rule as the server before drawing its cosmetic tracer: seated, the pistol only, forward only). The client draws its own hitscan tracer the moment the trigger is pulled (a cosmetic, with the local world's ray, from the muzzle of the gun in the figure's hand) and ignores the server's echo of its own shots; rockets and grenades are entities in the snapshot like any other, never predicted. A trailer rides with its tractor: the snapshot carries its pose and tyres, reconciled with the cab before the replay.
Events
A snapshot also carries the shots and blasts since the previous one (events): a shot is an origin, a yaw and the length to the first thing hit (one per shot; the renderer fans a shotgun's pellets out itself), a blast a point and a radius.
Other cars and people
What is drawn for everything the player does not control comes from the snapshots, not from the twins: each remote entity has a RemoteBuffer of the last eight measurements the server made of its pose. The renderer asks for the pose at ClientClock.renderTick, which is the estimated server tick minus six (see The clock; never clamped to the newest snapshot), less the entity's tier's delay (below): between two measurements it interpolates position, nlerps rotation and lerps steering. A vehicle's position follows the cubic Hermite curve through both measurements with both measured velocities, so its drawn velocity is continuous at every measurement as the car's own is: on straight lines a far car accelerating at 2.5 m/s² was drawn at each segment's average speed, a step of half a metre a second every fifth of a second (the probe counted 3,200 to 3,700 such speed spikes a minute on Riverbend's traffic; the curve leaves 180 to 200). The curve is drawn only where it is a motion the car could have made on its own: never running backwards along its way (a car at rest a few ticks after rolling at speed stopped against something) and asking no more acceleration than its full brakes give, about 40 m/s², plus the wire's rounding; a blow, a shove or a relocation keeps the straight line. A person or a dog changes its velocity within two or three ticks (30 m/s² to its speed, 45 to a stop), at a moment the measurements do not say, and keeps the straight line too. The speed a remote is animated at is the speed its pose is drawn moving at, so a figure's feet and its body agree; past the newest measurement of an entity in motion it extrapolates with the velocity for up to 150 ms, then holds; one at rest it holds where it was measured, which is exact.
Measurements only. Every frame the snapshot reader shows says which entities it changed, which it measured (their pose came, with the tick of the measurement: FrameNews.posed), which it removed and the tier of each change, and the one rule of what a frame does to the remotes (RemoteIntake, @foi/net-client; the netcode probe runs the same code) pushes a sample for the measured ones and nothing else, at the tick measured: the decoded snapshot lists a far entity's last pose again at every tick between its turns, and nothing is made of that. Before 0.68 the renderer recorded such a pose for an entity it took to be standing (its velocity zero), a sample the server never made: a pedestrian at rest got forty filled-in copies and then its first real step twelve ticks away, drawn in three (a burst of 4.5 times its speed, a slide of its feet); and the buffer dropped a moving entity's pose that came again unchanged, a heuristic over the same hole, which lost the stop of a far car whose quantized place had not changed (its wheels turning on the spot, a figure walking on the spot, 4,458 frames a minute on Riverbend). Nothing is filled in or dropped now.
A stop and a start are news. The server sends a far entity's pose on its turn (AOI_TIERS) and whenever its rest changes against the pose the client holds (changedGroups, snapshot-v2.ts: at rest is a quantized velocity across the ground within 3/64 m/s of zero on both axes, atRest; a parked car settling on its springs is at rest), so the client learns of a stop and a start in the very next snapshot, whatever the turn. The same rule bounds the motion between two measurements on the client: from a measurement at rest to the next in motion the entity stood until one snapshot before the second at the latest, from one in motion to the next at rest it had stopped one snapshot before the second (motionWindow), so a start is never spread over a long stand and a stop never drawn as a crawl.
The tier on the wire, a render delay per tier. Far from the player the server sends a moving entity's pose only every second (80 to 160 m) or fourth snapshot (160 to 250 m, AOI_TIERS); every change of an entity carries its tier for this client in its mask (TIER_EVERY: every snapshot, every second, every fourth; a tier that changed alone goes as a change with no group, four bytes), and each RemoteBuffer draws its entity behind the render tick by its tier's period less one snapshot (0, 3 or 9 ticks, TIER_DELAY_TICKS), so the tick drawn always lies between two of its measurements and a far car is never guessed on between its turns and corrected at each (a sawtooth of its speed, 5,000 spikes a minute at the far tier against 900 within 80 m before 0.68). The delay follows the tier by at most 5 % of a tick per tick (RENDER_DRIFT), never by a jump: an entity coming nearer catches up five per cent faster for a few seconds, one going away falls five per cent behind. A pose drawn past the newest measurement (a late sample) is a guess, the last velocity carried on: when the next measurement says where the entity really was, the difference is drawn away over six ticks (BLEND_TICKS) rather than jumped, and over longer when it is more than those ticks’ worth of motion (an entity held through a stall of the link), at no more than a third again its pace (BLEND_PACE_SHARE), so it catches up without a dash; a difference of metres, a teleport, is taken at once.
Identity per lifetime. bitECS hands a freed id straight to the next entity made, so a pedestrian the crowd takes away and another it puts out in the same step are one id (packages/sim/src/lifetimes.ts): the simulation keeps a generation per id, turned over on every removal (every removal goes through removeSimEntity; the lint holds it), the snapshot carries it (EntityState.gen, the record's last byte), and an encoder that holds an entity whose generation changed sends it as removed and whole again in the same frame, so the client makes a new remote (a fresh buffer, a fresh figure) and never draws one moving from where the old one stood (a third of the teleport jumps at Riverbend's market gate before 0.68). An entity that leaves the area of interest is removed the moment the server says so, never held at the edge (it was held two seconds, then removed, and jumped on coming back: half the freezes); the server keeps an entity a client holds already in its snapshots to 1.1 times AOI_RADIUS (AOI_KEEP), so one wandering about the edge is sent once and removed once.
Trailers and projectiles are drawn the same way, and so are the light pieces of the level a fast car knocked loose (a hydrant, a lamp): a snapshot lists every loose piece as a prop entity carrying its placement's index in inside, the renderer moves that placement's own instance, and the mirror puts the client's copy of the piece where the server has it (loose at its pose, planted again once the snapshot lists it no more), so a predicted car meets the piece where the server's does. Pedestrians are characters nobody owns, moved by a server-only system (pedestrianSystem); the clients' twins of them are proxies, unless near the player (they then coast between snapshots). The netcode probe (tests/netcode) measures all of it against the server's word; its invariants (tests/netcode/harness.test.ts, stale-samples.integration.test.ts) hold every car within 1.3 times its authoritative speed in every tier, no sample that is no measurement, no figure sliding or walking on the spot, no teleport within 80 m and no freeze at a reappearance.
Snapshots arrive a tick early or late against the frames now and then (the network, the server's timer, the frame a message waits for), and a stall holds several back and delivers them together: the render tick runs on a filtered estimate of the server's time that neither can move (The clock). Before 0.61, every car and person on screen moved a tick (22 cm at 48 km/h) forward or back whenever a snapshot came early or late; until 0.67 a stall halted them all and its end leapt them forward by the stall.
Cars near the player are drawn at the local time
Interpolated, a remote car is drawn a hundred milliseconds behind the newest snapshot, which is itself half a round trip old, while the player's own car is predicted ahead of the server by the inputs in flight: near the player the two timelines show. Measured over a simulated network (packages/net-client/src/predicted.test.ts, 130 ms round trip): a chaser at 30 m/s ramming a sedan at 10 m/s from behind was drawn 7.8 m short of the car when the prediction felt its push (12.9 m at 280 ms), a push across a visible gap; a car the player rams at 30 m/s was drawn 1.9 m inside the player's. In the browser, a ramming chaser on test-collisions was drawn 1 to 2.5 m away at each push.
The mirror already simulates, at the local time, every twin that may touch the controlled entity before the next snapshot; such a car or trailer is drawn where its twin stands (PredictedRemotes, Mirror.predicts and Mirror.presentPose), the way racing and car-combat games extrapolate the cars around the player, so the picture, the local prediction and the server agree on where the bodies meet: the same runs draw the chaser 0.2 m from the car at the push (the hulls reach past the boxes measured) and the rammed car never inside it. Each snapshot resets the twins to the server's word and replays, and the jump that makes in a predicted car (its coasting guess against what its driver did) is drawn away as a decaying offset like the player's own correction (N drops both). A person on foot near the player's car is drawn the same way, where its twin stands: interpolated, a pedestrian the car hits at 60 km/h was drawn standing while the car was already through where it stood, and then flung from a hundred milliseconds and a network trip behind, inside the car. Drawn at the local time, the hit shows on the frame the car reaches it, and its figure goes limp then (Mirror.predictsDown: the prediction knocked it down, car-hits.ts being the same on both sides) rather than when a snapshot says so, a round trip later (E2E-447, mirror.test.ts). window.__poc().contact measures it: the drawn gap to the nearest car, the deepest overlap since the page opened, and the drawn gap at every touch the prediction feels (E2E-310, E2E-311).
The blend between the two timelines is bounded to the car's pace. A car that comes near is blended from its interpolated pose to its predicted one, and back when it leaves, so it never jumps between the two timelines. The two poses are the same car a timeline's gap apart (the six ticks of render delay and the inputs in flight: 120 to 250 ms, a metre or two of a car's motion), and until 0.69 the blend took a fixed quarter of a second whatever the gap: covering 120 ms of motion in 250 ms adds half the car's speed to its drawn motion for that quarter of a second, a dash of 1.5 to 2 times its pace at every car that came near (measured in the browser by the netcode probe, sixty seconds each, laid at the mechanism prediction: at Riverbend's market gate on foot 3 bursts of up to 2.11 times the pace, on the pavement beside Broadway 2 of 1.8, driving Broadway 15 of up to 2.25, its near band's ratio at the 99th percentile 2.1, 1.8 and 5.7 times). Now the blend goes at no more than a third again the car's pace, as the remote buffer draws a guess away (BLEND_PACE_SHARE, BLEND_PACE_FLOOR): its length is max(PREDICTED_BLEND_SECONDS, gap / pace) with pace = max(BLEND_PACE_FLOOR, speed × BLEND_PACE_SHARE), the gap measured between the two poses every frame (PredictedRemotes.apply), so a car accelerating or braking stays within the bound too; a 1.4 m gap at 12 m/s takes 0.35 s each way and the car is drawn at 16 m/s through it, never more (predicted.test.ts). It is a blend in space (the drawn pose moved from the one timeline's to the other's) with a length set by the gap: a blend in time, the car's drawn moment advancing from now − delay to now, would need the twin's trajectory over the inputs in flight, which the replay makes anew at every snapshot and keeps nowhere, and the straight way between two poses of the same car a tenth of a second apart cuts a corner by centimetres.
The zone is entered from as far as the blend needs. A blend that takes longer must start earlier, or a fast closer meets the player's car before it is on the predicted timeline (a chaser at 100 km/h with a 6 m gap between its timelines takes 0.65 s). So the mirror draws a car at the local time from the moment it could touch the controlled entity within the blend's length plus the replay and the next snapshot's wait (Mirror.predicts, decided at every snapshot: mayTouch with that horizon, the blend's length worked out from the car's speed over the timelines' gap, blendSeconds; a car closing at 27.8 m/s on a player at rest is drawn at the local time from 21.5 m with nothing in flight, from 35 m with nine inputs in flight, mirror.test.ts), and keeps it there until it has been out of that reach for one blend's length (a quarter of a second at least), so a car hovering at the edge goes onto the predicted timeline once and comes off once, never back and forth with the jitter of its measured place. The simulation's own set is unchanged: only a twin that may touch the controlled entity before the next snapshot is simulated in full (the step's cost stays what the player can touch); a car in the wider zone is still a proxy, drawn at its measurement carried on by its velocity and its spin for the ticks the prediction runs ahead (presentPose), and when it comes into the simulated set its twin takes over at the same place, the difference (centimetres) an offset that decays. The same three runs after: no burst from the blend in any of them (the near band's ratio at the 99th percentile 1.37, 1.34 and 1.36 times, under the bound's 1.33 and the measurements' own few per cent), the far tiers at zero as before, and what remains in the band is the twins' snapshot corrections (SmoothingOffset, decaying at 20 a second whatever the entity's pace, as the player's own): none at the gate or the pavement, three driving Broadway, on a car and a walker crawling under a metre a second drawn at 1.4 to 1.8 m/s for a frame (2.3 times, the worst), 1,600 to 2,250 corrections a minute of a centimetre's median, 0.22 m at the most. Those decay by their own rule, not the pace's; it is the next thing to measure.
The frame loop runs the ticks its time owes, three at most (frameTicks, clock.ts): a slow frame must not make the next slower by catching up all at once. Up to three more are kept for the next frames, and the whole ticks a stall owes beyond that are dropped and counted: their inputs are never sent, so the client's inputs stay numbered by its clock, and the prediction runs them on the server's guess, as the server does (Predictor.guess, see below). The frame loop draws between ticks: the controlled entity (and whatever else the local simulation moves: its trailer, the predicted twins) is interpolated between its pose before the latest tick and its current pose by how far the accumulator has run into the next tick, alpha. Without it, the frame clock and the tick clock drift against each other and about twice a second a frame moves the car zero ticks and the next one two, a 0.4 m hitch at 100 km/h. It costs at most one tick of visual latency. The remotes are drawn at the clock's render tick alone, which runs on the frames' own time and needs no alpha (until 0.67 it advanced with the ticks and alpha filled the fraction; the two timelines are apart anyway, the controlled entity predicted ahead of the server, the remotes drawn a hundred milliseconds behind it).
The clock
The client runs its own 60 Hz tick accumulator. The server's snapshot carries how many of this client's inputs were still queued; the client shortens its tick interval by 3 % when the queue is about to run dry and lengthens it when the queue grows beyond four. The server drops the oldest inputs beyond eight, so a client that keeps running ahead gets corrected rather than delayed.
The time the others are drawn at is another clock (ClientClock, packages/net-client/src/clock.ts), kept in real ticks: it is advanced by every frame's elapsed time, never by the simulation's ticks (whose rate the nudge bends by 3 %), and before the frame's snapshots are read, so that each is observed at the time it arrived. Every snapshot is an observation of the server's offset from that time, its tick less the local time. A snapshot can be late (the network, the server's timer, a stall, the frame it waits for) but never early, so the largest offset seen is the nearest to the truth: the top of the window is the largest of the last two seconds of observations (CLOCK_WINDOW_TICKS, about forty snapshots); a late snapshot gives a smaller one and cannot raise it, a stall gives none, and the burst of six that ends a 300 ms stall raises nothing. The estimate does not chase the top: it lives in a band a tick wide under it (CLOCK_SLACK), where it starts and holds. Above the top (every recent arrival says the path got slower: the render tick would outrun the snapshots) it comes down to the top at 5 % per tick (RENDER_DRIFT, unseen in a car's pace; the buffers extrapolate meanwhile), below the band (the earliest arrivals say the remotes could be drawn with less delay) it rises to the band's floor at half a per cent per tick (CLOCK_SLEW, a tick in 3.3 s: there is no hurry to shed a tick of delay). The earliest arrival of a window is rare, so the top rises when one comes and falls back by the jitter when it ages out, and an estimate that chased it, as the first filtered clock did, was a sawtooth of 5 % pace changes on everything on screen every few seconds (11.6 % of the frames of a plain Riverbend replay off their pace); in the band the jitter moves nothing. A discontinuity (the estimate more than six ticks outside the band, RENDER_SNAP_TICKS: a server restarted, its tick jumped) is taken at once only once it has persisted for a whole window, never on one observation; in the clock's first two seconds, with no history to be fooled by, it is taken at once, which is how the snapshots held while the level built (seconds of them, handed to the first frame together) put the clock on the server's time from that frame. The render tick is the estimate less the interpolation delay, at real time's pace, never clamped to the newest snapshot: through a silence each remote's buffer extrapolates to its own limit (150 ms) and holds, and after nine ticks of silence the renderer holds their animation too (SILENT_TICKS). A tab in the background for seconds, its snapshots piled up, costs nothing afterwards either: the one frame that follows covers its time, the pile's newest snapshot sets the top a tick or two low and the next one corrects it.
Until 0.67 the estimate started again from every snapshot as it arrived, and the render tick was clamped to the newest. Measured on a Riverbend trace with a 300 ms stall injected (npm run test:netcode -- replay … --stall 40000:300, the netcode probe): every remote stood for ten frames, and the six late snapshots then leapt them fifteen ticks in three frames (1.6 and 1.9 m at 50 km/h); with the filtered estimate the same replay draws every frame at its pace (none off it by half a tick, none backward, the only trace of the stall the 50 ms the buffers hold at their extrapolation limit before the late samples blend in), and a 100 ms frame of the page moves the remotes by its own seven ticks instead of the simulation's catch-up (three ticks a frame for the frames after, a burst). packages/net-client/src/clock.test.ts pins the contract (a stall and its burst, jittered arrivals with the earliest rare at half a per cent of pace, a persistent discontinuity against a one-off early snapshot, the path's delay growing and shrinking for good, a local clock drifting, a paused tab, a join), tests/netcode/harness.test.ts the replay (H2, H6), and window.__stall(ms) holds a page's snapshots for E2E-640; window.__clock() reads the frame's drawn tick.
A client's ticks, on the server
A client's input n is its tick n, and the server runs exactly one of a client's ticks with each of its own, from the first input on (game-server.ts). When the input for the tick due is not in (the page stalled for a frame, a garbage collection, a lag spike, the server's own catch-up after a slow step), the server runs the tick on a guess, the last input held without its one-shot presses, and that tick is spent: the input meant for it, arriving later, is too late to steer, and only its presses are kept, for the next tick run. Before 0.42 the server ran the late inputs too, after the guesses: every hitch of the page drove the car on for the hitch twice, once on the guess and again on the inputs it stood for, and the state it acknowledged as input n held more than n of the client's ticks, so the client's replay put the car ahead of where the server had it. The player saw the car stall with the page and then lurch forward to catch up (2.7 m after three 100 ms hitches at 150 ms of round trip in the browser, E2E-448; a metre a hitch in server.integration.test.ts). The guess is what the player was doing (the throttle held), so a hitch now needs no correction at all; only a change of input during it shows, by what the guess missed.
The prediction runs those ticks on the same guess (guessedInput, @foi/protocol, the one rule the server's queue and the client share). The ticks a stall drops beyond the frame's backlog are never sent, so the server runs them on its guess, and the prediction runs them on it too (Predictor.guess, a quarter of a second of them at most, MAX_GUESSED_TICKS). A stall longer than the lead lets the server run on past the page's present, through the ticks the page owed and had not sent yet: a snapshot acknowledging such a tick has the ticks up to it run on the guess first, before anything of it is applied (Predictor.catchUp), and is held against the prediction's own record of its tick; the inputs of those ticks, sent after, are too late for both, and their presses count on the server's next tick. Until 0.69 the prediction skipped the dropped ticks and was set to such a snapshot's word as to a disagreement ("no record"): the local world stood off the server's by a tick of motion for each tick the two had run apart. A frame of 67 to 100 ms four or five frames after a crash into a wall, which the browser renderer takes there in software (the e2e's; on a GPU every frame of it is 17 ms), dropped one to ten ticks in three runs of four and cost 8 to 25 cm of correction a third of a second later (E2E-642); a 200 ms stall there, 58 to 75 cm over three rewinds; now none, the snapshots standing on the prediction (E2E-646, and server.integration.test.ts: a quarter of a second stuck at 60 km/h, 97 cm of correction before, none now).
Behind its schedule (a late timer, a slow step), the server catches up one tick per callback, the next one on setImmediate (loop.ts): Node reads the sockets between the two, so a tick runs on the inputs that came while it was behind instead of on guesses.
The transport
Every frame goes through @foi/protocol's Transport (send('reliable' | 'unreliable', frame), onMessage, onClose): the inputs and the snapshots are unreliable, everything else (the welcome, the city's and the missions' frames, the chat, the resync request) is reliable. By default one WebSocket carries both, ordered and reliable. A server started with FOI_RTC=1 also offers a WebRTC data channel for the unreliable frames: UDP, nothing retransmitted by the page, nothing held up behind a lost packet. The WebSocket stays open beside it for the reliable frames, and takes everything back when the channel fails.
Setting it up (@foi/protocol's rtc.ts). The welcome says the server offers the channel (rtc: true). The page then offers one over the WebSocket ({ type: 'rtc', op: 'offer', sdp }), the server answers (answer), both send their ICE candidates as they find them (candidate), and either says close (with why) when it falls back. Both ends create the same channel by its number, { negotiated: true, id: 0, ordered: false, maxRetransmits: 0 } (RTC_CHANNEL), so no message of the channel's own opens it. There is no STUN server (iceServers: []): the server is reached at its own address (its host candidates, or the one it announces) and learns the page's from the page's checks (a peer-reflexive candidate). On a machine's loopback the channel opens in about 0.5 to 0.7 s from the connection, the worker's start included.
The server (apps/server). http.ts wraps every connection a room takes in withRtc (rtc-transport.ts), which keeps the signalling from the game and routes each frame: the unreliable binary frames on the channel while it is open (the transport is then lossy), the rest over the WebSocket; what comes either way reaches the game. The peers live in a worker thread (rtc-worker.ts, werift 0.24.4, pure TypeScript), started on the first offer (rtc-host.ts): the game thread never loads werift nor spends its ticks on DTLS and SCTP. The two threads exchange one message per event-loop turn each way, the turn's frames packed into one buffer that is moved, not copied (rtc-wire.ts). The worker holds FOI_RTC_MAX_PEERS channels at most (128), runs eight handshakes at a time (the next offers wait their turn), answers one offer at a time (werift tries the range's ports one by one, and two peers looking at once could take the same), closes a channel not open 8 s after its offer, and drops the frames for a channel that holds more than 256 kB unsent. A worker that stops (a crash) closes its channels, and the next offers wait 1 s before it starts again, twice as long after each next stop up to 30 s. The game server hears of each change of lossy through the transport's onLossyChange.
Falling back. The server sends everything over the WebSocket again (lossy false) when the channel fails or closes (it did not open in time, its connection failed, the worker was full or stopped: the page is told close), when the page says close, when 30 binary frames in a row come over the WebSocket while the channel is open (half a second of inputs: the page fell back without a word; it is told), when the debug command rtcDrop drops the server's side (the page is not told: its own watch must find out), or when the WebSocket closes. The page (apps/client/src/renderer/rtc.ts, withRtc under holdMessages) falls back when the channel has not opened 8 s after its offer, when the connection fails or the channel closes, when nothing has come on the open channel for 1.5 s (the server sends twenty snapshots a second; not while the room is frozen, which the pause message says), and when the server says close. It offers again only after a channel that had opened: 3 s later, twice as long after each fall back in a row up to a minute, the count forgotten once a channel stays open a minute. A channel that never opened (no UDP to the server: a proxy, ngrok, a firewall) costs nothing: the page plays over the WebSocket meanwhile and does not try again. ?rtc=0 keeps the page on the WebSocket.
Seeing it. The HUD's connection line says connected via udp or connected via ws; window.__poc().net.transport is udp or ws and net.rtc the channel's state, openings, fall backs and the last one's reason. The legend's Server tab says the same and, on the channel, has a Drop the data channel button (the debug command rtcDrop, where debug commands are on): the page falls back in about 1.6 s (E2E-521). The server's /health counts the channels under rtc (open, on their way, opened, fall backs by reason) and its metrics are foi_rtc_* (Deploying).
What crosses. Frames on the channel may be lost, duplicated or reordered, and at the moment the snapshots move from one path to the other a frame of the channel can overtake the WebSocket's last one (the page runs the two paths' messages as separate tasks). Making sense of that is the snapshots' business, not the transport's: the transport says when it is lossy. Two quirks of werift 0.24.4 are worked around or known: its DTLS numbers its first records after the handshake from 0 again, repeating its Finished record's number, which Chromium's replay window drops (one early message of every channel was lost; the worker moves the count past it once the DTLS connects), and its SCTP never gives up a message when maxRetransmits is 0, so the server's side of the channel resends a lost frame once at most (SERVER_RETRANSMITS: the acked snapshots never need it, and a stale frame resent until it arrived would only fill the backlog), while the page's own frames are never resent.
| Switch | What |
|---|---|
FOI_RTC | 1 offers the channel (off by default; npm run dev turns it on; the desktop app's Play local keeps it off). |
FOI_RTC_PORTS | The UDP ports its peers bind to (40000-40999); a peer takes one per address it gathers on. Under a supervisor (FOI_PROCESSES) each process gets an equal slice. |
FOI_RTC_ADDRESSES | The local addresses to gather on and bind to (at most one IPv4 and one IPv6, comma-separated); all the host's by default. |
FOI_RTC_PUBLIC_IP | The address announced in the candidates instead of the local one: a host behind a NAT that forwards the same ports (Docker's published range). |
FOI_RTC_MAX_PEERS | The most channels the process holds (128, and no more than its ports). |
Over a transport that loses frames
A WebSocket delivers every frame, in order; a WebRTC data channel opened unordered and without retransmits (maxRetransmits: 0) does not: its frames are lost, duplicated and reordered, and a late one is never worth the wait. A transport like that says so (Transport.lossy), and both directions change over it.
The inputs travel as frames of the last few (codec.ts' kind 7: u8 kind | u32 ack | u32 newestSeq | u8 count | count × input, newest first, each input's number implied): every tick the client sends its last four over a lossy transport (one over a WebSocket), so an input lost in one frame comes in the next three (InputHistory, @foi/net-client). The server's InputQueue (apps/server/src/input-queue.ts) takes a frame's inputs oldest first and puts each in its place by its number: one older than the newest queued but not yet run is not late; one it already took (run on its tick, its presses carried, given up) is skipped, so a press sent in four frames presses once. A late one's presses are carried onto the next tick as before, once: the queue remembers which of the last 64 ticks' inputs it took, so a copy of a late press arriving a tick later does not press again (with one input a frame and a socket that never duplicates, that double press could not happen; with four it would, a few ticks apart, and only the simulation's own cooldowns hid it from getting in and out). While it sends no inputs (the room frozen for it in the pause menu), the client sends the acknowledgement alone every quarter of a second.
The snapshots cannot be the ordered deltas over it: each of those goes onto the one before it, so one lost frame would cost a keyframe. They are acked instead (@foi/protocol's snapshot-acked.ts, kind 6). The server keeps computing, for each client, what an ordered client would hold after frame N (S_N: every entity's record, and whether its pose came full), exactly as the ordered encoder does, and frame N carries what turns the newest frame the client acknowledged (S_B, the ack of its input frames) into S_N: the entities gone, the groups of bytes that differ, a position as small steps from the base's. So every frame the client shows is what an ordered client would show at that tick, whatever was lost between. The base can be any recent frame, so whether an entity's pose is full or quantized travels with each change (POSE_FULL), not inferred from the history. The server keeps the last 32 frames it sent from the acknowledged one on (a frame older than the acknowledged one is forgotten) and the client the states of its last 64, so every base the server picks is one the client still holds; with nothing acknowledged (the first frames, an acknowledgement lost for 32 frames) the frame is a keyframe, onto nothing. The events carry a serial per client and go again in every frame until a frame holding them is acknowledged, for 20 frames at most; the client shows each once, above the newest serial it has shown. Every 60 frames a check follows the header (the ordered deltas' FNV-1a of the state the frame produces). A frame over 16 kB goes on the reliable channel; it is a frame like any other. Without loss and with the acknowledgement back before the next frame, a frame costs at most four bytes more than the ordered one (the first event's serial); at 150 ms of round trip the base is three or four frames back, so what moved since then goes again.
A client behind on its acknowledgements (a tab in the background, whose game loop stops; a slow link; a queue in front of it, such as a WebSocket's compression with simulated loss on) is sent one snapshot round in four once twelve acked frames wait unacknowledged (ACKED_IN_FLIGHT in game-server.ts, about 0.6 s, twice what a 250 ms round trip keeps in flight). Frames sent faster than it reads them would outrun the 32 the encoder keeps: its acknowledgements would then name frames already forgotten, every frame after would be a keyframe, and the keyframes, ten times bigger, would keep it behind. Measured with 32 bots on the hillside before the cap: 131 to 174 kB/s a client of keyframes; with it, 48 to 54 kB/s, never a keyframe after the first.
The client reads every snapshot frame through one SnapshotReader (snapshot-reader.ts): whole (kind 3), ordered deltas (kind 5) or acked ones (kind 6). A frame whose tick is not newer than the last shown is stale and dropped (Mirror.applySnapshot ignores an older one too); an acked frame onto a base the client does not hold is dropped without a word (the next one, onto a newer acknowledgement, will come), so the acked snapshots never ask for a keyframe; the server switches a client between the ordered and the acked deltas when its transport changes, and the first frame of the new kind is a keyframe (one of the new kind that is not is dropped until it comes). What a frame tells (changed, posed, removed, tiers) is against the last frame shown, whatever base the frame went onto: a pose carried again that the client already showed is no new measurement, and a pose measured in a frame that was lost travels with its age, so the client records it at the tick it was true (E2E-484). snapshot-acked.property.test.ts holds it all to the ordered deltas: 200 seeded worlds of 300 frames (entities coming, moving, changing, going; the player's own switching between full and quantized poses; events) through a channel that loses up to 35 % of the frames, duplicates them and reorders them by up to five, the acknowledgements lost and late too: every frame shown is the ordered client's at its tick, in order, every event shown once (unless every frame that carried it was lost), never a keyframe asked for, both sides' memory bounded. apps/server/src/lossy.integration.test.ts plays the game over it (10 % lost, 150 ms): corrections under half a metre, every press of E taken once.
Judging it by hand: K loses 0, 2, 5, 10 or 20 % of the frames a lossy transport may lose (the snapshots coming in, up to 16 kB, and the input frames going out; never the text frames), with up to 30 ms of jitter so a few overtake each other (latency.ts); the HUD's latency line shows it (loss 10 %). Losing frames on purpose, the page says its transport is lossy (its inputs go four to a frame) and, over a WebSocket, asks the server for the acked snapshots (ACKED_REQUEST; ORDERED_REQUEST when K is back at 0). __poc().net reads the kind of the snapshots, the frames shown, stale and dropped, and those the loss took (E2E-518).
Judging it
Ladds artificial round-trip time in the client (both directions, before the socket). Drive at 150 ms: the car must still feel immediate andcorrectionmust stay near zero on straights and small in drifts.Kloses a share of the snapshots and the inputs on purpose, with some jitter (see above): at 10 % the car must drive on as it does without, and every press ofEland once.Pturns prediction off: the car becomes the raw 20 Hz authoritative stream, which is what the server actually sees. The difference is what prediction buys.Nturns smoothing off to see corrections as they really are.- Run into a wall, a parked car or a lamp at speed: the crash is heard, shaken and shown on the frame the car stops, and nothing moves after it.
npm run test:netcode -- impact --scenario wall --rtt 150measures it (tests/netcode). - Two browser windows show remote interpolation: the other car should move smoothly at 60 fps from 20 Hz data and lag the real position by about 100 ms.
The integration test apps/server/src/server.integration.test.ts does the same with bots over the memory transport at a 5-tick one-way delay and asserts the numbers.
What comes next (M2 → M7)
A snapshot-level test of the bandwidth budget; area of interest beyond the tiers. The deltas, the quantized poses, the acked snapshots and the inputs four to a frame are in (above), and so is the WebRTC data channel that carries them (The transport), the WebSocket kept for the reliable frames and as the fallback: the Transport interface's reliable | unreliable parameter and its lossy flag left everything above it unchanged.