five-on-it / packages/net-client/src/predictor
packages/net-client/src/predictor
Prediction and reconciliation for the entity the local player controls (their character on foot, their car while driving; TECHNICAL_SPECS §6.2):
- Every local tick the input is quantized, applied to the player's character (which forwards it to the car it drives, exactly as on the server), the world is stepped, and the input is remembered as pending with the state it produced. What the server alone decides is sent but never applied locally (see
predictable): the next snapshot shows the outcome. - On a snapshot the server's word on the controlled entity is held against what the prediction had for the same tick (the state remembered with the input the snapshot acknowledges: the pose, for a car the state that integrates over time, and the light pieces lying loose within PIECE_SCOPE of it), and the inputs the server has applied are dropped. Then one of three things:
- The prediction stands (Predictor.standing): the server agrees with it (AGREEMENT) on the controlled entity and on every piece near it (QUANTIZED_AGREEMENT: the pieces travel quantized), and nothing else near it is simulated in the local world (the
Mirrorsays so: no twin of a car or a person within reach). Nothing is rewound and nothing is replayed: the local world keeps its present, and theMirrorkeeps the snapshot's word off the pieces near the car. - It agrees but other bodies near it are simulated (a car it rams, a person it brushes, whose drivers' and walkers' next moves the snapshot does not carry): the controlled entity is set to the server's state and the inputs in flight replayed with the twins brought up from the snapshot's tick. If the replay comes out otherwise than the prediction had the present and the car met no other car or person in those ticks (in the first pass or the replay), the difference can only be the replay's own contacts (see below), and the prediction's own state is kept over it; if 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.
- It differs: the rewind. The controlled entity is set to the server's state (class, pose, steering, its trailer, the boost meter and its spent flag, the landing stun timer, the two grips, the tyres, the health) and the inputs in flight are replayed through the same
stepSim.
- The prediction stands (Predictor.standing): the server agrees with it (AGREEMENT) on the controlled entity and on every piece near it (QUANTIZED_AGREEMENT: the pieces travel quantized), and nothing else near it is simulated in the local world (the
- The distance between the pose before and after a rewind is reported, so the renderer can hide it with a decaying visual offset and the HUD can show it.
A prediction the server confirmed is never run again, because a replay is not a pure function of the snapshot and the inputs: the local world's physics keeps state the snapshot does not carry (a contact manifold's points and the impulses of its last step, the solver's warm start), which this build of Rapier gives no way to switch off. Until 0.69 every snapshot rewound: a car set back to before a crash it had already run, then run into the wall again, met it on the manifold of its own future, the blow landed on another of its points and the car came off the wall spun where the server's did not, half a metre off after a dozen ticks at fifteen inputs in flight (crash-correction.test.ts); a hydrant the prediction had knocked loose was planted again by every snapshot from before the knock and knocked loose again by the replay on new colliders, and the car came off it 13 to 55 cm from the server's. Dropping the car's contacts before the replay instead (a new collider, a kinematic ghost walked along the confirmed trajectory) disturbed the next crash as much: Rapier's pairs are rebuilt when a body's type or collider changes, and a crash resolved on a pair built at another step is another crash. The first pass through a crash, from a world that has never touched the wall, is the server's to the bit; the server confirms it tick by tick, and it stands. Where the server's word differs, the rewind is what puts the prediction right, manifold or no manifold.
A tick of the client's the server runs without its input runs here on the same input. The server runs a client's ticks one per tick of its own, and one whose input has not come runs on its guess, the last input it took held without its presses (@foi/protocol's guessedInput). The ticks the client's frame loop drops (a stall beyond its backlog) are never sent, so they run on that guess there, and here too (Predictor.guess); and a snapshot that acknowledges a tick the prediction has not reached (a stall longer than the lead: the server ran on through the ticks the page owed) has the ticks up to it run here on the guess before it is compared (Predictor.catchUp), whose inputs, sent after it, are then too late for both. Until 0.69 the prediction skipped the dropped ticks and was set to such a snapshot's word as to a disagreement: the local world was a tick of motion off for each tick of the stall the two had run apart, and the next snapshots corrected it by them: 8 to 25 cm a third of a second after a crash into a wall whose aftermath the browser drew in software in frames of up to 100 ms (E2E-642), 58 to 75 cm after a 200 ms stall there (E2E-646), 97 cm after a quarter of a second stuck at 60 km/h (server.integration.test.ts).
A crash the prediction feels on the controlled entity, and a light piece it knocks loose, are told to the renderer as they happen (Predictor.takeFelt), a round trip before the server's word.
Prediction can be switched off to feel the raw authoritative stream for comparison.