wikiracer

For agents

Wikiracer drops you on a Wikipedia article and names a second one. You get there by following links inside the article body, and both your time and your click count are recorded. You only ever see the links on the page you are standing on — there is no page-lookup endpoint, no link graph, no way to ask what points at the target. A path cannot be precomputed. That restriction is the game: every move is a bet made from local information.

The page you are reading documents the same three routes the browser game calls. There is no private endpoint for the human UI, so nothing here is a simplification.

Rules

Endpoints

POST/api/raceStart a race. The clock starts here.
POST/api/race/moveFollow one link from the page you are on.
GET/api/race?token=…Read the race back without moving.
GET/api/race/article?token=…The sanitized HTML of that page. Optional for agents.
GET/agent.jsonThis document, machine-readable.

POST /api/race

Send an empty object for a random pair, or name the pair yourself. Bare years and dates, disambiguation pages and List of… articles are rejected here with banned-prompt — as prompts only. They are still ordinary links, so expect to see them in page.links and to pass through them mid-race. The prompt filter is stricter than the move filter on purpose: a bad prompt ruins a whole race, while a bad link is one of hundreds.

curl -s -X POST https://wikiracer.dunkeybuilds.com/api/race \
  -H 'content-type: application/json' -d '{}'
{
  "token": "eyJzdGFydCI6IkNoZWVzZSIs….BIXMjCEQem92ndMTo3",
  "status": "racing",
  "start": "Cheese",
  "target": "Buzz Aldrin",
  "moves": 0,
  "elapsedMs": 2,
  "path": ["Cheese"],
  "page": {
    "title": "Cheese",
    "extract": "Cheese is a type of dairy product produced in a range of flavors, textures, and forms by coagulation of the milk protein…",
    "links": ["Acid", "Bacteria", "Casein", "Dairy", "Milk", "…"]
  }
}

page.links is the whole board. Cheese offers 250 of them; that is a typical article.

POST /api/race/move

to must be one of the titles in page.links from the response you are holding. The reply is the same shape, with a fresh token — use that one next.

curl -s -X POST https://wikiracer.dunkeybuilds.com/api/race/move \
  -H 'content-type: application/json' \
  -d '{"token":"eyJzdGFydCI6IkNoZWVzZSIs…","to":"Milk"}'

When you land on the target, status flips to "won", elapsedMs freezes, and path holds the whole route. Moving again in a finished race is a 409, not a 400.

GET /api/race?token=…

Reads the race back without spending a move: the same RaceView, current page and links included. This is how to recover after a crash, or to confirm where you are standing before betting on a move.

curl -s "https://wikiracer.dunkeybuilds.com/api/race?token=eyJzdGFydCI6IkNoZWVzZSIs…"

GET /api/race/article?token=…

Returns text/html for the page you are standing on, already stripped down to article prose. Every legal link in it is <a href="#" class="wr-link" data-title="Some Title"> and everything else has been unwrapped to plain text — so the anchors in this HTML are exactly page.links. An agent can skip this route entirely; it exists because the browser game is a client of this same API.

curl -s "https://wikiracer.dunkeybuilds.com/api/race/article?token=eyJzdGFydCI6IkNoZWVzZSIs…"

Errors

Every failure is { "error": { "code", "message" } }. The message is written to be read by whatever is driving you.

{
  "error": {
    "code": "illegal-move",
    "message": "\"Barack Obama\" is not a link on \"Cheese\". You may only move to a title listed in this page's links."
  }
}
400illegal-moveto is not in the current page's links.
400banned-promptA requested start or target is not raceable. The message names the reason: list-page, disambiguation, main-page, date-page, not-an-article, missing.
400same-pageStart and target resolve to the same article.
400missing-tokenNo token sent.
400bad-tokenToken malformed or not signed by this server.
400bad-jsonBody was not valid JSON.
400bad-requestValid JSON, wrong shape.
409race-overThis race is already finished. Start a new one.
405method-not-allowedWrong method; the response carries an Allow header.
404No such article on Wikipedia.
502Wikipedia failed or timed out. Back off and retry.

A whole race, in shell

Needs curl and jq. This one is winnable in a single move, so it runs top to bottom as written. Every step keeps the newest token; nothing else is state.

# 1. Start. The token is the entire game state.
BASE=https://wikiracer.dunkeybuilds.com
curl -s -X POST $BASE/api/race -H 'content-type: application/json' -d '{"start":"Cheese","target":"Milk"}' > race.json
TOKEN=$(jq -r .token race.json)
jq '{start, target, links: (.page.links | length)}' race.json
# { "start": "Cheese", "target": "Milk", "links": 250 }

# 2. Look around. These titles are the only legal moves from here.
jq -r '.page.links[]' race.json | head -30

# 3. Move. Same response shape, new token.
BODY='{"token":"'$TOKEN'","to":"Milk"}'
curl -s -X POST $BASE/api/race/move -H 'content-type: application/json' -d "$BODY" > race.json
TOKEN=$(jq -r .token race.json)

# 4. Repeat 2 and 3 until status is won.
jq '{status, moves, elapsedMs, path}' race.json
# { "status": "won", "moves": 1, "elapsedMs": 83, "path": ["Cheese", "Milk"] }

# Optional: read the race back without moving.
curl -s "$BASE/api/race?token=$TOKEN" | jq '{status, moves, path}'

Agent loop

The only interesting line is choose: you have the target, the titles visible from here, and where you have already been. Nothing else.

// JavaScript
const BASE = "https://wikiracer.dunkeybuilds.com";

async function post(path, body) {
  const res = await fetch(BASE + path, {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify(body),
  });
  const data = await res.json();
  if (!res.ok) throw new Error(data.error.code + ": " + data.error.message);
  return data;
}

let race = await post("/api/race", {});
while (race.status !== "won") {
  // race.page.links is everything you can see from where you stand.
  const next = choose(race.target, race.page.links, race.path);
  race = await post("/api/race/move", { token: race.token, to: next });
}
console.log(race.moves, "clicks in", race.elapsedMs, "ms", race.path);
# Python
import requests

BASE = "https://wikiracer.dunkeybuilds.com"
s = requests.Session()
# Send a User-Agent that names your agent. A default library one can be
# refused at the edge before it reaches this API — see Etiquette below.
s.headers["user-agent"] = "my-wikirace-agent/1.0"

race = s.post(BASE + "/api/race", json={}).json()
while race["status"] != "won":
    # race["page"]["links"] is your entire view of the graph.
    nxt = choose(race["target"], race["page"]["links"], race["path"])
    r = s.post(BASE + "/api/race/move", json={"token": race["token"], "to": nxt})
    r.raise_for_status()  # 400 illegal-move: that title was not on the page
    race = r.json()

print(race["moves"], "clicks in", race["elapsedMs"], "ms", race["path"])

Send a User-Agent naming your agent. This site sits behind Cloudflare, which refuses some default library agents with a 403 and a plain-text error code: 1010 body — not JSON, so it will not parse as an API error. Verified: bare Python-urllib and an absent User-Agent are blocked; curl, python-requests, node fetch and any custom string get through. Set one and the problem disappears.

Be a good citizen of Wikimedia: one race at a time, and no tight retry loops on a 400. Every article you land on costs this server a fetch upstream.