Ghost-Style Multiplayer: Racing 8 Players With No Collisions
Mars Rally Championship, a free browser rally game, runs its online races with no car-to-car collisions, deliberately. Up to 8 pilots load the same Mars stage at once, see each other as name-tagged translucent rovers, and run their own clock from their own GO. The server is a Colyseus relay: it forwards transforms and ranks reported finish times, and it simulates nothing. That one decision removed client prediction, server reconciliation and rollback from the codebase — and removed rubber-banding from the race.
Contact racing is a physics problem wearing a networking costume
I started where everyone starts. I wanted cars that touch. Then I wrote down what touching actually requires.
If two cars can collide, two clients can disagree about the world, and something has to be right. That means an authoritative server running the vehicle simulation — in my case a per-wheel tire model at eight substeps a frame, times eight rovers — plus client-side prediction so the local car still answers the key press on the same frame, plus reconciliation to fold the server's correction back into local state without visible jitter. Glenn Fiedler's networked physics series and Valve's Source multiplayer networking article are the canonical descriptions of that machine, and both are honest about the cost: you are not writing netcode, you are writing a second physics engine that has to agree with the first one.
That is a lot of work. What bothered me more is what it buys the player on a bad connection. Every contact racing game I have played eventually produces the same artifact: you are shoved, teleported, or overtaken by someone whose packets arrived late, and a correction lands as a visible position change on your screen. The server was right. You still lost the corner.
So the question stopped being "how do I do this well?" and became "what is contact actually worth here?"
The model I shipped: everyone races the same stage, nobody touches
Ghost-style simultaneous time trial. Everybody in the room loads the same stage, everybody drives at once, and opponents render as translucent rovers with a name plate over the roof — the 25%-opacity ghost mesh the solo system already used, tinted per player. Nothing collides. Your timer starts at your own GO and stops at your own finish line. The server sorts the finish times.
The trade written out plainly, because it is a trade and not a free lunch:
| Dimension | Contact racing | Ghost-style simultaneous time trial |
|---|---|---|
| Server role | Authoritative physics sim of every car, plus state reconciliation to every client | Relay: forwards transforms, ranks reported finish times, simulates nothing |
| Latency sensitivity | High — corrections arrive late and land as visible position changes | Low — a late packet delays one opponent's proxy and nothing else |
| What the player loses | Nothing, on a good connection | Blocking, drafting, contact, and the pass won with position instead of pace |
| What the player gains | Wheel-to-wheel racing and the drama that comes with it | Comparable times, no rubber-banding, and a race that survives jitter |
The second row decided it. In the ghost model a player on hotel wifi cannot degrade anybody else's race: their proxy gets choppy, or vanishes for half a second, and my car's physics is untouched — their car was never in it.
The server is a relay, and the protocol version is a literal on both sides
Online racing runs against a mars_race
Colyseus room. Colyseus handles room lifecycle,
schema state sync and matchmaking, and that is all I ask of it. Room state is a
small player map — name, tint, transform, checkpoint index, finish time, DNF
flag — and the server's only rules are capacity, phase transitions and ranking.
The whole tuning surface is five constants:
// public/js/net.js — mirrored literals from the server's race-config.ts
const RACE_ROOM = 'mars_race';
const RACE_PROTOCOL_VERSION = 1;
const TRANSFORM_SEND_HZ = 15; // server drops above 25 Hz
const RENDER_DELAY_MS = 150; // interpolation buffer depth
const STARVE_HIDE_MS = 500; // hide a remote car after silence
const SNAPSHOT_BUF_CAP = 20;
const PROGRESS_TICK_S = 0.25;
RACE_PROTOCOL_VERSION is the one I would defend hardest. It is a plain
literal duplicated in the client and in the server's config, and every create
or join call sends it as a join option. If the two disagree, the server refuses
the join with race_protocol_mismatch and the client turns that into a
readable line: GAME UPDATED — REFRESH THE PAGE TO RACE ONLINE. A browser game
deploys under people's feet, and somebody always has a tab open from before the
deploy. A skew caught at the door beats one that quietly corrupts a race where
half the field reads a field the other half stopped sending. Mirrored constants
need a rule to stay mirrored; mine is that both literals move in one change.
The Colyseus client is vendored as a UMD build inside the repo, pinned to the same 0.16.x as the server, and imported lazily the first time someone opens online racing. A live-race feature should not depend on a CDN being reachable, and the rest of the game already runs with no build step.
15 Hz out, 150 milliseconds back
Each client sends its own transform 15 times a second while it is on track — during the countdown too, so the field is visible lined up on the grid before GO. The payload is quantized on the way out, at exactly the precision the ghost recorder already uses: two decimals of position, three of quaternion, one of speed.
S.net.room.send('transform', {
x: r2(S.carState.pos.x), y: r2(S.carState.pos.y), z: r2(S.carState.pos.z),
qx: r3(S.carGroup.quaternion.x), qy: r3(S.carGroup.quaternion.y),
qz: r3(S.carGroup.quaternion.z), qw: r3(S.carGroup.quaternion.w),
s: Math.round(S.carState.speed * 10) / 10,
});
Fifteen updates a second is nowhere near enough to render smoothly at 60 fps,
which is the reason for the second constant. Incoming snapshots go into a small
per-player buffer capped at 20 entries — a little over a second of history —
and remote cars are drawn not at the newest snapshot but at now - 150 ms,
deliberately in the past.
const renderT = performance.now() - RENDER_DELAY_MS;
for (const [, r] of S.remotePlayers) {
const buf = r.buf;
const n = buf.length;
if (n === 0 || renderT > buf[n - 1].t + STARVE_HIDE_MS) {
r.mesh.visible = false; // half a second of silence: the car goes away
continue;
}
let i = n - 1; // find the pair bracketing renderT
while (i > 0 && buf[i - 1].t > renderT) i--;
const f1 = buf[i];
const f0 = i > 0 ? buf[i - 1] : f1;
const span = f1.t - f0.t;
const t = span > 0 ? Math.min(1, Math.max(0, (renderT - f0.t) / span)) : 1;
r.mesh.position.lerpVectors(_v0, _v1, t);
r.mesh.quaternion.slerpQuaternions(_q0, _q1, t);
r.mesh.visible = true;
}
Deliberately rendering late is the part that surprises people, so it is worth stating flatly: interpolation delay is what buys smooth remote motion. At 15 Hz the gap between packets is about 67 ms, so a 150 ms buffer holds roughly two packets in hand. Every frame has a snapshot behind it and a snapshot ahead of it, and the renderer interpolates between two known positions instead of guessing past the newest one. Extrapolation — predicting forward from the last snapshot — is the alternative, and it is what makes remote cars overshoot a corner and snap back. Fiedler's snapshot interpolation write-up is the clearest treatment of this if you want the full derivation.
The cost is that opponents are always 150 ms stale. In a contact game that is disqualifying — you cannot lean on a car that is not where it is drawn. In a game where nobody touches anybody, it is invisible. You are chasing a reference, not a hitbox.
When a player's packets stop, their car is hidden after 500 ms rather than frozen in place. A rover parked mid-corner reads as a hazard, and in a game with no collisions a hazard is a lie.
A separate 4 Hz tick drives the standings: rank chip on the HUD, live table on the results screen. Finishers sort by time, everyone else by checkpoints cleared, DNFs last. Checkpoint and finish messages ride events the solo game already emitted, so the race logic needed no call-site edits.
Rooms, invite links, and a countdown nobody wins
The lobby is deliberately thin. Public rooms come from an HTTP list the browser
polls every three seconds; private rooms do not list at all, and travel instead
as a link ending in ?join=<code> that opens the game with the code prefilled
and one button to press. Everyone gets a 20-character call sign stored locally,
defaulting to PILOT, and there is no account anywhere — the whole pitch of
racing friends in a browser.
The start sequence has one barrier that matters. When the host starts, the room moves to a loading phase and every client loads terrain, reporting back when it is ready; the countdown holds on WAITING FOR PLAYERS until the server flips the phase. Only then does each client reset its own 3-2-1-GO. Every countdown therefore begins within one state patch of every other, and every timer starts at its own GO — so a slow loader delays the race instead of losing it.
Two smaller decisions fell out of having no authority. Mid-race pause is disabled, because a local pause would freeze one car while the field kept racing; pressing pause opens a leave-race confirmation instead, and the sim and the timer keep running behind it. Leaving is a DNF. And if the connection drops mid-race, the run does not end — you get a toast saying the race continues offline, and it becomes an ordinary solo run with a valid time, because the physics was always local anyway.
What ghost-style racing actually costs
I do not want to undersell this. You lose real things.
You cannot block. You cannot defend a line. You cannot lean on someone through a corner and take a place you did not have the pace for. No contact drama, no first-corner pileup, no rivalry built out of paint trading. It is a race against times, not against cars, and if what you want from multiplayer is the fight, no amount of tuning will get you there from here.
What softens it here is the setting. Mars has 0.020 kg/m³ of air, about 1.6% of Earth's, so aerodynamic drag at 240 km/h works out to roughly 0.02 m/s². Slipstreaming a car you cannot touch would be worth essentially nothing even if I had modelled it. Contact would have mattered; drafting would not. On a game set on Earth, that column of the trade is heavier.
Determinism turns a finish into a checkable artifact
The reason ghosts work in this game is that the simulation is deterministic: fixed physics substeps, a seeded noise field, and inputs recorded per frame. Replay the inputs and you get the run back bit-exact — which is what ghost racing is built on, and the subject of a longer write-up on deterministic replays.
The same property has a second use that I did not plan for, and it is the strongest argument for this whole architecture. If a finished run is an input recording plus a deterministic sim, then a submitted time is not a claim — it is an artifact, and an artifact can be checked rather than trusted.
The leaderboard already does the cheap half. A submission is validated server-side before any write: stage and time bounds, a physics floor derived from route length and the car's top speed, then one pass over the recorded frames looking for non-finite coordinates, per-frame teleports beyond the speed cap, a path too short for the route, and checkpoints hit out of order. The expensive half — re-simulating the recording headlessly and comparing — is a roadmap item, not something I have shipped. It is possible at all only because the physics is deterministic, which is the same property that makes a translucent opponent a faithful reference rather than a decoration.
Contact racing forecloses that. Once the server owns the simulation, the client's recording is an opinion about a world it did not author.
Where to try this
- Online racing — how rooms, invite links and the ranking work from a player's side.
- Start a race — create a room, copy the link, send it to one person.
- Ghost racing — the solo half of the same idea, against your own recordings and the leaderboard's.
- The leaderboard — where validated times land.
The game runs at /play with no download and no account. If you build on this design, I would rather hear the trade was wrong for your game than that this post pretended there wasn't one.