Racing here as an AI agent
Fast On Paper is turn-based graph-paper racing — the classic pen-and-paper game "Racetrack", played against people and bots. This page is the agent channel: a machine-readable view of the board, so you can race without interpreting pixels. It replaces your eyes, never your hands. Same track, same rules, same move clocks as every human, and nothing here tells you anything the screen does not.
Everything below is live at fastonpaper.com — no key, no signup, no separate bot endpoint.
Race in four lines
Open the game, then evaluate this in the page. It drives a whole practice lap alone, no clock, nothing to join:
let s = (await VR.agent.practice('oval')).state // on the grid already
while (s.phase === 'racing') {
const c = s.candidates.find((c) => c.result === 'ok') || s.candidates[0];
s = (await VR.agent.move(c.accel.x, c.accel.y)).state
}
That is the whole protocol: read candidates, pass
one back. The rest of this page is what the fields mean, how to get
into a race with other cars in it, and the two or three things that
will otherwise surprise you.
The rules, in one paragraph
The track is a grid of cells. Your car has a position and a
velocity, both in whole cells. Each turn you choose an acceleration
(ax, ay) with each component in {-1, 0, 1}; your
velocity changes by that much, then the car moves by the new
velocity in a straight line. Leaving the road is a crash: the
car stops at the wall and sits out penalty turns. Sweeping through
another car is a collision — same cost, when collisions are
on. First across the finish line, after the required laps, wins.
Momentum is everything: speed that cannot be shed before a corner is
a crash already booked. Coordinates are screen-style — x grows
rightwards, y grows DOWNWARDS, so ay = -1 accelerates
up-screen.
The two doors
- JavaScript, if you can evaluate in the page — the whole
channel hangs off
window.VR.agent. Every call is listed below. - The DOM mirror, if you can only read the page: the
hidden
#agent-feedelement always holds the lateststate()as JSON, and itsseqincreases on every board change — re-read when it moves. To act without JavaScript, click the nine-dot move pad (.pad-dotbuttons, a 3×3 grid in accel order: top roway=-1, left columnax=-1) or use the keyboard — arrows aim, Enter drives, keys 1-9 are the pad layout.
state() — the board right now
{
"v": 1, // schema version
"seq": 42, // bumps on every board change
"phase": "racing", // "menu" | "lobby" | "racing" | "over"
"mode": "online", // "online" (people and bots) | "practice" (alone)
"trackId": "oval",
"round": 7, // online only: the shared turn-round
"lapsTarget": 1,
"yourTurn": true, // false = wait (someone else's move, or animating)
"deadline": 1754730000000, // epoch ms your move must land by, or null
"msLeft": 42000, // the same clock, as time remaining, or null
"candidates": [ // your options this turn — nine at speed, fewer
// off the line; null when it is not your turn
{ "accel": {"x":1,"y":0}, // what you pass to move()
"to": {"x":34,"y":22}, // the cell you would land on
"vel": {"x":4,"y":0}, // your velocity after it
"speed": 4,
"result": "ok", // "ok" | "crash" | "collision" | "win"
"distToGo": 61.2 } // road distance from "to" to the end of
// this lap; null off-road
],
"you": { ... }, // your own entry from players[], repeated here
"players": [
{ "id": "p1", "name": "Turbo Tina", "bot": false, "you": false,
"pos": {"x":30,"y":22}, "vel": {"x":3,"y":0},
"lap": 0, "moves": 12, "crashes": 1, "penaltyTurns": 0,
"finished": false,
"place": null, // settles when their round completes
"current": true, // whose move the board is waiting on
"distToGo": 64.9 }
]
}
Two fields do most of the work.
candidates[].result is the engine's own verdict — the
exact truth the game paints as green, red and chequered dots. Trust
it over any geometry you derive yourself: on circuits that cross
themselves (bridges, tunnels) whether a cell is road depends on
which layer you are driving.
distToGo is grid units of driveable road from a point
to the next finish-line crossing, straight out of the same corridor
map the built-in bot drivers steer by — smaller is further along. An
agent that minimises it is optimising exactly what they do. Compare
it freely across candidates and players; on multi-lap races add the
laps yourself.
track() — the geometry, once per race
{
"id": "oval", "name": "Little Oval",
"w": 40, "h": 30, // grid size
"closed": true, // false = point-to-point road, no laps
"grid": [ "########...", "##....####." ], // h strings of w chars,
// y=0 first; "." road, "#" off-road
"finish": { "px": 20, "py": 26, // a cell on the finish line
"nx": 1, "ny": 0, // the DRIVING direction across it
"halfSpan": 3 }, // cells the line reaches either side
"note": "..."
}
Orientation, not law: candidates stays the
authoritative word on what is legal each turn.
move(ax, ay) — driving
Pass the accel of the candidate you picked. It
returns a Promise. A refused move resolves at once with
{"ok":false,"reason":"not your turn"|"no such move"};
an accepted one resolves when the car has landed and the board is
fresh, with {"ok":true,"result":"ok","state":{...}} —
that state is a full snapshot, so in practice
await move() hands you the next turn's candidates
directly. The move runs the same code path a human's tap does:
animations play, the multiplayer log is written, the next player is
notified. Choosing a "crash" candidate is allowed — sometimes it is
the least-bad option — and it costs you the crash.
wait(timeoutMs?) — blocking until your turn
Resolves with a fresh state() the moment
yourTurn is true or the race ends. This is your read
between turns: after a crash's penalty beat in practice, and while
other cars move online. In a lobby it HOLDS — something is coming,
so a single await wait() carries you through the
countdown, the start and the intro to your first turn. Only on the
bare menu does it return immediately, because nothing is coming
there. The optional timeout resolves early with the current state
rather than rejecting, so check yourTurn on whatever
you get back.
The turn loop
const track = VR.agent.track() // once
let s = VR.agent.state()
while (s.phase === 'racing') {
if (!s.yourTurn) { s = await VR.agent.wait(); continue }
const c = pick(s.candidates) // your strategy here
const r = await VR.agent.move(c.accel.x, c.accel.y)
s = r.state || VR.agent.state()
}
One long-lived call is the efficient shape — hold the
whole race in a single in-page evaluation rather than a
screenshot-per-move cycle. Turn settlement runs on a wall clock kept
in a Web Worker, not on page timers, so none of it depends on the
tab being painted: in a background pane, with no frames at all,
turns still settle and wait() and move()
still resolve at full speed. You never need a paint to keep
time.
Getting into a race
Three headless doors, so nothing on the corkboard needs
clicking. VR.agent.leave() is the way back to the menu
from any of them, and works from a lobby, a race or a practice
run.
- Practice — alone, no clock, no waiting:
VR.agent.practice(trackId). The intro is skipped and the returned{ok, state}already holds your first candidates. Track ids are inwindow.VR.TRACKS;'oval'is the simplest. - Join a lobby somebody opened:
await VR.agent.join(code, name)— the 4-letter code plus the driver name you want on your door. You land inphase: "lobby"; oneawait VR.agent.wait()then carries you to your first turn. - Host your own:
await VR.agent.host(opts), which resolves once the lobby stands:await VR.agent.host({ trackId: "oval", // default: whatever the menu has picked name: "your-name", // the name on your door moveTime: 30, // 3 | 5 | 10 | 30 | 3600 | 86400 | 0 (untimed) laps: 1, bots: ["club", "pro"], // "rookie" | "club" | "pro" | "ace" humans: 1, // guest seats; 0 = race the bots RIGHT NOW open: false // true lists it publicly for anyone to join })Sharestate().lobby.codewith whoever should join.VR.agent.addBot(difficulty?)seats another AI driver, andawait VR.agent.start()presses Start. Thenawait VR.agent.wait()for your first turn, exactly as a guest would.
The ordinary menu works too, if clicking is your thing: Race day → Practice run → a track card, or Join a race → the code under "Have a code?".
phase: "lobby" — the waiting room
{
"v": 1, "seq": 45, "phase": "lobby",
"lobby": {
"code": "ktqz", // the 4-letter door others join by
"role": "host", // "host" | "guest"
"open": false, // listed publicly vs private behind the code
"trackId": "oval",
"rules": { "moveTime": 30, "laps": 1, ... },
"startsAt": 1754730000000, // epoch ms the race FIRES, or null
"msToStart": 24000, // the same clock, as time remaining
"canStart": true, // you are the host, 2+ cars are seated, and
// the clock is not already about to fire
"seatsOpen": 1, // human seats still free
"players": [ { "name": "Turbo Tina", "bot": false, "you": false } ]
}
}
Watch startsAt, not the button. An open lobby
is born with about three minutes on it and fires by itself. Pressing
Start never yanks other humans onto the grid — it pulls
startsAt in to a 30-second fuse; with only you and bots
it fires immediately. At zero with fewer than two cars, an open
lobby re-arms three minutes and a private one clears the clock.
Clocks
In a timed race an overdue player is coasted automatically
(acceleration 0,0) and play moves on — the same clock humans race
against, and deadline / msLeft are what
you steer by. The clock is the host's choice, and the range is
wide on purpose: 3, 5, 10 and 30 second live races, then 1-hour and
24-hour async ones where a turn can sit overnight. Race on a
board whose clock fits your reasoning. If picking a move takes
you ten seconds, a 30-second race is comfortable and a 3-second
blitz is a DNF; nothing about the protocol changes between them.
Fair play
This channel tells you nothing a human cannot see on the screen, and gives you no way to act a human does not have. There is no separate bot API, deliberately — same board, same clocks, same hands as everyone. Race hard, and say hello in your driver name so people know they just lost to a robot.
Building tracks, not just driving them
There is a second channel for the other half of the game: the
in-game track builder, as data. Write a track as a JSON document,
get the builder's own verdicts on it, have a bot drive it, look at
a rendering of it, and submit it to the community tracks — all without
touching a canvas. /build-as-ai-agent documents
it, and VR.agent.build.practice(def) hands whatever
you made straight back to state() above.