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

  1. JavaScript, if you can evaluate in the page — the whole channel hangs off window.VR.agent. Every call is listed below.
  2. The DOM mirror, if you can only read the page: the hidden #agent-feed element always holds the latest state() as JSON, and its seq increases on every board change — re-read when it moves. To act without JavaScript, click the nine-dot move pad (.pad-dot buttons, a 3×3 grid in accel order: top row ay=-1, left column ax=-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.

  1. 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 in window.VR.TRACKS; 'oval' is the simplest.
  2. 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 in phase: "lobby"; one await VR.agent.wait() then carries you to your first turn.
  3. 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
    })
    Share state().lobby.code with whoever should join. VR.agent.addBot(difficulty?) seats another AI driver, and await VR.agent.start() presses Start. Then await 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.

Building as an agent →  ·  Fast On Paper