SLOPSHOPPER

code-race

Race others at typing a line of code, right in Claude Code: start with /race (or the Race button while Claude thinks), type in your message box.

newbandspinnercommandtoastprompt
★ 1v0.6.0MITupdated 2026-10-04nnixaa/claude-code-race/plugins/code-race
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · code-race
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /race ⎿ code-race: Looking for racers… ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

Code Race for Claude Code

Typing races against other Claude Code users, right above your prompt. Waiting on Claude? Race.

<img src="docs/race.png" width="640" alt="A race in progress above the prompt: four lanes with Claude's mascot in each, the line to type with the next character highlighted, and the same line half typed in the message box">

Play

Command
/raceRace whoever is around. The room waits 10 seconds for players, and bots take the seats nobody took, so there is always a race.
/race friendMake a room for your friends. It gets a five-letter code.
/race join <code>Join a friend's room.
/race topShow the leaderboard.
/race nick <name>Race under a name. It starts a race too.

While Claude is thinking, the Race button beside the spinner starts a race too.

A race

  • Up to four people race the same line. Half the lines are code (TypeScript, Python, Go, SQL, shell, CSS) and half are short notes in the style of developer docs.
  • The line shows above the prompt in a monospace face and opens a little at a time. Type it in your message box. A mistake turns red until you delete it, and Enter sends nothing while a race runs.
  • Whatever you had in the message box before the race is put back after it.
  • At the end: medals at the finish line, a crown for the winner, your place, words per minute and accuracy, and Rerace.
  • In the terminal every racer is a small Clawd, Claude Code's mascot, swinging its arms as it runs and raising them at the finish.

Racing friends

  • /race friend makes a room and shows its code. Copy invite puts /race join <code> on your clipboard to send.
  • Anyone in the room presses Start. There are no bots: only the people in the room race.
  • Rerace takes everyone into the room's next race, under the same code.

Leaderboard and names

<img src="docs/leaderboard.png" width="640" alt="The leaderboard above the prompt: medals for the top three, each player's mascot, their fastest race, races and wins, and your row lit">

  • /race top ranks everyone by their fastest finished race, with how many races they ran and won. Your row is lit, and shown under the top ten when you are not in it.
  • A name is one player's. Whoever first races under it keeps it, /race nick refuses a name that is taken, and anyone else under it races as anon-….

Install

Requires Claude Code 2.1.284 or later. The terminal and the desktop app both work.

/plugin marketplace add nnixaa/claude-code-race
/plugin install code-race@claude-code-race

Then start a new session.

Code race is a mod: a plugin of function hooks, which Claude Code offers as an early-access surface, so a release may change how it looks or behaves.

What it sends

To the race server: a random player id the plugin makes (with desktop or terminal, so the two apps are two players), your nickname, and during a race how far along the line you are and how many mistakes you made. A friends' room adds its code. What you type stays on your machine; only the count of matching characters is sent. Nothing from your sessions, prompts, code or account is sent.

The server keeps every race and result (the id, nickname, times, words per minute, accuracy) and who holds each name, for the leaderboard. Other racers see your nickname, never your id.

Without the server the plugin races bots offline.

Server

server/ is the backend: a Cloudflare Worker with Durable Objects for the lobby, each race and each friends' room, and D1 for races, results and names. It runs on Cloudflare's free plan, with a daily cap on joins and a smaller one per address (JOIN_CAP_PER_DAY in wrangler.toml, PER_ADDRESS_PER_DAY in src/index.ts). How fast the bots type is BOT_WPM_MIN and BOT_WPM_MAX. To run your own:

cd server
npm install
npx wrangler d1 create code-race   # put its id in wrangler.toml
npx wrangler d1 migrations apply code-race --remote
npx wrangler deploy

Then point SERVER in plugins/code-race/hooks/register.tsx at your Worker. For local work, npm run dev serves it on port 8787, npm run smoke races two scripted players against it, and node scripts/opponent.mjs [name] [wpm] [minutes] [server] waits in the lobby and races whoever comes.

Working on the plugin

claude --plugin-dir plugins/code-race loads it from this folder and reloads it on save. claude plugin validate plugins/code-race checks it the way Claude Code will. The engine writes the API's TypeScript declarations into plugins/code-race/.claude-plugin/types when it loads the plugin (or run /plugin-types there), and tsconfig.json uses them.

Limitations

  • On the desktop app the Race button shows while Claude is thinking, not while a tool runs, and the other racers move once a second.
  • In a very short terminal window the lanes sit right against each other; from about 26 rows there is a gap between them.

License

MIT

