Typing test above the Claude Code prompt: retype a line of code as fast as you can. Plays while Claude works, pauses when the turn ends, keeps your best score…

Typing test above the Claude Code prompt, for the minutes Claude spends working: retype a line of code as fast as you can. The game pauses and its status line says so when Claude finishes the turn, so you never miss a reply. Playing costs no tokens: the mod answers every key and click itself.
A single game split out of sezaakgun/cc-arcade (MIT, by Seza Akgün). Want all nine games, a picker and the pet in one plugin? Install
games/cc-arcadeinstead.
/typing opens the board above the prompt (/typing stop or the close button closes it).Your best score is kept in the plugin's store across sessions.
npx claude-code-templates@latest --mod games/typing-test
claude
It is written to .claude/skills/typing-test/, which Claude Code auto-loads as typing-test@skills-dir. For one session with hot reload: claude --plugin-dir .claude/skills/typing-test.
claude -p, the desktop app or mobile.Clones of the genre, not affiliated with or endorsed by the original publishers.
hooks/register.tsx 92 lines1/* @jsx h */
2import type { Register } from 'claude-code'
3import { isBetter } from './games/best.ts'
4
5// Typing test above the Claude Code prompt, split out of cc-arcade (https://github.com/sezaakgun/cc-arcade,
6// MIT, by Seza Akgün) as a single-game mod. `/typing` opens it, `/typing stop` closes it. The board in
7// ./boards is a surface module that runs on the drawing thread; this module mounts it, keeps the best
8// score in $.store and pauses the game when Claude finishes a turn. Playing costs no tokens.
9
10const GAME = 'typing' // what the board posts its score under
11const TITLE = 'Typing test'
12const LOWER_IS_BETTER = false
13const UNIT = ' wpm'
14
15let open = false
16// bumped at each finished turn; the open game pauses when it sees a new value
17let turnsDone = 0
18let best = 0
19
20export const register: Register = on => {
21 on('session.start', async ($, e, next) => {
22 const r = await next(e)
23 // a store that cannot be read costs the best score, never the game
24 const score = Number(await $.store.get('best').catch(() => undefined))
25 if (score) best = score
26 await $.command.register({
27 name: 'typing',
28 description: `$Typing test above the prompt, while Claude works (stop closes)`,
29 argumentHint: '[stop]',
30 immediate: true,
31 }).catch(err => $.ui.log(`typing-test: /typing not registered: ${err}`))
32 return r
33 })
34
35 on('command.run', { command: 'typing' }, async ($, e) => {
36 const arg = e.args.trim().toLowerCase()
37 if (arg === 'stop' || arg === 'close') {
38 open = false
39 $.ui.invalidate('ui.render')
40 return { text: `$Typing test closed` }
41 }
42 open = true
43 $.ui.invalidate('ui.render')
44 const score = best ? ` · best ${best}$ wpm` : ''
45 return { text: `$Typing test · click the board to play · Esc returns to the prompt · /typing stop closes${score}` }
46 })
47
48 on('turn.complete', async ($, e, next) => {
49 const r = await next(e)
50 // a subagent's turn is not the one the person is waiting for
51 if (e.agentId) return r
52 if (open) {
53 turnsDone++
54 $.ui.invalidate('ui.render')
55 }
56 return r
57 })
58
59 // a finished round, posted by the board: keep the best score and hand it back with the next props
60 on('ui.message', async ($, e, next) => {
61 const data = e.data as { game?: unknown; score?: unknown } | null
62 if (data?.game !== GAME || typeof data.score !== 'number') return next(e)
63 if (isBetter(data.score, best || undefined, LOWER_IS_BETTER)) {
64 best = data.score
65 await $.store.set('best', best).catch(err => $.ui.log(`typing-test: store write failed: ${err}`))
66 $.ui.toast(`$Typing test: new best ${best}$ wpm`)
67 }
68 return { props: { best, done: turnsDone, safe: false } }
69 })
70
71 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
72 // the board needs a terminal's keys and mouse; the desktop and mobile surfaces draw their own band
73 if (!open || e.props.hasSurvey || e.surface !== 'terminal') return next(e)
74 const { Box, Button, Client } = $.ui.resolve(e)
75 // the band's own width: the transcript column's while a Pane is docked beside it
76 const cols = e.props.bodyColumns || (e.viewport?.columns ?? 80)
77 const close = () => {
78 open = false
79 $.ui.invalidate('ui.render')
80 }
81 return (
82 <Box flexDirection="column">
83 <Box flexDirection="row" columnGap={1}>
84 <Button key="typing-test:close" label="close" onPress={close} />
85 </Box>
86 <Client key="board:typing" module="./boards/typing.tsx" width={cols} props={{ best, done: turnsDone, safe: false }} />
87 {await next(e)}
88 </Box>
89 )
90 })
91}
92hooks/games/best.ts 10 lines1// Which score counts as a new best. Minesweeper's best is its fastest clear, so lower wins there;
2// everywhere else higher wins. A score of zero or less never counts: a Minesweeper board whose
3// first click clears it takes 0 s and would otherwise lock the best forever, and a Pong match you
4// lost posts a negative margin.
5export function isBetter(score: number, prev: number | undefined, lowerIsBetter = false): boolean {
6 if (!Number.isFinite(score) || score <= 0) return false
7 if (prev === undefined || prev <= 0) return true
8 return lowerIsBetter ? score < prev : score > prev
9}
10hooks/boards/typing.tsx 62 lines1/* @jsx h */
2import type { ClientSurface } from 'claude-code'
3import { base, claudeDone, statusLine, type Base, type Props } from './common.tsx'
4import { accuracy, newTyping, press, wpm, type TypingGame } from '../games/typing.ts'
5
6type State = Base & { game: TypingGame; ms: number; started: boolean }
7type Run = [text: string, color: string, bg?: string]
8
9export default function Typing(props: Props, surface: ClientSurface<State>) {
10 const { Box, Text } = surface.elements
11 const fresh = (s: State): State => ({ ...s, banner: false, playing: false, started: false, ms: 0, game: newTyping(Math.random, Math.max(20, surface.columns - 4)) })
12
13 if (surface.state === undefined) {
14 surface.setState({ ...base(props), game: newTyping(), ms: 0, started: false })
15 // the clock runs from the first key to the last correct one, and stops while Claude's banner shows
16 surface.every(100, () => {
17 const s = surface.state
18 if (s?.started && s.playing && !s.game.done) surface.setState({ ...s, ms: s.ms + 100 })
19 })
20 surface.onKey(({ key, ctrl, meta }) => {
21 const s = surface.state
22 if (!s || ctrl || meta) return
23 // Tab skips to another line; Enter after a finished line starts the next
24 if (key === 'tab' || (key === 'return' && s.game.done)) return surface.setState(fresh(s))
25 // the space bar may arrive as the character or by name
26 const game = press(s.game, key === 'space' ? ' ' : key)
27 if (game === s.game) return
28 if (game.done) surface.post({ game: 'typing', score: wpm(game.text.length, s.ms) })
29 surface.setState({ ...s, banner: false, playing: true, started: true, game })
30 })
31 }
32 claudeDone(props, surface)
33
34 const s = surface.state
35 const g = s?.game
36 if (!s || !g) return <Text dimColor>typing</Text>
37
38 // typed characters green when right, the expected character on red when wrong, the cursor
39 // inverted, the rest dim; one span per run of the same style
40 const runs: Run[] = []
41 for (let i = 0; i < g.text.length; i++) {
42 const style: Run = i < g.typed.length
43 ? g.typed[i] === g.text[i] ? [g.text[i], 'green'] : [g.text[i], 'whiteBright', 'red']
44 : i === g.typed.length ? [g.text[i], 'black', 'white'] : [g.text[i], 'gray']
45 const last = runs[runs.length - 1]
46 if (last && last[1] === style[1] && last[2] === style[2]) last[0] += style[0]
47 else runs.push(style)
48 }
49 const seconds = (s.ms / 1000).toFixed(1)
50 const text = g.done ? `typing · ${wpm(g.text.length, s.ms)} wpm · ${accuracy(g)}% accuracy · ${seconds}s · Enter for another line`
51 : !s.started ? 'typing · click here and type the line · Tab skips to another'
52 : `typing · ${wpm(g.typed.length, s.ms)} wpm · ${accuracy(g)}% · ${seconds}s · Tab skips`
53 return (
54 <Box flexDirection="column">
55 <Box borderStyle="round" borderColor={g.done ? 'green' : 'gray'} paddingX={1}>
56 <Text>{runs.map(([t, c, b]) => (b ? <Text color={c} backgroundColor={b}>{t}</Text> : <Text color={c}>{t}</Text>))}</Text>
57 </Box>
58 {statusLine(Text, s.banner, `${text} · best ${props?.best ?? 0} wpm`)}
59 </Box>
60 )
61}
62hooks/boards/common.tsx 61 lines1/* @jsx h */
2import type { ClientElements, ClientSurface } from 'claude-code'
3
4// Shared by every board. A board is a surface module: it runs on the drawing thread with its own
5// frame clock, keys (after a click gives it focus; Esc gives the prompt back) and mouse.
6//
7// Never name a local `h` in a board file: every JSX tag compiles to a call of `h`.
8
9export type Props = { best?: number; done?: number; safe?: boolean } | undefined
10export type Base = { playing: boolean; banner: boolean; seenDone: number }
11export type Paint = readonly [glyph: string, color?: string, bg?: string]
12type TextTag = ClientElements['Text']
13
14export const base = (props: Props): Base => ({ playing: false, banner: false, seenDone: props?.done ?? 0 })
15
16// the hooks module bumps props.done when Claude finishes a turn: pause and say so, once per turn
17export function claudeDone<S extends Base>(props: Props, surface: ClientSurface<S>) {
18 const s = surface.state
19 const done = props?.done ?? 0
20 if (s && done !== s.seenDone) surface.setState({ ...s, seenDone: done, banner: true, playing: false })
21}
22
23export function statusLine(Text: TextTag, banner: boolean, text: string) {
24 return banner
25 ? <Text color="yellow" bold wrap="truncate-end">{`● Claude is done · ${text}`}</Text>
26 : <Text dimColor wrap="truncate-end">{text}</Text>
27}
28
29export type Run = [text: string, color?: string, bg?: string]
30
31// one run per stretch of cells sharing both colors. A drawing the engine cannot fit in its node
32// budget is worse than a coarser one, so maxRuns folds neighbours together, pair by pair, until
33// the row fits: a merged pair keeps the left one's colors and every glyph.
34export function runs(cols: number, y: number, at: (x: number, y: number) => Paint, maxRuns = Infinity): Run[] {
35 let out: Run[] = []
36 for (let x = 0; x < cols; x++) {
37 const [glyph, color, bg] = at(x, y)
38 const last = out[out.length - 1]
39 if (last && last[1] === color && last[2] === bg) last[0] += glyph
40 else out.push([glyph, color, bg])
41 }
42 while (out.length > Math.max(1, maxRuns)) {
43 const folded: Run[] = []
44 for (let i = 0; i < out.length; i += 2) {
45 const [t, c, b] = out[i]
46 const next = out[i + 1]
47 folded.push(next ? [t + next[0], c, b] : [t, c, b])
48 }
49 out = folded
50 }
51 return out
52}
53
54// a board as rows of two-character cells (a terminal cell is about twice as tall as wide, so two
55// make a square), one styled span per run of the same colors
56export function grid(Text: TextTag, cols: number, rowCount: number, at: (x: number, y: number) => Paint, maxRuns = Infinity) {
57 return Array.from({ length: Math.max(0, rowCount) }, (_, y) => (
58 <Text>{runs(cols, y, at, maxRuns).map(([t, c, b]) => (b ? <Text color={c} backgroundColor={b}>{t}</Text> : <Text color={c}>{t}</Text>))}</Text>
59 ))
60}
61hooks/games/typing.ts 39 lines1// Typing test: pure logic, drawn by the Typing module in ../surface.tsx. Lines of code and prose to
2// retype; speed is words per minute at five characters a word.
3
4export const LINES = [
5 'const total = items.reduce((sum, item) => sum + item.price, 0)',
6 'if (!response.ok) throw new Error(`request failed: ${response.status}`)',
7 'git commit -m "fix: retry the upload when the connection drops"',
8 'for (const [key, value] of Object.entries(config)) console.log(key, value)',
9 'The quick brown fox jumps over the lazy dog while the build runs.',
10 'export function clamp(n: number, lo: number, hi: number) { return Math.min(hi, Math.max(lo, n)) }',
11 'SELECT name, count(*) FROM orders GROUP BY name ORDER BY 2 DESC LIMIT 10;',
12 'Small, well-named functions are easier to read than clever ones.',
13 'docker run --rm -it -v "$PWD":/app -w /app node:22 npm test',
14 'def mean(xs): return sum(xs) / len(xs) if xs else 0.0',
15]
16
17export type TypingGame = { text: string; typed: string; errors: number; done: boolean }
18
19export const newTyping = (rand = Math.random, maxLength = Infinity): TypingGame => {
20 const fits = LINES.filter(l => l.length <= maxLength)
21 const pool = fits.length ? fits : LINES
22 return { text: pool[Math.floor(rand() * pool.length)], typed: '', errors: 0, done: false }
23}
24
25// one key: a printable character is typed (a wrong one counts as an error and stays until
26// backspaced), backspace removes the last character; the line is done when typed exactly
27export function press(g: TypingGame, key: string): TypingGame {
28 if (g.done) return g
29 if (key === 'backspace' || key === 'delete') return { ...g, typed: g.typed.slice(0, -1) }
30 if ([...key].length !== 1 || key < ' ') return g
31 const typed = g.typed + key
32 if (typed.length > g.text.length) return g
33 const errors = g.errors + (key === g.text[typed.length - 1] ? 0 : 1)
34 return { ...g, typed, errors, done: typed === g.text }
35}
36
37export const wpm = (chars: number, ms: number) => (ms > 0 ? Math.round((chars / 5) / (ms / 60_000)) : 0)
38export const accuracy = (g: TypingGame) => (g.typed.length ? Math.max(0, Math.round(100 * (1 - g.errors / (g.typed.length + g.errors)))) : 100)
39