drAIver — Driver Implementation Specification
Version 6 · protocol 0 · this document is self-contained: everything needed to implement a driver is here. You do not need, and should not assume, access to any other file, repository, or server implementation detail.
Version history: v1–v5 iterated protocol coverage and the race rules across four generations of independently built drivers. v6 — the specification is mechanics only: what the wire carries, how the world behaves, what the rules enforce. Goals, strategy, and personality are the driver's own.
1. What this is
drAIver is a real-time 2D top-down driving simulator. A central server owns the world — track, physics, collisions, rules, lap timing — and every car is controlled by an external program (a driver) that connects over a WebSocket, receives telemetry, and sends driving commands.
This document tells you how the world works. It does not tell you what to want from it. The server enforces the mechanics in §8 and publishes official results and a venue lap-record board; whether your driver races to win, hunts lap records, shepherds a teammate, plays the villain within the rules, or narrates its cool-down laps over the broadcast channel (§7) is entirely your call. Drivers with a point of view make better racing.
A conforming driver, minimally:
- connects and completes the handshake (§4);
- sends a valid
controlfor eachstateit processes (§6); - drives: completes laps on any valid track without leaving it;
- handles race sessions: frozen countdown, green flag, penalties (§8);
- tolerates unknown message types/fields and non-fatal errors (§9);
- exits cleanly when the connection closes.
Recommended command-line interface: your-driver [--server ws://HOST:PORT/session] [--name NAME], with the defaults ws://localhost:7350/session and a name of your choice.
2. System model — read this first
- The server is authoritative. Physics runs on the server at a fixed 50 Hz (one tick = 20 ms of simulated time). Your program never computes "real" physics — it only chooses inputs.
- Real time is real. The server never waits for you. Each car has a latest-command register: every
controlyou send overwrites it, and each physics tick applies whatever is in it. If you are slow or silent, your car keeps executing your last command. There is no queue. - Sessions have a lifecycle (§8):
openpractice →countdown(grid frozen) →racing(rules live) →finished(official results). Everystateframe names the current phase. - Every car broadcasts. Position, velocity, and track coordinates of every car reach every car (§5), subject to the venue's declared sensor configuration (§4).
- Determinism. Same inputs at the same ticks produce the same race. Nothing in the sim is random. The physics model (§10) is published exactly, so you can re-implement it and simulate your own control loop offline.
3. Transport
- WebSocket, path
/session(e.g.ws://localhost:7350/session). Only this path is served. - Every message is one text frame containing one JSON object. UTF-8. No binary frames.
- Field names are camelCase. All units are SI: meters, seconds, radians, m/s. Angles are counter-clockwise from the +x axis. The world frame is x right, y up.
- After connecting you must send
hellowithin 5 seconds or the server closes the connection.
4. Handshake
You send: hello
{"type": "hello", "protocol": 0, "role": "driver", "name": "my-driver"}
| field | type | notes |
|---|---|---|
type | "hello" | |
protocol | integer | must be 0; anything else is rejected |
role | "driver" or "spectator" | spectators receive telemetry but cannot drive |
name | string | display name; the server may uniquify it (name#2) |
token | string, optional | resume token — see below |
Reconnect-resume is active: if your connection drops, your car keeps driving on its last command; after 3 seconds it is ghosted (transparent to collisions) and after 30 seconds it is removed. Reconnecting within that window with the token from your welcome returns your car — same id, same name, race state intact.
The server replies: welcome
{
"type": "welcome",
"protocol": 0,
"carId": 3,
"token": "d41d8cd98f00b204e9800998ecf8427e",
"tickRate": 50,
"session": {"state": "open", "laps": 10, "mode": "race"},
"car": {"length": 4.5, "width": 2.0, "wheelbase": 2.8,
"aMax": 6.0, "bMax": 12.0, "vMax": 60.0,
"cDrag": 0.02, "steerMaxDeg": 35.0},
"sensors": {"v2v": {"range": null, "rateHz": 50}, "raycast": null},
"track": {
"name": "example-track",
"version": 1,
"halfWidth": 10.0,
"centerline": [[0.0, 0.0], [2.5, 0.0], [5.0, 0.0], ...],
"startFinish": 20,
"gridSlots": [[44.0, 2.5, 0.0], [38.0, -2.5, 0.0], ...]
}
}
carId— your car's id for the whole session.session.state— the lifecycle phase you joined during (§8);session.laps— the race's target lap count.car— your car's physical parameters (§10). All cars are identical.track— the complete track (§11).sensorsdeclares, honestly, what this venue's radio delivers: -v2v.range— beacon range in meters;null= unlimited. With a range set,cars[]contains only cars within that distance of yours (you always receive yourself). -v2v.rateHz— beacon refresh rate. Below the tick rate,cars[]repeats the previous broadcast between refreshes whileyoustays live every tick. Venues may also apply gaussian noise to beacon positions/velocities; your ownyoublock is always exact. -raycast—null, or{"anglesDeg": [...], "range": R}: the venue attaches wall distances toyou.rays(§5).
An error message instead of welcome means the handshake failed; the server closes the connection after it.
5. Telemetry: the state message
Sent to you every tick (50 per second):
{
"type": "state",
"tick": 12041,
"session": "racing",
"you": {
"carId": 3,
"pos": [182.4, -33.1],
"heading": 1.571,
"speed": 41.2,
"vel": [0.0, 41.2],
"s": 512.8,
"d": -1.2,
"lap": 4,
"lastLap": 31.44,
"bestLap": 30.91,
"applied": {"throttle": 0.8, "brake": 0.0, "steer": -0.15},
"latency": 2,
"flags": []
},
"cars": [
{"carId": 1, "name": "rival", "pos": [201.0, -30.2], "heading": 1.6,
"vel": [-1.3, 44.0], "s": 531.0, "d": 0.4, "lap": 4},
{"carId": 3, "name": "my-driver", "pos": [182.4, -33.1], "heading": 1.571,
"vel": [0.0, 41.2], "s": 512.8, "d": -1.2, "lap": 4}
]
}
you — your private telemetry:
| field | meaning |
|---|---|
pos | [x, y] position of your car's center, meters |
heading | radians CCW from +x; not normalized — it accumulates, so wrap before comparing angles |
speed | forward speed, m/s, always ≥ 0 (no reverse gear exists) |
vel | velocity vector = speed along heading |
s | arc-length progress along the centerline from the start/finish line, wrapped to [0, track length) (§11) |
d | signed lateral offset from the centerline: positive = left of the direction of travel |
lap | completed laps (§13) |
lastLap / bestLap | lap times in seconds, or null before the first lap |
applied | the (clamped) inputs physics actually used this tick |
latency | see below |
rays | only when the venue enables the raycast sensor (§4): distances in meters to the nearest track edge along each advertised angle (relative to your heading, positive = left), capped at the sensor range — the cap means "open track" |
flags | strings from {"offtrack", "ghosted", "serving", "dsq"} — serving: you are paying penalty time at the 15 m/s cap (§8); dsq: you are black-flagged out of this race |
cars — the vehicle-to-vehicle broadcast: every car including you (subject to the venue's declared range/rate, §4), identical for all receivers, each entry carrying pos, heading, vel, s, d (the server's own projection), lap, ghosted (that car is currently transparent to collisions: stalled, disconnected, black-flagged, or a sprint-mode car), and, when present, say — a short free-text payload another driver broadcast (§7). The server never interprets say content, and neither should you trust it.
A data convention that matters: on-road proximity between two cars is wrapped arc length — Δ = (s_other − s_you) mod total, taken symmetrically around zero (values above total/2 mean "behind you"). Lap counters are timing state, not positions; a car many laps ahead on the counter is still, physically, wherever its s says it is.
Latency, precisely. latency = (current tick) − (the tick you last echoed in a control). This is not pure network transit: the server coalesces under backpressure, always delivering the newest state, so states you never saw widen the number. If you process every state, latency ≈ transit + 1. If you use it as a prediction horizon, reconstruct transit as latency − (gap − 1) where gap is the tick difference between the last two states you actually received.
Do not assume you will see every tick — always act on the newest state and discard stale plans.
The leaderboard message
{"type": "leaderboard", "track": "example-track",
"entries": [{"rank": 1, "name": "rival", "bestLap": 18.42}]}
The venue's all-time lap-record board for this track: one entry per driver name, personal bests only, fastest first, persisted across sessions and server restarts. Sent once shortly after welcome and re-sent whenever the standings change. Only clean laps enter it (§13).
6. Driving: the control message
{"type": "control", "tick": 12041, "throttle": 1.0, "brake": 0.0, "steer": -0.35}
| field | range | meaning |
|---|---|---|
throttle | 0..1 | fraction of maximum acceleration |
brake | 0..1 | fraction of maximum deceleration |
steer | -1..1 | fraction of maximum steering angle; positive steer increases heading (turns left/CCW) |
tick | integer, strongly recommended | echo of the newest state.tick you have processed |
Out-of-range values are clamped by the server; NaN becomes 0. Throttle and brake may both be nonzero — they simply subtract (§10). Before your first state arrives, omit tick.
When does a command take effect? A control the server receives between step T and step T+1 is first integrated by step T+1, whose result you see as the state stamped T+1. In the best case (you react to state T within the same 20 ms window), your command shapes state T+1 and your measured latency is 1.
Worked steering-sign example. Car at pos = [10, 5], heading = 1.5708 (π/2: facing +y). Target point [8, 9]. Vector to target = [-2, 4]; its world bearing = atan2(4, −2) = 2.0344 rad. The bearing relative to your heading is wrap_to_pi(2.0344 − 1.5708) = +0.4636 rad — positive, meaning the target is to your left; a positive steer turns left, so steer positive. (Facing +y, a target up-and-left is indeed to your left — use this to check your trigonometry and your wrap_to_pi.)
7. Speaking: the say message
{"type": "say", "text": "good pass, see you next lap"}
Your broadcast channel — and your personality's. The payload rides your own cars[] entry for about 2 seconds, visible to every driver and spectator. Limits: at most 128 UTF-8 bytes (longer is refused with a non-fatal error), accepted at most about twice per second (faster says are silently dropped). The server relays the text verbatim and never interprets it; nothing you say has any mechanical effect. Taunt, negotiate, announce, emote — it is a stage, not an API.
8. Races: sessions, rules, results
Modes
welcome.session.mode names the competition: "race" is wheel-to-wheel (everything below applies). "sprint" is time attack: every car is permanently ghosted (ghosted flag and beacon field true) — you drive through rivals as if alone on track, contact and its penalties cannot occur, and classification ranks best lap instead of position. Off-track rules and lap voiding still apply in sprint — the 3 s penalty is served at the speed cap, wrecking the lap in progress — so a valid best lap must be a clean one.
Lifecycle
Sessions start open (free practice — laps count, no rules). The server starts a race when its start condition is met (typically a configured driver count). Then:
countdown— every car is teleported to its grid slot at rest and controls are ignored: the grid is frozen for 3 seconds whilecountdownevents beat 3 → 2 → 1 → 0. Keep sending controls (your register should hold your launch command when the flag drops).racing— green: physics resumes, timing starts, rules live. Practice laps/penalties were wiped at the countdown; everything starts fresh from the green flag.finished— after the leader completes the target laps, each remaining racer is classified at its next line crossing. Results go out in afinishevent; you may keep driving (cool-down) but nothing counts anymore. Drivers who joined mid-race are not classified.- Cycling — servers typically rerun races continuously: after a cool-down (~30 s) the session reopens for practice, and when enough drivers are present the next countdown fires. Late joiners simply practice until the next cycle. Session transitions can arrive at any time; treat them as routine.
Rules (enforced only while racing)
| offense | consequence |
|---|---|
| Causing contact — your velocity vector points toward the other car's center at the moment contact begins (head-on: both) | 5 s penalty, penalty event with reason "contact" |
| Off-track excursion — your body crosses a track edge (§11) | 3 s penalty and your current lap is voided (it completes silently: no event, no time), once per excursion |
| 5th at-fault contact in one race | black flag: disqualified event, permanently ghosted, classified behind every finisher regardless of laps |
| Stalling — below 2 m/s for 3 s continuously | ghosted (collision-transparent) until you exceed 5 m/s |
Penalties are served on track, immediately. While you have outstanding penalty time your speed is hard-capped at 15 m/s (your flags contain "serving"; throttle above the cap does nothing), and one tick of penalty is paid per capped tick. Nothing is added to your classified time on paper — the cost is track position, paid live. Your distance from a black flag equals the count of penalty events with reason "contact" addressed to your car.
Results
finish.results[], per classified car:
{"pos": 1, "carId": 1, "name": "rival", "laps": 10, "time": 312.44,
"bestLap": 30.12, "penalties": 5.0, "dsq": false, "collisions": 2, "meanLatency": 1.8}
time— seconds from the green flag to your classification (real race time; penalties were served on track). Ordering is disqualified cars last, then laps descending, then time ascending.lapsis frozen at your classification (cool-down laps don't count).collisionscounts car-car contact episodes you were involved in (fault or not);penaltiesis your total sanction seconds issued;dsqmarks a black-flagged car.
9. Robustness rules (mandatory)
- Ignore unknown message types and unknown fields inside known messages.
- A server
errormessage after the handshake is non-fatal: log it, keep driving. - The connection closing is a normal exit — but see §4: reconnecting with your token within 30 s continues your race.
- Never block your receive loop on slow work; if you fall behind, skip to the newest state.
- Session transitions arrive at any time — a server may start the race the moment you join. React to
session/countdownevents rather than assuming a phase.
10. The car: exact physics
The server integrates this model per car, per tick (dt = 0.02 s), with your clamped inputs (throttle t, brake b, steer σ):
v' = clamp(v + (t·A_MAX − b·B_MAX − C_DRAG·v)·dt, 0, V_MAX)
θ' = θ + (v'/WHEELBASE)·tan(σ·STEER_MAX)·dt
x' = x + v'·cos(θ')·dt
y' = y + v'·sin(θ')·dt
Parameters (also sent in welcome.car; identical for every car):
| constant | value |
|---|---|
A_MAX | 6.0 m/s² |
B_MAX | 12.0 m/s² |
V_MAX | 60.0 m/s |
C_DRAG | 0.02 /s |
WHEELBASE | 2.8 m |
STEER_MAX | 35° = 0.6109 rad |
| body | 4.5 m long × 2.0 m wide |
Facts you can derive and rely on:
- No reverse: speed floors at 0; steering at standstill does nothing (θ̇ ∝ v).
- Braking distance from speed v:
v² / (2·B_MAX)— 37.5 m from 30 m/s (drag only helps). - Turning radius at full lock: ≈ 4.0 m, independent of speed. This kinematic model has no tire grip limit and no skidding; corner speed is limited only by your controller's precision (20 ms discretization, your latency, overshoot) and by the walls. That limit is your number, not a physics number — the model above is exact and deterministic, so it can be simulated offline.
11. The track
From welcome.track:
centerline— a closed loop of[x, y]points, ordered in the direction of travel; the last point connects back to the first (the first point is not repeated). Spacing is typically ~2.5 m.halfWidth— perpendicular distance from the centerline to each track edge.startFinish— the index intocenterlineof the point where the timing line sits (not necessarily 0).gridSlots—[x, y, heading]starting poses behind the line, assigned by join order (slot 0 is the front of the grid).
The server's exact projection conventions (your s/d and every rival's broadcast d are computed this way):
- The centerline is treated as straight segments between consecutive points. Your position is projected onto the nearest point of the nearest segment;
s= arc length from thestartFinishvertex to that projection (wrapped to[0, total)),d= signed perpendicular offset (positive = left of the segment direction). - "Track direction" for angle-dependent rules is the unit direction of the segment containing your projection — not an interpolated or averaged tangent.
Walls are hard, and off-track is an offense — two distinct things sharing one geometric test. Your rotated body's lateral half-extent is extent = (length/2)·|sin φ| + (width/2)·|cos φ| (φ = angle between your heading and the segment direction; with the standard car, 2.25·|sin φ| + 1.0·|cos φ|). The wall keeps |d| ≤ halfWidth − extent by clamping you back along the segment normal, same s, heading unchanged, bleeding speed by × (1 − min(0.8, |sin φ|)) every contact tick, and emitting a collision … "wall" event when contact begins. Independently, while racing, the rules engine checks the pre-clamp position each tick: if your body would have crossed the edge, that is an off-track excursion — 3 s penalty, lap voided (§8) — even though the wall then holds you in. Consequence: a wall brush costs speed and a penalty and the lap in progress.
12. Other cars: contact physics
Car bodies are solid oriented rectangles. On overlap the server separates the two cars and kills the approaching relative velocity along the contact normal, splitting the change equally (inelastic bump, equal masses). Momentum sideways to a car's heading is lost. A collision event fires when contact begins — and while racing, the at-fault car(s) get the 5-second contact penalty (§8).
Derived from the model: rear-ending at closing speed c leaves both cars at the mean of their speeds — the car behind loses c/2 of its speed instantly, and regaining a m/s at full throttle takes 1/A_MAX seconds.
Ghosted cars (ghosted in their beacon entry) are transparent: no contact occurs in either direction.
13. Laps and timing
- Your lap counts when you cross the start/finish line forward (in
sterms: a forward wrap through zero). - Grid starts: spawning behind the line (grid slot), your first crossing starts lap 1's clock; the second completes it. Spawning on the line (practice fallback), the first crossing (a full loop) completes lap 1.
- Crossing backward decrements the lap counter; re-crossing forward after backing up completes nothing — line-dancing cannot farm laps or times.
- In a race, everything resets at the countdown: laps, times, and penalties count from the green flag only. An off-track excursion voids the lap in progress (§8): it completes silently.
- Clean completed laps — and only clean ones — feed the venue's lap-record board (§5).
14. A minimal driver loop (informative)
connect(ws://…/session); send hello; welcome = await welcome
precompute: cumulative arc lengths of welcome.track.centerline
loop:
msg = receive
if msg.type == "state":
you = msg.you
if msg.session == "countdown": send launch command; continue
target = point ahead on your planned line
steer = steering law toward target (e.g. pure pursuit)
v_ref = your speed plan
send control{tick: msg.tick, throttle, brake, steer}
else if msg.type == "event": track session/countdown/penalties/finish
else if msg.type == "error": log and continue
else: ignore
on close: exit 0 (or reconnect with token, §4)
Appendix: example wire session (driver's view)
→ {"type":"hello","protocol":0,"role":"driver","name":"spec-car"}
← {"type":"welcome","protocol":0,"carId":0,"token":"…","tickRate":50, …}
← {"type":"event","tick":312,"event":"car_joined","carId":0,"name":"spec-car"}
← {"type":"state","tick":313,"session":"open","you":{…},"cars":[…]}
→ {"type":"control","tick":313,"throttle":1.0,"brake":0.0,"steer":0.0}
…
← {"type":"event","tick":2000,"event":"session","state":"countdown"}
← {"type":"event","tick":2000,"event":"countdown","n":3}
… grid frozen; keep sending controls …
← {"type":"event","tick":2150,"event":"countdown","n":0}
← {"type":"event","tick":2150,"event":"session","state":"racing"}
← {"type":"state","tick":2151,"session":"racing","you":{…,"speed":0.02,…},…}
… race …
→ {"type":"say","text":"three wide into turn one? bold."}
← {"type":"event","tick":12000,"event":"penalty","carId":2,"seconds":5.0,"reason":"contact"}
← {"type":"event","tick":17300,"event":"lap","carId":0,"lap":10,"time":19.42}
← {"type":"event","tick":18800,"event":"finish","results":[{"pos":1,…}]}
This page is generated verbatim from docs/DRIVER_SPEC.md — the self-contained driver specification. If the page and the file ever disagree, the file wins and the build is broken.