Source 2 files
hooks/register.tsx 887 lines
1// Code race: type a line of code faster than the others.
2//
3// The server matches up to four people into a race and fills the empty seats with bots.
4// Friends can have a room of their own instead: /race friend makes one under a code, the
5// others join with /race join <code>, and the race starts when one of them presses Start.
6// The band above the prompt shows the track and, under it, the line, opening a little at
7// a time. You type in Claude's own message box: what matches the line moves you on, a
8// mistake turns red there until you delete it, and Enter sends nothing while a race runs.
9// Whatever was in the box before the race is put back after it.
10//
11// Once a second the mod sends your position and gets everyone's back. The track is a
12// plain picture, drawn again on each answer, each key, and each change of phase: a
13// surface reloads an interactive picture on every redraw, and a plain one runs no
14// animation, so the racers move in steps of a second. Without the server the race is
15// against bots alone.
16import { atom, read, update } from 'claude-code'
17import type { EngineInterface, PromptDecoration, Register, RenderElement, RenderSurface } from 'claude-code'
18
19import type { Board, Race, Racer, Standing } from '../types'
20
21const SERVER = 'https://code-race.agentloom-app.workers.dev'
22const race = atom({ plugin: 'code-race', key: 'race' } as const, null as Race | null)
23const board = atom({ plugin: 'code-race', key: 'board' } as const, null as Board | null)
24
25const SEATS = 4
26const COUNTDOWN_MS = 3000
27const GO_MS = 700
28const GRACE_MS = 5000 // once everyone else is in, how long you have left
29const MAX_RACE_MS = 180_000 // a race is over this long after GO, as on the server
30const JOIN_TIMEOUT_MS = 8000 // no answer from the server by then: race bots instead
31const POLL_MS = 1000
32const SEGMENTS = 6
33const BEHIND = 12 // typed characters still shown in the line
34const AHEAD = 56 // characters shown ahead of where you are
35const PLACE = ['1st', '2nd', '3rd', '4th']
36const YOU = '#d97757'
37const GREY = '#8a857c'
38
39// Offline only: the line and the bots, made here.
40const LINES = [
41  'const active = users.filter(u => u.lastSeen > Date.now() - 7 * DAY).map(u => ({id: u.id, name: u.name.trim()})).sort((a, b) => a.name.localeCompare(b.name));',
42  'for (const [key, value] of Object.entries(config)) {if (value === undefined) continue; params.set(key, String(value));} const query = params.toString();',
43]
44const BOT_NAMES = ['mira', 'devon', 'kenji', 'ola', 'sam', 'priya', 'lucas', 'noor', 'ivan', 'zoe']
45const BOT_COLORS = ['#6a9bcc', '#788c5d', '#b0aea5']
46
47function botOf(name: string, color: string, length: number): Racer {
48  const wpm = 28 + Math.round(Math.random() * 27)
49  const finishMs = Math.round((length / ((wpm * 5) / 60)) * 1000)
50  const legs = Array.from({ length: SEGMENTS }, () => 1 / (0.6 + Math.random() * 0.8))
51  const total = legs.reduce((a, b) => a + b, 0)
52  let at = 0
53  const marks = legs.map(l => (at += l / total))
54  return { kind: 'bot', name, color, pos: 0, finishedAt: null, hasLeft: false, finishMs, marks }
55}
56
57// How far along a bot is, ms after the start: straight lines between its marks.
58function shareAt(bot: Racer, ms: number): number {
59  if (ms <= 0) return 0
60  const t = ms / bot.finishMs
61  if (t >= 1) return 1
62  let prev = 0
63  for (let i = 0; i < bot.marks.length; i++) {
64    const mark = bot.marks[i]!
65    if (t <= mark) return (i + (t - prev) / (mark - prev)) / SEGMENTS
66    prev = mark
67  }
68  return 1
69}
70
71// --- you ---------------------------------------------------------------------------------
72
73// The id lasts across sessions, so a leaderboard can count on it. The desktop app and the
74// terminal share this machine's store, so the id says which of the two plays: each is a
75// player of its own, the same one every session. A race keeps the id it joined with.
76async function playerOf($: EngineInterface): Promise<{ id: string; name: string }> {
77  let player = (await $.store.get('player')) as string | undefined
78  if (typeof player !== 'string') {
79    player = Array.from({ length: 4 }, () => Math.random().toString(36).slice(2, 8)).join('-')
80    await $.store.set('player', player)
81  }
82  const [surface] = await $.session.surfaces()
83  const name = ((await $.store.get('name')) as string | undefined) ?? `anon-${player.slice(0, 4)}`
84  return { id: `${player}-${surface === 'desktop' ? 'desktop' : 'terminal'}`, name }
85}
86
87// --- the server ---------------------------------------------------------------------------
88
89type HumanSeat = { kind: 'you' | 'human'; name: string; color: string; pos: number; finishedAt: number | null; left?: boolean }
90type BotSeat = { kind: 'bot'; name: string; color: string; finishMs: number; marks: number[] }
91type Snapshot = { roomId: string; party?: string | null; serverNow: number; code: string; formingUntil: number; startAt: number; racers: (HumanSeat | BotSeat)[] }
92type Answer = Snapshot | { offline: string } | { refused: string }
93
94async function call($: EngineInterface, path: string, body: unknown): Promise<Answer | null> {
95  try {
96    const res = await $.http.fetch(SERVER + path, {
97      method: 'POST',
98      headers: { 'content-type': 'application/json' },
99      body: JSON.stringify(body),
100    })
101    if (!res.ok) return null
102    return JSON.parse(res.text) as Answer
103  } catch {
104    return null
105  }
106}
107
108const colorOf = (c: unknown) => (typeof c === 'string' && /^#[0-9a-f]{6}$/i.test(c) ? c : GREY)
109
110// The race as an answer has it, on this machine's clock. The offset between the clocks is
111// measured at the middle of each request and kept from the quickest one: a slow answer
112// (the first join wakes the server) would put it off by up to a second, and the countdown
113// would jump. The server's finish stands over yours, since it is what the race records;
114// until it comes, yours shows.
115function fromSnapshot(r: Race, s: Snapshot, sentAt: number, receivedAt: number): Race {
116  const rtt = receivedAt - sentAt
117  const isBetter = r.offset === null || rtt <= r.rtt
118  const offset = isBetter ? s.serverNow - (sentAt + receivedAt) / 2 : r.offset!
119  const local = (t: number | null) => (t === null ? null : t - offset)
120  const racers: Racer[] = s.racers.flatMap((x): Racer[] => {
121    if (x.kind === 'you') return []
122    const color = colorOf(x.color)
123    if (x.kind === 'bot') return [{ kind: 'bot', name: x.name, color, pos: 0, finishedAt: null, hasLeft: false, finishMs: x.finishMs, marks: x.marks }]
124    return [{ kind: 'human', name: x.name, color, pos: x.pos, finishedAt: local(x.finishedAt), hasLeft: !!x.left, finishMs: 0, marks: [] }]
125  })
126  const yours = s.racers.find((x): x is HumanSeat => x.kind === 'you')
127  const finishedAt = yours?.finishedAt != null ? local(yours.finishedAt) : r.finishedAt
128  return {
129    ...r,
130    me: typeof yours?.name === 'string' ? yours.name : r.me, // as everyone sees it
131    roomId: s.roomId,
132    party: typeof s.party === 'string' ? s.party : null,
133    code: s.code,
134    formingUntil: s.formingUntil - offset,
135    startAt: s.startAt - offset,
136    racers,
137    polledAt: receivedAt,
138    offset,
139    rtt: isBetter ? rtt : r.rtt,
140    tick: r.tick + 1,
141    finishedAt,
142    pos: finishedAt !== null ? s.code.length : r.pos,
143  }
144}
145
146// Bots alone, after a short search: the server could not be reached, or said no.
147function offlineRace(r: Race, now: number, note: string): Race {
148  const code = LINES[Math.floor(Math.random() * LINES.length)]!
149  const names = [...BOT_NAMES].sort(() => Math.random() - 0.5)
150  const racers = BOT_COLORS.map((c, i) => botOf(names[i]!, c, code.length))
151  return { ...r, roomId: 'offline', code, formingUntil: now + 1500, startAt: now + 1500 + COUNTDOWN_MS, racers, note }
152}
153
154// `party`: 'new' makes a friends' room, a code joins one (or its next round); none races
155// whoever comes.
156async function startRace($: EngineInterface, party: string | null = null) {
157  const now = await $.clock.now()
158  await leaveRace($) // a race still running is left, its seat freed and your draft put back
159  await update($, board, () => null)
160  const me = await playerOf($)
161  const id = now
162  // Joining: drawn at once, filled in by the server's answer.
163  await update($, race, () => ({
164    id,
165    me: me.name,
166    playerId: me.id,
167    roomId: null,
168    party,
169    code: '',
170    formingUntil: now + JOIN_TIMEOUT_MS,
171    startAt: now + JOIN_TIMEOUT_MS + COUNTDOWN_MS,
172    racers: [],
173    polledAt: now,
174    offset: null,
175    rtt: 1e9, // no answer yet: any first one is quicker
176    pos: 0,
177    wrong: 0,
178    errors: 0,
179    finishedAt: null,
180    draft: null,
181    isRestored: false,
182    tick: 0,
183    note: null,
184  }))
185  const path = party === null ? '/join' : party === 'new' ? '/party' : `/party/${party}/join`
186  const answer = await call($, path, { player: me.id, name: me.name })
187  const at = await $.clock.now()
188  const cur = await read($, race)
189  if (!cur || cur.id !== id || cur.roomId !== null) return
190  // Friends get no bots in their place: a room that cannot be had is said so.
191  if (party !== null && !(answer && 'roomId' in answer)) return void (await refuse($, answer && 'refused' in answer ? answer.refused : 'The race server did not answer.'))
192  await update($, race, (cur): Race | null => {
193    if (!cur || cur.id !== id || cur.roomId !== null) return cur
194    if (answer && 'roomId' in answer) return fromSnapshot(cur, answer, now, at)
195    return offlineRace(cur, at, answer && 'offline' in answer ? answer.offline : 'Server unreachable')
196  })
197}
198
199// The leaderboard: the ten fastest players, each by their fastest finished race, and you.
200async function fetchBoard($: EngineInterface): Promise<Board | null> {
201  const me = await playerOf($)
202  try {
203    const res = await $.http.fetch(SERVER + '/leaderboard', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ player: me.id }) })
204    if (!res.ok) return null
205    const b = JSON.parse(res.text) as Board
206    return Array.isArray(b.top) ? b : null
207  } catch {
208    return null
209  }
210}
211
212async function refuse($: EngineInterface, why: string) {
213  await update($, race, () => null)
214  $.ui.toast(`Code race: ${why}`)
215}
216
217// Start, in a friends' room: the countdown begins for everyone at their next answer.
218async function startParty($: EngineInterface) {
219  const r = await read($, race)
220  if (!isCurrent(r) || !r.party || !r.roomId) return
221  const sentAt = await $.clock.now()
222  const answer = await call($, `/rooms/${r.roomId}/start`, { player: r.playerId })
223  if (!answer || !('roomId' in answer)) return
224  const at = await $.clock.now()
225  await update($, race, cur => (cur && cur.id === r.id ? fromSnapshot(cur, answer, sentAt, at) : cur))
226}
227
228async function poll($: EngineInterface) {
229  const r = await read($, race)
230  if (!isCurrent(r) || !r.roomId || r.roomId === 'offline') return
231  const sentAt = await $.clock.now()
232  // Marked first, so the ticker sends no second poll while this one runs.
233  await update($, race, cur => (cur && cur.id === r.id ? { ...cur, polledAt: sentAt } : cur))
234  const answer = await call($, `/rooms/${r.roomId}/progress`, { player: r.playerId, pos: r.pos, errors: r.errors })
235  if (!answer || !('roomId' in answer)) return
236  const at = await $.clock.now()
237  await update($, race, cur => (cur && cur.id === r.id ? fromSnapshot(cur, answer, sentAt, at) : cur))
238}
239
240// Leaves the race on screen, if one runs: the server stops waiting for you, and whatever
241// was in your message box before the race goes back there.
242async function leaveRace($: EngineInterface) {
243  const r = await read($, race)
244  if (!isCurrent(r)) return
245  const stage = phaseOf(r, await $.clock.now()).stage
246  if (r.roomId && r.roomId !== 'offline' && stage !== 'done') void call($, `/rooms/${r.roomId}/leave`, { player: r.playerId })
247  if (!r.isRestored && r.draft !== null) await $.prompt.fill({ text: r.draft, mode: 'replace' })
248}
249
250async function quitRace($: EngineInterface) {
251  await leaveRace($)
252  await update($, race, () => null)
253}
254
255// The session keeps a race across reloads of the mod, so one saved by an older version
256// can come back in another shape; such a race is dropped.
257const isCurrent = (r: Race | null): r is Race =>
258  !!r &&
259  typeof r.playerId === 'string' &&
260  typeof r.isRestored === 'boolean' &&
261  'offset' in r &&
262  Array.isArray(r.racers) &&
263  r.racers.every(x => typeof x.hasLeft === 'boolean' && Array.isArray(x.marks))
264
265// --- where the race is -------------------------------------------------------------------
266
267const finishOf = (r: Race, x: Racer) => (x.kind === 'bot' ? r.startAt + x.finishMs : x.finishedAt)
268
269function phaseOf(r: Race, now: number) {
270  // Those still racing: everyone but who quit or went quiet. When they are all in, you
271  // get GRACE_MS more; and nobody's race runs past MAX_RACE_MS.
272  const racing = r.racers.filter(x => !x.hasLeft)
273  const othersIn = r.racers.length > 0 && racing.every(x => (finishOf(r, x) ?? Infinity) <= now)
274  const ins = r.racers.map(x => finishOf(r, x) ?? Infinity).filter(t => t <= now)
275  const lastIn = othersIn ? Math.max(r.startAt, ...ins) : Infinity
276  const capAt = r.startAt + MAX_RACE_MS
277  const endAt = Math.min(othersIn ? lastIn + GRACE_MS : Infinity, capAt)
278  const isOut = r.finishedAt === null && now >= endAt
279  const isStarted = r.roomId !== null && now >= r.formingUntil
280  const stage = !isStarted ? 'joining' : now < r.startAt ? 'countdown' : r.finishedAt !== null || isOut ? 'done' : 'racing'
281  const count = stage === 'countdown' ? Math.ceil((r.startAt - now) / 1000) : 0
282  const showsGo = stage === 'racing' && now < r.startAt + GO_MS
283  const graceLeft = stage === 'racing' && othersIn ? Math.ceil((endAt - now) / 1000) : null
284  // A friends' room waits for Start, not for a clock.
285  const waitLeft = stage === 'joining' && r.roomId && !r.party ? Math.max(0, Math.ceil((r.formingUntil - now) / 1000)) : null
286  return { stage, count, showsGo, graceLeft, waitLeft, isOut, othersIn, endAt }
287}
288
289// Your place, from 1: one more than everyone who finished before you.
290function placeOf(r: Race): number {
291  const yours = r.finishedAt ?? Infinity
292  return 1 + r.racers.filter(x => (finishOf(r, x) ?? Infinity) < yours).length
293}
294
295// How much of the line your message box matches, and how many of the box's characters
296// that took. The box may rewrite what you type: a backslash before a character it reads as
297// markdown, curly quotes, a dash for --, an ellipsis for ...; each counts as what you typed.
298const SAME: Record<string, string> = { '“': '"', '”': '"', '‘': "'", '’': "'", '—': '--', '–': '--', '…': '...' }
299function matchOf(code: string, text: string): { pos: number; used: number } {
300  let i = 0
301  let j = 0
302  while (j < text.length && i < code.length) {
303    const ch = text[j]!
304    if (ch === code[i]) {
305      i++
306      j++
307    } else if (ch === '\\' && code[i] !== '\\' && text[j + 1] === code[i]) {
308      j++ // the box's escape, not a key you pressed
309    } else if (SAME[ch] && code.startsWith(SAME[ch]!, i)) {
310      i += SAME[ch]!.length
311      j++
312    } else break
313  }
314  return { pos: i, used: j }
315}
316const wpmOf = (chars: number, ms: number) => (ms > 500 ? Math.round(chars / 5 / (ms / 60_000)) : 0)
317const placeWord = (place: number) => (place === 1 ? 'You won!' : `${PLACE[place - 1]} place`)
318
319// --- the track ---------------------------------------------------------------------------
320
321// Claude's mascot as pixel art: a body with two eyes, arms out, and four legs in one of
322// two steps, so a racer seen at each redraw appears to run.
323const CELL = 2.5
324function mascot(color: string, step: 0 | 1): string {
325  const r = (x: number, y: number, w: number, h: number, fill = color) =>
326    `<rect x="${x * CELL}" y="${y * CELL}" width="${w * CELL}" height="${h * CELL}" fill="${fill}"/>`
327  const legs = [2, 4, 6, 8].map((x, i) => r(x, 4, 1, (i + step) % 2 === 0 ? 2 : 1)).join('')
328  return `${r(1, 0, 9, 4)}${r(0, 2, 11, 1)}${r(3, 1, 1, 1, '#1f1e1d')}${r(7, 1, 1, 1, '#1f1e1d')}${legs}`
329}
330
331// Drawn one to one for a band about 760 pixels wide; the surface scales it down when the
332// band is narrower. Its height is given to the surface too, or the picture takes a default.
333const W = 760
334const LANE = 24
335const TOP = 8 // room above the first lane for a crown
336const NAME_W = 84
337const FINISH = W - 44
338const MASCOT_W = 11 * CELL
339const trackX = (share: number) => NAME_W + share * (FINISH - NAME_W - MASCOT_W)
340const SANS = 'font-family="-apple-system,system-ui,sans-serif"'
341const trackHeight = () => TOP + SEATS * LANE
342const MEDALS = ['#e3b341', '#b8b5ad', '#c98b5b'] // gold, silver, bronze
343
344const escape = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
345
346// A medal right of the finish line for places 1 to 3, the place in words after that.
347function medal(y: number, place: number, isYou: boolean): string {
348  if (place > 3) return `<text x="${FINISH + 9}" y="${y + 13}" ${SANS} font-size="12" fill="${GREY}" font-weight="${isYou ? 700 : 400}">${PLACE[place - 1]}</text>`
349  return medalAt(FINISH + 18, y + 9, place)
350}
351function medalAt(cx: number, cy: number, place: number): string {
352  const ribbon = `<path d="M${cx - 5} ${cy - 10} L${cx - 2} ${cy - 3} L${cx + 2} ${cy - 3} L${cx + 5} ${cy - 10} Z" fill="${YOU}" opacity=".8"/>`
353  const disc = `<circle cx="${cx}" cy="${cy + 1}" r="7" fill="${MEDALS[place - 1]}"/>`
354  const digit = `<text x="${cx}" y="${cy + 4.5}" text-anchor="middle" ${SANS} font-size="9" font-weight="800" fill="#1f1e1d">${place}</text>`
355  return ribbon + disc + digit
356}
357
358// A pixel crown on the winner's head.
359function crown(x: number, y: number): string {
360  const r = (dx: number, dy: number, w: number, h: number) => `<rect x="${x + dx}" y="${y + dy}" width="${w}" height="${h}" fill="${MEDALS[0]}"/>`
361  return r(5, -4, 17, 3) + r(5, -8, 3, 4) + r(12, -9, 3, 5) + r(19, -8, 3, 4)
362}
363
364// A burst of confetti around the banner when you won, laid out the same way each time:
365// pieces on a golden-angle spiral, wide rather than tall to fit the track.
366function confetti(h: number): string {
367  const colors = [YOU, '#6a9bcc', '#788c5d', MEDALS[0], '#c98bb9']
368  const cx = (NAME_W + FINISH) / 2
369  const cy = h / 2
370  return Array.from({ length: 48 }, (_, i) => {
371    const angle = i * 2.39996
372    const reach = 0.45 + ((i * 37) % 55) / 100
373    const x = Math.round(cx + Math.cos(angle) * reach * 300)
374    const y = Math.round(Math.min(h - 6, Math.max(2, cy + Math.sin(angle) * reach * (h / 2 + 6))))
375    const turn = (i * 47) % 180
376    const [w, l] = i % 3 === 0 ? [4, 4] : [3, 7]
377    return `<rect x="${x}" y="${y}" width="${w}" height="${l}" rx="1" fill="${colors[i % colors.length]}" transform="rotate(${turn} ${x} ${y})"/>`
378  }).join('')
379}
380
381// The whole track in one picture: a lane each, the finish line, medals and a crown once
382// the race is over for you, and over it the wait, the countdown, GO, or your result.
383function trackSvg(r: Race, now: number): string {
384  const p = phaseOf(r, now)
385  const elapsed = Math.max(0, now - r.startAt)
386  const isRunning = p.stage === 'racing' || p.stage === 'done'
387  const step = (r.tick % 2) as 0 | 1 // a step per redraw
388  const parts: string[] = [`<clipPath id="names"><rect x="0" y="0" width="${NAME_W - 6}" height="${trackHeight()}"/></clipPath>`]
389
390  // Places among those already in.
391  const order = [...r.racers.map(x => finishOf(r, x) ?? Infinity), r.finishedAt ?? Infinity].filter(t => t <= now).sort((a, b) => a - b)
392  const placeAt = (t: number | null) => (t === null || t > now ? null : order.indexOf(t) + 1)
393  const marks = (y: number, place: number | null, isYou: boolean, x: number) => {
394    if (p.stage !== 'done' || place === null) return
395    parts.push(medal(y, place, isYou))
396    if (place === 1) parts.push(crown(x, y))
397  }
398  const lane = (i: number, name: string, isYou: boolean) => {
399    const y = TOP + i * LANE
400    if (isYou) parts.push(`<rect x="0" y="${y}" width="${W}" height="${LANE}" rx="6" fill="${YOU}" opacity=".12"/>`)
401    const weight = isYou ? 700 : 400
402    parts.push(`<text x="8" y="${y + 16}" ${SANS} font-size="13" fill="${isYou ? YOU : GREY}" font-weight="${weight}" clip-path="url(#names)">${escape(name.slice(0, 16))}</text>`)
403    parts.push(`<line x1="${NAME_W}" y1="${y + 19.5}" x2="${FINISH}" y2="${y + 19.5}" stroke="#c9c3b6" stroke-dasharray="2 5"/>`)
404    parts.push(`<rect x="${FINISH}" y="${y + 2}" width="2" height="19" fill="${GREY}"/>`)
405    return y + 4
406  }
407
408  // You, stepping with each character you type.
409  const yy = lane(0, r.me, true)
410  const youX = trackX(r.code ? r.pos / r.code.length : 0)
411  parts.push(`<g transform="translate(${youX} ${yy})">${mascot(YOU, (r.pos % 2) as 0 | 1)}</g>`)
412  marks(yy, placeAt(r.finishedAt), true, youX)
413  if (p.isOut) parts.push(`<text x="${FINISH + 9}" y="${yy + 13}" ${SANS} font-size="12" font-weight="700" fill="${GREY}">DNF</text>`)
414
415  // The others, where the last answer (a human) or their marks (a bot) put them.
416  r.racers.forEach((o, k) => {
417    const y = lane(k + 1, o.name, false)
418    const fin = finishOf(r, o)
419    const share = o.kind === 'human' ? (r.code ? o.pos / r.code.length : 0) : isRunning ? shareAt(o, elapsed) : 0
420    const isMoving = isRunning && (fin === null || fin > now) && !o.hasLeft
421    const x = trackX(share)
422    parts.push(`<g transform="translate(${x} ${y})" opacity="${o.hasLeft ? 0.4 : 1}">${mascot(o.color, isMoving ? step : 0)}</g>`)
423    marks(y, placeAt(fin), false, x)
424  })
425  // Seats nobody has taken yet, until the bots take them; friends race without.
426  if (p.stage === 'joining' || (p.stage === 'countdown' && !r.party)) {
427    for (let i = r.racers.length + 1; i < SEATS; i++) {
428      const y = TOP + i * LANE
429      parts.push(`<text x="8" y="${y + 16}" ${SANS} font-size="13" fill="#c9c3b6">…</text>`)
430      parts.push(`<line x1="${NAME_W}" y1="${y + 19.5}" x2="${FINISH}" y2="${y + 19.5}" stroke="#e3dfd5" stroke-dasharray="2 5"/>`)
431    }
432  }
433
434  const h = trackHeight()
435  const mid = (NAME_W + FINISH) / 2
436  const banner = (text: string, size: number) => {
437    parts.push(`<rect x="${mid - 100}" y="${h / 2 - 22}" width="200" height="44" rx="10" fill="#faf9f5" opacity=".88"/>`)
438    parts.push(`<text x="${mid}" y="${h / 2 + size * 0.35}" text-anchor="middle" ${SANS} font-size="${size}" font-weight="800" fill="${YOU}">${text}</text>`)
439  }
440  const isWin = p.stage === 'done' && !p.isOut && placeOf(r) === 1
441  if (isWin) parts.unshift(confetti(h))
442  if (p.waitLeft !== null && p.waitLeft > 0) banner(`${p.waitLeft}s`, 26)
443  if (p.stage === 'joining' && r.party && r.roomId) banner(escape(r.party), 26)
444  if (p.stage === 'countdown') banner(String(p.count), 32)
445  if (p.showsGo) banner('GO!', 32)
446  if (p.isOut) banner('Race over', 24)
447  else if (p.stage === 'done') banner(placeWord(placeOf(r)), 24)
448  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${h}" width="${W}" height="${h}" shape-rendering="crispEdges">${parts.join('')}</svg>`
449}
450
451// The line on the desktop, drawn as a picture in a monospace face, which the surface's
452// text has none of: every character the same width, so the line never shifts unevenly. As
453// in the terminal: what you typed dim, the next character on an orange ground, then what is
454// ahead, opening as you go.
455const MONO = 'font-family="ui-monospace,SFMono-Regular,Menlo,Consolas,monospace"'
456const LINE_FONT = 13
457const CHAR_W = LINE_FONT * 0.6
458const LINE_H = 22
459function lineSvg(r: Race): string {
460  const from = Math.max(0, r.pos - BEHIND)
461  const typed = `${from > 0 ? '…' : ''}${r.code.slice(from, r.pos)}`
462  const next = r.code[r.pos] ?? ''
463  const room = Math.floor(W / CHAR_W) - typed.length - 2 // to the right edge, less the cursor and the …
464  const ahead = r.code.slice(r.pos + 1, r.pos + 1 + room)
465  const more = r.pos + 1 + room < r.code.length
466  const at = (typed.length * CHAR_W).toFixed(1)
467  const y = 15
468  // Spaces as no-break spaces: a surface may fold a space at the edge of a text away.
469  const keep = (t: string) => escape(t).replace(/ /g, '\u00a0')
470  return (
471    `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${LINE_H}" width="${W}" height="${LINE_H}">` +
472    '<style>.t{fill:#1f1e1d}.d{fill:#a29f96}@media (prefers-color-scheme:dark){.t{fill:#eceae4}.d{fill:#7d7a72}}</style>' +
473    `<g ${MONO} font-size="${LINE_FONT}">` +
474    // What you typed ends where you are, whatever the face's real width.
475    `<text class="d" x="${at}" y="${y}" text-anchor="end">${keep(typed)}</text>` +
476    `<rect x="${at}" y="3" width="${CHAR_W.toFixed(1)}" height="17" rx="2" fill="${r.wrong > 0 ? '#c4473a' : YOU}"/>` +
477    `<text x="${at}" y="${y}" fill="#ffffff" font-weight="700">${keep(next)}</text>` +
478    `<text class="t" x="${((typed.length + 1) * CHAR_W).toFixed(1)}" y="${y}">${keep(ahead)}${more ? '<tspan class="d">…</tspan>' : ''}</text>` +
479    '</g></svg>'
480  )
481}
482
483// The leaderboard on the desktop, as a picture: a medal or the place, the racer's mascot,
484// the name, then the fastest race, races and wins in columns; your row lit as your lane is.
485const BOARD_ROW = 30
486const BOARD_COLORS = ['#6a9bcc', '#788c5d', '#c98bb9', '#e3b341', '#b0aea5']
487const COLS = { name: 84, best: 560, races: 650, wins: 740 }
488// A player keeps a colour from the name, wherever they place; yours is orange.
489const boardColor = (x: Standing) => (x.isYou ? YOU : BOARD_COLORS[[...x.name].reduce((h, c) => (h * 31 + c.charCodeAt(0)) >>> 0, 7) % BOARD_COLORS.length]!)
490const boardHeight = (b: Board) => 24 + Math.max(1, b.top.length) * BOARD_ROW + (b.you && !b.top.some(x => x.isYou) ? BOARD_ROW + 12 : 0)
491function boardSvg(b: Board): string {
492  const parts: string[] = ['<style>.t{fill:#1f1e1d}.d{fill:#8a857c}@media (prefers-color-scheme:dark){.t{fill:#eceae4}.d{fill:#9a968d}}</style>']
493  const label = (x: number, text: string, anchor = 'start') => parts.push(`<text class="d" x="${x}" y="14" text-anchor="${anchor}" ${SANS} font-size="11">${text}</text>`)
494  label(COLS.name, 'Player')
495  label(COLS.best, 'Best wpm', 'end')
496  label(COLS.races, 'Races', 'end')
497  label(COLS.wins, 'Wins', 'end')
498  // Everything on the row's middle line: the medal's disc, the mascot's body, the text.
499  const row = (x: Standing, y: number, color: string) => {
500    const mid = y + 13
501    const base = mid + 4.5 // a baseline that centres 13px text on the line
502    if (x.isYou) parts.push(`<rect x="0" y="${y}" width="${W}" height="${BOARD_ROW - 4}" rx="6" fill="${YOU}" opacity=".12"/>`)
503    parts.push(x.place <= 3 ? medalAt(22, mid - 1, x.place) : `<text class="d" x="22" y="${base}" text-anchor="middle" ${SANS} font-size="13">${x.place}</text>`)
504    parts.push(`<g transform="translate(44 ${mid - 2 * CELL})">${mascot(color, 0)}</g>`) // the body is four cells high
505    const name = `${SANS} font-size="13" ${x.isYou ? `fill="${YOU}" font-weight="700"` : 'class="t"'}`
506    parts.push(`<text x="${COLS.name}" y="${base}" ${name}>${escape(x.name.slice(0, 24))}</text>`)
507    parts.push(`<text class="t" x="${COLS.best}" y="${base}" text-anchor="end" ${SANS} font-size="13" font-weight="700">${x.wpm}</text>`)
508    parts.push(`<text class="d" x="${COLS.races}" y="${base}" text-anchor="end" ${SANS} font-size="13">${x.races}</text>`)
509    parts.push(`<text class="d" x="${COLS.wins}" y="${base}" text-anchor="end" ${SANS} font-size="13">${x.wins}</text>`)
510  }
511  b.top.forEach((x, i) => row(x, 22 + i * BOARD_ROW, boardColor(x)))
512  if (!b.top.length) parts.push(`<text class="d" x="${COLS.name}" y="${22 + 17}" ${SANS} font-size="13">No finished races yet.</text>`)
513  if (b.you && !b.top.some(x => x.isYou)) {
514    const y = 22 + Math.max(1, b.top.length) * BOARD_ROW + 12
515    parts.push(`<text class="d" x="22" y="${y - 8}" text-anchor="middle" ${SANS} font-size="13">⋯</text>`)
516    row(b.you, y, YOU)
517  }
518  const h = boardHeight(b)
519  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${h}" width="${W}" height="${h}">${parts.join('')}</svg>`
520}
521
522// --- the mod -----------------------------------------------------------------------------
523
524// What the band shows changes with time alone at these moments; a redraw is asked for then.
525const phaseKey = (r: Race, now: number) => {
526  const p = phaseOf(r, now)
527  return `${p.stage}:${p.count}:${p.showsGo}:${p.graceLeft}:${p.waitLeft}:${p.isOut}`
528}
529let lastKey = ''
530
531const USAGE =
532  'Usage: /race to race whoever comes, /race friend to make a room for friends, /race join <code> to join one, /race top for the leaderboard, /race nick <name> to race under a name nobody else has (letters, digits, _ . -).'
533
534export const register: Register = on => {
535  on('session.start', async ($, e, next) => {
536    // Immediate: a race starts at once, also while Claude is still answering.
537    await $.command.register({ name: 'race', description: 'Race others at typing a line of code', argumentHint: '[friend | join <code> | top | nick <name>]', immediate: true })
538    if (!isCurrent(await read($, race))) await update($, race, () => null)
539    $.clock.every(250, () => tick($))
540    return next(e)
541  })
542
543  on('command.run', { command: 'race' }, async ($, e) => {
544    const args = e.args.trim()
545    if (/^(?:top|leaderboard|board|lb)$/i.test(args)) {
546      const b = await fetchBoard($)
547      if (!b) return { text: 'The race server did not answer.' }
548      await update($, board, () => b)
549      return { text: 'The leaderboard is above the prompt.' }
550    }
551    if (/^(?:friend|friends|room|party)$/i.test(args)) {
552      void startRace($, 'new')
553      return { text: 'Making a room for friends…' }
554    }
555    const join = args.match(/^join\s+(\S+)$/i)?.[1]
556    if (join) {
557      if (!/^[a-z2-9]{5}$/i.test(join)) return { text: `${join} is not a room code: it has five letters and digits, like K7QMX.` }
558      void startRace($, join.toUpperCase())
559      return { text: `Joining room ${join.toUpperCase()}…` }
560    }
561    if (args) {
562      const name = args.match(/^(?:nick|name)\s+(.+)$/)?.[1]
563      const clean = name?.replace(/[^\p{L}\p{N}_.-]/gu, '').slice(0, 16)
564      if (!clean) return { text: USAGE }
565      // A name is one player's: the server says whether it is free.
566      const me = await playerOf($)
567      const answer = (await call($, '/nick', { player: me.id, name: clean })) as { name?: string; refused?: string } | null
568      if (!answer) return { text: 'The race server did not answer; your name is unchanged.' }
569      if (answer.refused) return { text: `${answer.refused} Pick another with /race nick <name>.` }
570      await $.store.set('name', answer.name ?? clean)
571    }
572    void startRace($)
573    return { text: `${args ? `You race as ${(await $.store.get('name')) as string}. ` : ''}Looking for racers…` }
574  })
575
576  // Your typing, in the message box: held back during the countdown, then checked against
577  // the line after each edit.
578  on('prompt.edit', async ($, e, next) => {
579    const r = await read($, race)
580    if (!isCurrent(r)) return next(e)
581    const stage = phaseOf(r, await $.clock.now()).stage
582    if (stage === 'countdown') return { text: e.text, cursor: e.cursor }
583    if (stage !== 'racing') return next(e)
584    if (!e.key && e.inputText.length > 8) return { text: e.text, cursor: e.cursor } // a paste is not typing
585    const box = await next(e)
586    const now = await $.clock.now()
587    const { pos, used } = matchOf(r.code, box.text)
588    const wrong = box.text.length - used
589    const done = pos === r.code.length // whatever trails it in the box does not hold you back
590    await update($, race, (cur): Race | null =>
591      isCurrent(cur) && cur.id === r.id && cur.finishedAt === null
592        ? { ...cur, pos, wrong, errors: cur.errors + (wrong > cur.wrong ? 1 : 0), finishedAt: done ? now : null, isRestored: done }
593        : cur,
594    )
595    if (done) {
596      void poll($) // the finish goes to the server at once
597      const draft = r.draft ?? ''
598      return { text: draft, cursor: draft.length }
599    }
600    // A red ground, not an underline: an underline hides an underscore and dresses up a space.
601    const decorations: PromptDecoration[] = wrong > 0 ? [{ start: used, end: box.text.length, backgroundColor: 'error', color: '#ffffff' }] : []
602    return { ...box, decorations: [...(box.decorations ?? []), ...decorations] }
603  })
604
605  // Your own Enter sends nothing to Claude while a race runs, so a half-typed line never
606  // reaches it. Slash commands still go through, and prompts that are not yours (a task's
607  // notice, a scheduled prompt) are left alone.
608  on('prompt.submit', async ($, e, next) => {
609    if (e.origin.kind !== 'composer' || e.text.trimStart().startsWith('/')) return next(e)
610    const r = await read($, race)
611    if (isCurrent(r) && ['countdown', 'racing'].includes(phaseOf(r, await $.clock.now()).stage)) {
612      // A held prompt still empties the box: what you typed goes straight back.
613      $.clock.after(50, () => void $.prompt.fill({ text: e.text, mode: 'replace' }))
614      return { drop: 'Enter is paused during a code race: finish the line or press Quit.' }
615    }
616    return next(e)
617  })
618
619  // While Claude works, a small Race button beside the spinner: the desktop holds a /race
620  // typed then in its own queue until the turn ends, so this is the way in mid-turn.
621  on('ui.render', { component: 'Spinner' }, async ($, e, next) => {
622    const row = await next(e)
623    if (e.surface !== 'desktop' && e.surface !== 'terminal') return row
624    const r = await read($, race)
625    if (isCurrent(r) && phaseOf(r, await $.clock.now()).stage !== 'done') return row
626    const { Box, Button } = $.ui.resolve(e)
627    return (
628      <Box flexDirection="row" columnGap={2} alignItems="center">
629        {row}
630        <Button key="race" label="Race" plain onPress={() => startRace($)} />
631      </Box>
632    )
633  })
634
635  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
636    if (e.props.hasSurvey || (e.surface !== 'terminal' && e.surface !== 'desktop')) return next(e)
637    const r = await read($, race)
638    const b = await read($, board)
639    const now = await $.clock.now()
640    const { Box, Text, Button } = $.ui.resolve(e)
641
642    // The leaderboard, asked for with /race top, while no race runs.
643    if (b && (!isCurrent(r) || phaseOf(r, now).stage === 'done')) {
644      const head = (
645        <Box flexDirection="row" columnGap={2} alignItems="center">
646          <Text bold>Code race</Text>
647          <Text dimColor>Leaderboard · each player's fastest race</Text>
648          <Button key="close" label="×" plain role="dismiss" onPress={() => update($, board, () => null)} />
649        </Box>
650      )
651      if (e.surface === 'desktop') {
652        const { Svg } = $.ui.resolve(e)
653        return (
654          <Box flexDirection="column" rowGap={1} width="100%">
655            {head}
656            <Svg source={boardSvg(b)} alt="Leaderboard" height={boardHeight(b)} />
657          </Box>
658        )
659      }
660      return (
661        <Box flexDirection="column" width="100%">
662          {head}
663          {terminalBoard(b, e.props.maxRows, Text)}
664        </Box>
665      )
666    }
667
668    if (!isCurrent(r)) return next(e)
669    const p = phaseOf(r, now)
670    const len = r.code.length
671    const status = statusOf(r, p, now, e.surface === 'terminal')
672
673    // The line, a window around where you are: what you typed dim, the next character on
674    // an orange ground (an underline would hide an underscore), then what is ahead; the
675    // rest opens as you go.
676    const lineOf = (ahead: number) => {
677      const from = Math.max(0, r.pos - BEHIND)
678      const next_ = r.code[r.pos] === ' ' ? '␣' : (r.code[r.pos] ?? '')
679      return (
680        <Text>
681          <Text dimColor>{`${from > 0 ? '…' : ''}${r.code.slice(from, r.pos)}`}</Text>
682          <Text bold color="#ffffff" backgroundColor={r.wrong > 0 ? 'error' : YOU}>{next_}</Text>
683          <Text>{r.code.slice(r.pos + 1, r.pos + 1 + ahead)}</Text>
684          <Text dimColor>{r.pos + 1 + ahead < len ? '…' : ''}</Text>
685        </Text>
686      )
687    }
688
689    const isDone = p.stage === 'done'
690    const isWaiting = p.stage === 'joining' && !!r.party && !!r.roomId // a friends' room, filling up
691    const quit = <Button key="quit" label="Quit" onPress={() => quitRace($)} />
692    const invite = `/race join ${r.party}`
693    const copy = async (surface: RenderSurface) => {
694      const { isCopied } = await $.ui.copy({ text: invite, surface })
695      $.ui.toast(isCopied ? `Copied: ${invite}` : `Send your friends: ${invite}`)
696    }
697    const header = (
698      <Box flexDirection="row" columnGap={2} alignItems="center">
699        <Text bold>Code race</Text>
700        <Text dimColor>{status}</Text>
701        {isWaiting && <Button key="start" label="Start" variant="primary" onPress={() => startParty($)} />}
702        {isWaiting && <Button key="copy" label="Copy invite" onPress={press => copy(press.surface)} />}
703        {isDone && <Button key="again" label="Rerace" variant="primary" onPress={() => startRace($, r.party)} />}
704        {isDone ? <Button key="quit" label="×" plain role="dismiss" onPress={() => quitRace($)} /> : quit}
705      </Box>
706    )
707
708    // The terminal: the lanes, then one row right over the prompt: the line while the race
709    // runs, the header otherwise. The band keeps its height from joining to the end.
710    if (e.surface === 'terminal') {
711      const ahead = Math.max(12, Math.min(AHEAD, e.props.bodyColumns - BEHIND - 14))
712      return (
713        <Box flexDirection="column" width="100%">
714          {terminalTrack(r, p, now, e.props.bodyColumns, e.props.maxRows, Box, Text)}
715          {p.stage === 'racing' ? (
716            <Box flexDirection="row" columnGap={2}>
717              {lineOf(ahead)}
718              {quit}
719            </Box>
720          ) : (
721            header
722          )}
723        </Box>
724      )
725    }
726
727    // The desktop: a plain picture, not a frame: a frame reloads, and flashes, on every redraw.
728    const { Svg } = $.ui.resolve(e)
729    return (
730      <Box flexDirection="column" rowGap={1} width="100%">
731        {header}
732        <Svg source={trackSvg(r, now)} alt="Race track" height={trackHeight()} />
733        {p.stage === 'racing' ? <Svg source={lineSvg(r)} alt={r.code} height={LINE_H} /> : null}
734      </Box>
735    )
736  })
737}
738
739// A small Clawd for the terminal, one row high and four cells wide: a body three cells
740// across with its two black eyes, and in the half cells at its sides the arms, which swing as
741// it runs and go up over the line.
742const EYES = '#000000'
743const ARMS = { down: ['▟', '▙'], up: ['▜', '▛'], left: ['▜', '▙'], right: ['▟', '▛'] } as const
744const ART_W = 4
745
746function clawd(arms: keyof typeof ARMS, color: string) {
747  return [
748    { text: ARMS[arms][0], color },
749    { text: '▪▪', color: EYES, bg: color },
750    { text: ARMS[arms][1], color },
751  ]
752}
753
754// The terminal's track: a row a racer, an empty row between them where the band has room.
755// The room is judged once, for all four seats, so nothing moves as racers join.
756function terminalTrack(r: Race, p: Phase, now: number, columns: number, maxRows: number, Box: any, Text: any): RenderElement {
757  const len = r.code.length
758  const elapsed = Math.max(0, now - r.startAt)
759  const isRunning = p.stage === 'racing' || p.stage === 'done'
760  const share = (o: Racer) => (o.kind === 'bot' ? (isRunning ? shareAt(o, elapsed) : 0) : len ? o.pos / len : 0)
761  const order = [...r.racers.map(x => finishOf(r, x) ?? Infinity), r.finishedAt ?? Infinity].filter(t => t <= now).sort((a, b) => a - b)
762  const placeAt = (t: number | null) => (t === null || t > now ? null : order.indexOf(t) + 1)
763  const lanes = [
764    { name: r.me, color: YOU, share: len ? r.pos / len : 0, isYou: true, moving: p.stage === 'racing', place: placeAt(r.finishedAt) },
765    ...r.racers.map(o => {
766      const fin = finishOf(r, o)
767      return { name: o.name, color: o.color, share: share(o), isYou: false, moving: isRunning && (fin === null || fin > now) && !o.hasLeft, place: placeAt(fin) }
768    }),
769  ]
770  const free = Math.max(1, (maxRows || 8) - 1) // less the row under the lanes
771  const hasGaps = free >= SEATS * 2 - 1
772  const width = Math.max(24, Math.min(64, columns - 20))
773  const tag = (place: number | null) => (p.stage === 'done' && place !== null ? ` ${['🥇', '🥈', '🥉'][place - 1] ?? PLACE[place - 1]}` : '')
774  const rows: RenderElement[] = []
775  const shown = Math.max(1, Math.min(SEATS, free))
776  lanes.slice(0, shown).forEach((l, i) => {
777    // A step a redraw for the others, a step a key for you.
778    const stride = l.isYou ? r.pos % 2 === 1 : r.tick % 2 === 0
779    const arms = l.place !== null ? 'up' : !l.moving ? 'down' : stride ? 'left' : 'right'
780    const at = Math.round(l.share * (width - ART_W))
781    if (hasGaps && i > 0) rows.push(<Text key={`gap${i}`}> </Text>)
782    rows.push(
783      <Text key={`lane${i}`}>
784        <Text color={l.isYou ? YOU : undefined} bold={l.isYou} dimColor={!l.isYou}>{l.name.slice(0, 10).padEnd(10)}</Text>
785        <Text dimColor>{` ${'─'.repeat(at)}`}</Text>
786        {clawd(arms, l.color).map((g, j) => (
787          <Text key={j} color={g.color} backgroundColor={'bg' in g ? g.bg : undefined}>{g.text}</Text>
788        ))}
789        <Text dimColor>{`${'─'.repeat(width - ART_W - at)}│`}</Text>
790        <Text bold={l.isYou}>{tag(l.place)}</Text>
791      </Text>,
792    )
793  })
794  // Seats nobody has taken yet keep their rows, so the band doesn't grow as racers join.
795  for (let i = lanes.length; i < shown; i++) {
796    if (hasGaps) rows.push(<Text key={`gap${i}`}> </Text>)
797    rows.push(<Text key={`lane${i}`} dimColor>{`${'·'.padEnd(10)} ${'─'.repeat(width)}│`}</Text>)
798  }
799  return <Box flexDirection="column">{rows}</Box>
800}
801
802// The leaderboard in the terminal: columns lined up, a medal or the place, a small Clawd in
803// the racer's colour, and as many rows as the band has room for.
804function terminalBoard(b: Board, maxRows: number, Text: any): RenderElement[] {
805  const you = b.you && !b.top.some(x => x.isYou) ? b.you : null
806  const room = Math.max(3, (maxRows || 14) - 2 - (you ? 1 : 0))
807  const cells = (x: Standing) =>
808    `${x.name.slice(0, 16).padEnd(16)}${String(x.wpm).padStart(9)}${String(x.races).padStart(7)}${String(x.wins).padStart(6)}`
809  const row = (x: Standing, color: string) => (
810    <Text key={`row${x.place}${x.name}`}>
811      <Text>{x.place <= 3 ? `${['🥇', '🥈', '🥉'][x.place - 1]} ` : `${String(x.place).padStart(2)} `}</Text>
812      {clawd('down', color).map((g, j) => (
813        <Text key={j} color={g.color} backgroundColor={'bg' in g ? g.bg : undefined}>{g.text}</Text>
814      ))}
815      <Text color={x.isYou ? YOU : undefined} bold={x.isYou}>{` ${cells(x)}`}</Text>
816    </Text>
817  )
818  return [
819    <Text key="head" dimColor>{`${' '.repeat(3 + ART_W)} ${'Player'.padEnd(16)}${'Best wpm'.padStart(9)}${'Races'.padStart(7)}${'Wins'.padStart(6)}`}</Text>,
820    ...(b.top.length ? b.top.slice(0, room).map(x => row(x, boardColor(x))) : [<Text key="none" dimColor>No finished races yet.</Text>]),
821    ...(you ? [row(you, YOU)] : []),
822  ]
823}
824
825// One clock for the race, every 250 ms: the server asked once a second while it runs, a
826// redraw at each change of phase, your draft taken out of the message box at the countdown
827// and put back when the race is over for you.
828async function tick($: EngineInterface) {
829  const r = await read($, race)
830  if (!isCurrent(r)) return
831  const now = await $.clock.now()
832  const p = phaseOf(r, now)
833
834  // No answer from the server in time: race bots instead.
835  if (r.roomId === null && now >= r.formingUntil) {
836    if (r.party) return void (await refuse($, 'The race server did not answer.'))
837    await update($, race, cur => (cur && cur.id === r.id && cur.roomId === null ? offlineRace(cur, now, 'Server unreachable') : cur))
838    return
839  }
840  // The countdown: what you had written is kept aside, and the box cleared for the race.
841  if (p.stage === 'countdown' && r.draft === null) {
842    const { text } = await $.prompt.read()
843    const kept = await update($, race, cur => (cur && cur.id === r.id && cur.draft === null ? { ...cur, draft: text } : cur))
844    if (kept?.draft === text) await $.prompt.fill({ text: '', mode: 'replace' })
845    return
846  }
847  // Over for you, finished or not: your draft goes back, once the box takes it.
848  if (p.stage === 'done' && !r.isRestored) {
849    const { isFilled } = await $.prompt.fill({ text: r.draft ?? '', mode: 'replace' })
850    if (isFilled) await update($, race, cur => (cur && cur.id === r.id ? { ...cur, isRestored: true } : cur))
851  }
852
853  // Answers keep coming until everyone is in, so medals arrive as they finish.
854  const isLive = p.stage !== 'done' || (!p.othersIn && now < r.startAt + MAX_RACE_MS)
855  if (r.roomId && r.roomId !== 'offline' && isLive && now - r.polledAt >= POLL_MS) return void (await poll($))
856  if (r.roomId === 'offline' && isLive && p.stage !== 'joining' && now - r.polledAt >= POLL_MS) {
857    await update($, race, cur => (cur && cur.id === r.id ? { ...cur, polledAt: now, tick: cur.tick + 1 } : cur)) // offline: a redraw a second
858    return
859  }
860  const key = phaseKey(r, now)
861  if (key !== lastKey) {
862    lastKey = key
863    await update($, race, cur => (cur && cur.id === r.id ? { ...cur, tick: cur.tick + 1 } : cur))
864  }
865}
866
867type Phase = ReturnType<typeof phaseOf>
868
869function statusOf(r: Race, p: Phase, now: number, isTerminal: boolean): string {
870  const humans = r.racers.filter(o => o.kind === 'human').length + 1
871  const bots = r.racers.filter(o => o.kind === 'bot').length
872  const who = `${humans} ${humans === 1 ? 'person' : 'people'}${bots ? `, ${bots} bot${bots === 1 ? '' : 's'}` : ''}`
873  if (p.stage === 'joining') {
874    if (r.party === 'new' && !r.roomId) return 'Making a room…'
875    if (r.party && !r.roomId) return `Joining room ${r.party}…`
876    if (r.party) return `Room ${r.party} · ${humans}/${SEATS} in · friends join with /race join ${r.party}`
877    if (r.roomId === 'offline') return `${r.note ?? 'Offline'} · racing bots`
878    return `Finding racers… ${humans}/${SEATS}${p.waitLeft !== null ? ` · ${p.waitLeft}s` : ''}`
879  }
880  if (p.stage === 'countdown') return `${isTerminal ? `${p.count}… ` : ''}Get ready · ${r.roomId === 'offline' ? (r.note ?? 'offline') : who}`
881  if (p.stage === 'racing') return p.graceLeft !== null ? `Everyone else is in · ${p.graceLeft}s left` : 'Type the line in your message box'
882  const accuracy = r.pos + r.errors > 0 ? Math.round((100 * r.pos) / (r.pos + r.errors)) : 100
883  if (p.isOut) return `Race over · ${PLACE[placeOf(r) - 1]} place · ${wpmOf(r.pos, p.endAt - r.startAt)} wpm`
884  const yourMs = (r.finishedAt ?? now) - r.startAt
885  return `${placeWord(placeOf(r))} · ${wpmOf(r.code.length, yourMs)} wpm · ${accuracy}% accuracy`
886}
887
types/index.d.ts 47 lines
1// Someone else in the race. Times are on this machine's clock: the server's are moved over
2// by the offset measured at each answer.
3export type Racer = {
4  kind: 'human' | 'bot'
5  name: string
6  color: string
7  pos: number // a human's position at the last answer
8  finishedAt: number | null // a human's finish
9  hasLeft: boolean // a human who quit or went quiet: nobody waits for them
10  finishMs: number // a bot's finish, after the start
11  marks: number[] // a bot's pace: when it reaches each sixth of the line, as shares of finishMs
12}
13
14export type Race = {
15  id: number // when it was started, which tells one race from the next
16  me: string // your nickname
17  playerId: string // the id this race joined with, used for all its calls
18  roomId: string | null // null while joining; 'offline' when racing bots alone
19  party: string | null // a friends' room: its code ('new' while it is being made); null for a race with whoever comes
20  code: string
21  formingUntil: number // when the room closes and the countdown starts
22  startAt: number // GO: typing opens
23  racers: Racer[]
24  polledAt: number
25  offset: number | null // server clock minus this one, from the quickest answer so far
26  rtt: number // how long that answer took
27  pos: number // how much of the line your message box matches
28  wrong: number // characters in your box past that, which you have to delete
29  errors: number
30  finishedAt: number | null
31  draft: string | null // what was in your message box before the race, given back after
32  isRestored: boolean // the message box has been given back
33  tick: number // bumped on each answer and at each change of phase: a redraw, and a racer's next step
34  note: string | null
35}
36
37// A row of the leaderboard: a player's fastest finished race, how many they ran and won.
38export type Standing = { place: number; name: string; wpm: number; races: number; wins: number; isYou: boolean }
39// The leaderboard as shown above the prompt: the ten best, and you wherever you are.
40export type Board = { top: Standing[]; you: Standing | null }
41
42declare module 'claude-code' {
43  interface PluginState {
44    'code-race': { race: Race | null; board: Board | null }
45  }
46}
47