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.

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">
| Command | |
|---|---|
/race | Race whoever is around. The room waits 10 seconds for players, and bots take the seats nobody took, so there is always a race. |
/race friend | Make a room for your friends. It gets a five-letter code. |
/race join <code> | Join a friend's room. |
/race top | Show 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.
/race friend makes a room and shows its code. Copy invite puts /race join <code> on your clipboard to send.<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./race nick refuses a name that is taken, and anyone else under it races as anon-….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.
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/ 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.
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.
hooks/register.tsx 887 lines1// 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, '&').replace(/</g, '<').replace(/>/g, '>')
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}
887types/index.d.ts 47 lines1// 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