Online races
Online races put two to four players on the same strip at once. Each browser simulates its own car from live input and shows the others as predictions from their relayed input. The server never trusts what a client says about its race: it collects every runner’s input log and replays it through the same simulation the game runs, and that replay decides the places. The client side lives in net/, the server in party/.
The race server (party/)
Section titled “The race server (party/)”party/ is a Cloudflare Worker built on Durable Objects with the partyserver package (routePartykitRequest, Server) and deployed with wrangler. party/wrangler.jsonc binds two Durable Object classes:
| Class | File | URL | Role |
|---|---|---|---|
RaceRoom |
party/src/RaceRoom.ts |
/parties/race-room/<code> (WebSocket) |
One room code: lobby, race, verdict |
RoomIndex |
party/src/RoomIndex.ts |
/parties/room-index/global (HTTP) |
The single global list of rooms, for the room browser and quick match |
party/src/index.ts routes requests with routePartykitRequest and refuses any non-GET request to the room index from outside: only rooms report to it, from inside the worker, so nobody can list fake rooms.
The server imports the game’s own code directly (../../src/game/sim/..., ../../src/game/net/protocol.ts). That is why sim/ must stay free of Phaser and three (see Architecture): the worker runs the exact same simulation files as the browser.
RaceRoom only wires connections. The state is split into plain classes without connections or timers:
| File | Holds |
|---|---|
party/src/lobby.ts (Lobby) |
Members in join order, host, capacity, public flag, distance, readiness |
party/src/roomRace.ts (RoomRace) |
One race: start message, input relay, finish grace, timeout, verdict broadcast |
party/src/heat.ts (Heat) |
Each runner’s input log and done/forfeit state, judging once all are done |
party/src/judge.ts |
Replaying one run, the finish grace rule, placings |
party/src/inputGuard.ts |
Validating an input batch before it is accepted |
party/src/messages.ts |
JSON parse, send, and a shape check of a joining Entry |
party/src/roomDirectory.ts |
Which listed rooms are open, and random room codes |
Rooms and joining
Section titled “Rooms and joining”A room is identified by its code. Any code works: the first player to connect to /parties/race-room/<code> creates the room by joining it and becomes host. NetLobby lets players type a code (letters without I and O, since codes get read out loud); NetBrowse lists public rooms and offers quick match.
joinRoom.ts opens a RaceConnection and moves to the NetRoom scene as soon as the first room state arrives. If the server answers full or outdated instead, it closes the connection and calls onRefused with the reason, and the calling scene shows a message (OUTDATED_TEXT for a version mismatch).
Lobby rules (party/src/lobby.ts, RaceRoom.onLobbyMessage):
- Capacity starts at
MIN_PLAYERSand the host can raise it up toMAX_PLAYERS(both innet/protocol.ts), never below the players already present. - The player who opens the room chooses whether it is public. The host can change visibility, distance and capacity, and kick others.
- When the host leaves, the longest-present player takes over. When the room empties, its settings reset.
- The race starts by itself when the room is full and everyone is ready, or when the host sends
startonce everyone present (at leastMIN_PLAYERS) is ready. - A connection to a full or racing room gets
fulland is closed. - After a result, everyone is set back to not ready, so a rematch is another round of readying up.
Room browser and quick match
Section titled “Room browser and quick match”On every state change a room POSTs a RoomListing to RoomIndex (RaceRoom.reportToIndex); a room with no players removes its listing. The index keeps the listings in Durable Object storage so they survive eviction.
roomIndex.ts has the two client calls:
fetchOpenRooms(): GET the index.openRooms()inparty/src/roomDirectory.tskeeps public rooms in the lobby phase with a free seat that reported withinSTALE_MS, fullest first.quickMatchCode(): GET with?quick=1. Returns the fullest open room that races the default distance (the quarter mile). If there is none, the index hands out a fresh code and remembers it forQUICK_ROOM_REUSE_MS, so two players pressing quick match at the same moment land in the same new room. A pending code whose host has since switched distance is not reused.
Protocol
Section titled “Protocol”All messages are JSON over one WebSocket, typed as ClientMessage and ServerMessage in net/protocol.ts, which both sides import.
Client to server:
t |
Fields | Who | Meaning |
|---|---|---|---|
ping |
c (client time) |
anyone | Clock sync sample; answered with pong |
join |
entry, public?, v?, token? |
anyone | Join with tune and look; v is the client’s SIM_VERSION, token the account session |
ready |
ready |
member | Toggle ready in the lobby |
capacity |
n |
host | Room size |
kick |
id |
host | Remove a player |
visibility |
public |
host | Show or hide the room in the browser |
distance |
d |
host | Race distance |
start |
host | Start early once everyone present is ready | |
emote |
i |
member | Preset emote by index into EMOTES, rate limited by EMOTE_COOLDOWN_MS |
input |
e, at |
racer | Input events, and the number of ticks the sender has completed |
done |
racer | The sender’s run is over |
Server to client:
t |
Fields | Meaning |
|---|---|---|
pong |
c, s (server time) |
Clock sync reply |
full |
Room full or racing; connection closed | |
outdated |
Join refused: other race physics; connection closed | |
room |
room: RoomState |
Lobby state, sent to every member on any change; you is the receiver’s id |
kicked |
You were removed by the host | |
emote |
from, i |
Someone’s emote |
start |
lane, startAt, runners, track, distance |
Race begins; startAt is server epoch ms when the lights start |
input |
e, at, lane |
Another runner’s accepted input batch, relayed |
result |
runs, ids, places |
The verdict, by lane: outcome and finish frame, member id, place |
An Entry is { car, tune, look? }. car and tune feed the simulation; look (CarLook: name, sprite, paint, underglow) is display only. net/entry.ts builds the local entry from the garage and sanitises other players’ looks, falling back to a stock car for anything unknown. On the server, isEntry checks the shape, then ownedEntry (party/src/race/onlineEntry.ts) replaces the car and look with what the player owns: it checks the session token the join carries (RaceConnection gets it from ensureSignedIn), reads the account’s garage and builds the selected car’s stats from its model and parts. Only the tune comes from the client: it carries no value and the simulation clamps it. A join without a valid session is closed. party/scripts/twoClients.ts joins with a claimed 5000 hp car and checks the results replay with the guest’s starter car.
Clock sync and the shared race clock
Section titled “Clock sync and the shared race clock”Every client must start the lights at the same moment. On connect, RaceConnection sends PING_COUNT pings a short interval apart, before the join, so the replies are in before any start arrives. Each pong gives a sample of (sent, server, received).
clockOffset() in net/clockSync.ts picks the sample with the shortest round trip, since it leaves the least room for asymmetric delay, and assumes the request and reply took equally long:
offset = server - (sent + received) / 2When start arrives, localStartAt = startAt - offset turns the server’s start time into local Date.now() time. The server sets startAt START_DELAY_MS (party/src/roomRace.ts) in the future so the message reaches everyone first.
NetRaceClock (net/NetRaceClock.ts) then times the race against the wall clock instead of summed frame deltas, so a browser that stutters catches up instead of drifting. It uses the same start-light rhythm as the offline RaceClock: green comes four lamp intervals after startAt, and framesDue(now) is the simulation frame that should have run by now, floor((now - greenAt) / (1000 / SIM_HZ)), negative during the countdown. NetRace.update() runs ticks until the session’s frame reaches framesDue, starting from the first lamp so players can rev in neutral before green.
Inputs, prediction and the replay verdict
Section titled “Inputs, prediction and the replay verdict”Sending
Section titled “Sending”The simulation is a pure function of car, tune and input (see Race simulation), so a run can be sent as its input instead of its positions. sim/inputRecorder.ts turns live controls into an event log of { f, k }: gas and nitrous are held states, logged only on their on/off edges; shifts are single events. Frame 0 is the first tick after green, the countdown runs from -STAGING_FRAMES.
After each tick NetRaceSession.flush() returns a batch when events are due, or a heartbeat every HEARTBEAT_FRAMES without new input. at in a batch means “every event before this frame has been sent”, which tells the others how far the run is known even when nothing was pressed. When the local run ends (finish, blown engine, or timeout), the last batch is forced out and done follows.
Server checks and relay
Section titled “Server checks and relay”acceptBatch() (party/src/inputGuard.ts) only accepts a batch that extends the run in order: at never goes back and stays within MAX_RACE_FRAMES, every event is well formed, frames never decrease, no event lands at or after at, and a run holds at most MAX_EVENTS events. Accepted batches are appended to the runner’s log in Heat and broadcast to the other members with the sender’s lane.
Showing the other cars
Section titled “Showing the other cars”NetRaceSession simulates the local car directly. Each remote car runs an OpponentPredictor (sim/opponentPredictor.ts): exact up to the last reported frame, and beyond it a guess with gas and nitrous held as last known and shifts at the best shift point. When late input corrects the prediction, the drawn car closes the gap by CORRECTION_RATE per tick, so the correction shows as a short slide instead of a jump.
The client ends its own run on the finish grace only when another car’s confirmed input (not a prediction) puts it over the line. A predicted car over the line might belong to a hidden tab that stopped sending.
Judging
Section titled “Judging”judgeRun() in party/src/judge.ts replays each log with replayRun() (sim/inputLog.ts), the same stepping code the browser ran, with AI off. Because the sim is deterministic, the replay reproduces what that player saw, so the server can judge without trusting any client’s claimed time. scripts/netCheck.ts checks exactly this: a run recorded live replays to the same result, the predictor converges on the exact run, and clockOffset recovers a known offset.
The replay returns a fractional finish frame (interpolated inside the crossing tick, so two cars crossing in the same tick still have an order), or blown, or no finish. A run is only replayed up to cutAt, the frames its runner reported, so a replay never holds the last input on to a finish the runner did not drive.
When the race ends:
- A runner is done after
done, after leaving (outcomeleft, a forfeit), or when the race is cut off. - When the first runner reports
donewith a finishing log,RoomRace.startGraceresets the alarm to the finish grace (finishGraceFrames()insim/finishGrace.ts, which grows with distances past the quarter mile) plusGRACE_SLACK_MS. A runner who stopped sending does not hold up the race. - If no one finishes, the Durable Object alarm fires after
RACE_TIMEOUT_MS(party/src/RaceRoom.ts: start delay, lamps,MAX_RACE_FRAMES, plus slack). - On the alarm,
Heat.cutOff()ends every runner still going, judged on the frames it reported. applyFinishGrace()turns a finish more than the grace behind the first car intotimeout.placings()ranks by outcome (finishbeatsblownandtimeout, which beatleft), then by finish frame. Each place is one plus the number of runs that beat it, so ties share a place.
The result goes to everyone, the lobby is unreadied, and the room is back in the lobby phase. If the room empties mid-race, the race is dropped without a verdict.
Heat logs and grades
Section titled “Heat logs and grades”The client keeps its own copy of every lane’s input in HeatLogs (net/HeatLogs.ts): its own batches as it sends them, the others’ as they are relayed. When the result arrives, grade() sorts each lane’s events by frame and runs gradeRun() (sim/runGrades.ts) to get the launch and shift grades that NetResults shows next to the server’s places. The places come from the server; the grades are computed locally from the same logs.
SIM_VERSION
Section titled “SIM_VERSION”The server judges on its own copy of the sim. If a client ran different physics, it would see one race and be judged on another. net/simVersion.ts exports SIM_VERSION, read from a build-time constant __SIM_VERSION__, or 'dev' when unset (the Vite dev server and wrangler dev, so local play always matches).
scripts/deploy.sh stamps both sides with the same value:
SIM_VERSIONis the short hash of the last commit that touchedsrc/game/sim,src/game/net/protocol.tsorparty/src. Commits that change only art or menus keep the version, so a deploy of those never locks players out of rooms.- The worker gets it through
wrangler deploy --define __SIM_VERSION__:...; the game build through thedefineinvite/config.prod.mjs, which reads theSIM_VERSIONenvironment variable. - The script deploys
HEADfrom agit archiveexport, never the working tree, because other sessions’ unfinished edits must not go live. - The server goes first. An old game against the new server is refused and told to reload; a new game against the old server would race unchecked.
On mismatch, RaceRoom.join sends outdated and closes the connection (a missing v, from clients before the check, also mismatches). joinRoom reports 'outdated' and the browse or lobby scene shows OUTDATED_TEXT: “The game was updated. Reload it to race online.”
Running it locally
Section titled “Running it locally”The game connects to VITE_PARTY_HOST, defaulting to ws://localhost:8787 (net/RaceConnection.ts); .env.production points production builds at the deployed worker.
| Command | What it does |
|---|---|
cd party && npm run dev |
wrangler dev: the race server on port 8787 |
npm run dev (repo root) |
The game; online play then talks to the local server |
cd party && npm run test:room |
party/scripts/twoClients.ts: scripted WebSocket clients against a running local server, covering lobby, start, relay, verdict, rematch, full room, disconnect, four players and distances |
npx tsx scripts/netCheck.ts |
Replay, prediction and clock-offset checks, no server or browser |
node scripts/netShot.mjs |
Starts Vite and the race server (or reuses a running one) and plays a race in headless browsers; --players 4, --quick, --latency <ms> |
npm run check |
Also type checks party/ (tsc -p party/tsconfig.json) |
npm run deploy |
scripts/deploy.sh: worker, then game to Cloudflare (play.neondrag.com); DRY_RUN=1 skips the real deploys |
In dev, RaceConnection exposes itself as window.netConnection so scripts/netShot.mjs can read the room.