Space Invaders above the Claude Code prompt: shoot each wave before it lands. Plays while Claude works, pauses when the turn ends, keeps your best score; zero…

Space Invaders above the Claude Code prompt, for the minutes Claude spends working: shoot each wave before it lands. 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.
/invaders opens the board above the prompt (/invaders stop or the close button closes it).p pauses, r restarts. One shot at a time, three lives, and each cleared wave starts a row lower and steps faster.Your best score is kept in the plugin's store across sessions.
npx claude-code-templates@latest --mod games/invaders
claude
It is written to .claude/skills/invaders/, which Claude Code auto-loads as invaders@skills-dir. For one session with hot reload: claude --plugin-dir .claude/skills/invaders.
claude -p, the desktop app or mobile.Clones of the genre, not affiliated with or endorsed by the original publishers.
hooks/register.tsx 95 lines1/* @jsx h */
2import type { Register } from 'claude-code'
3import { isBetter } from './games/best.ts'
4
5// Space Invaders 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. `/invaders` opens it, `/invaders 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 = 'invaders' // what the board posts its score under
11const TITLE = 'Space Invaders'
12const LOWER_IS_BETTER = false
13const UNIT = ''
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: 'invaders',
28 description: `$Space Invaders above the prompt, while Claude works (stop closes)`,
29 argumentHint: '[stop]',
30 immediate: true,
31 }).catch(err => $.ui.log(`invaders: /invaders not registered: ${err}`))
32 return r
33 })
34
35 on('command.run', { command: 'invaders' }, 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: `$Space Invaders closed` }
41 }
42 open = true
43 $.ui.invalidate('ui.render')
44 const score = best ? ` · best ${best}$` : ''
45 return { text: `$Space Invaders · click the board to play · Esc returns to the prompt · /invaders 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(`invaders: store write failed: ${err}`))
66 $.ui.toast(`$Space Invaders: new best ${best}$`)
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 // one row for the close button, one for whatever else draws in the band
78 const rows = Math.max(8, e.props.maxRows - 2)
79 const tall = (max: number) => Math.min(rows, max)
80 const close = () => {
81 open = false
82 $.ui.invalidate('ui.render')
83 }
84 return (
85 <Box flexDirection="column">
86 <Box flexDirection="row" columnGap={1}>
87 <Button key="invaders:close" label="close" onPress={close} />
88 </Box>
89 <Client key="board:invaders" module="./boards/invaders.tsx" width={cols} height={tall(19)} props={{ best, done: turnsDone, safe: false }} />
90 {await next(e)}
91 </Box>
92 )
93 })
94}
95hooks/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/invaders.tsx 84 lines1/* @jsx h */
2import type { ClientSurface } from 'claude-code'
3import { base, claudeDone, grid, statusLine, type Base, type Props } from './common.tsx'
4import { fire, newInvaders, shift, tick, type InvadersGame, type Pt } from '../games/invaders.ts'
5
6const ROW_COLORS = ['magentaBright', 'cyan', 'green', 'yellow']
7type State = Base & { game?: InvadersGame }
8
9export default function Invaders(props: Props, surface: ClientSurface<State>) {
10 const { Box, Text } = surface.elements
11 const size = () => ({
12 bw: Math.max(20, Math.min(30, Math.floor((surface.columns - 2) / 2))),
13 bh: Math.max(10, Math.min(16, surface.rows - 3)),
14 })
15 // the running round, or a new one when there is none or the last one ended
16 const ensure = (s: State): InvadersGame => (s.game && !s.game.over ? s.game : newInvaders(size().bw, size().bh))
17
18 if (surface.state === undefined) {
19 surface.setState(base(props))
20 surface.every(100, () => {
21 const s = surface.state
22 if (!s?.game || !s.playing || s.game.over) return
23 const game = tick(s.game)
24 if (game.over) surface.post({ game: 'invaders', score: game.score })
25 surface.setState({ ...s, game })
26 })
27 surface.onKey(({ key }) => {
28 const s = surface.state
29 if (!s) return
30 // the space bar may arrive as the character or by name
31 const k = key === 'space' ? ' ' : key.toLowerCase()
32 if (k === 'p') {
33 if (s.game && !s.game.over) surface.setState({ ...s, banner: false, playing: !s.playing })
34 return
35 }
36 if (k === 'r') {
37 surface.setState({ ...s, banner: false, playing: true, game: newInvaders(size().bw, size().bh) })
38 return
39 }
40 const g = ensure(s)
41 const game = k === 'left' || k === 'a' ? shift(g, -1)
42 : k === 'right' || k === 'd' ? shift(g, 1)
43 : k === ' ' || k === 'up' || k === 'w' ? fire(g)
44 : undefined
45 if (game) surface.setState({ ...s, banner: false, playing: true, game })
46 })
47 // the ship follows the pointer over the board and a click fires (the border takes the first column)
48 surface.onPointer(ev => {
49 const s = surface.state
50 if (!s || (ev.type !== 'move' && ev.type !== 'down')) return
51 if (ev.type === 'move' && (!s.game || s.game.over || !s.playing)) return
52 const g = ensure(s)
53 const moved = { ...g, ship: Math.max(0, Math.min(g.w - 1, Math.floor((ev.x - 1) / 2))) }
54 surface.setState({ ...s, banner: false, playing: true, game: ev.type === 'down' ? fire(moved) : moved })
55 })
56 }
57 claudeDone(props, surface)
58
59 const s = surface.state
60 const g = s?.game
61 const { bw, bh } = g ? { bw: g.w, bh: g.h } : size()
62 const top = g ? Math.min(...g.aliens.map(a => a[1])) : 0
63 const flap = (g?.ticks ?? 0) % 10 < 5
64 const has = (list: Pt[] | undefined, x: number, y: number) => !!list?.some(p => p[0] === x && p[1] === y)
65 const rows = grid(Text, bw, bh, (x, y) => {
66 if (!g) return [' ']
67 if (y === g.h - 1 && x === g.ship) return ['▟▙', 'greenBright']
68 if (has(g.aliens, x, y)) return [flap ? '▚▞' : '▞▚', ROW_COLORS[(y - top) % ROW_COLORS.length]]
69 if (has(g.shots, x, y)) return [' |', 'yellowBright']
70 if (has(g.bombs, x, y)) return [' :', 'red']
71 return [' ']
72 })
73 const text = !g ? 'invaders · click here, then ←→ move · space fires · or the mouse'
74 : g.over ? `invaders · game over · score ${g.score} · wave ${g.wave} · r plays again`
75 : !s?.playing ? `invaders · paused · score ${g.score} · p resumes`
76 : `invaders · score ${g.score} · wave ${g.wave} · lives ${'▲'.repeat(g.lives)} · space fires · p pause`
77 return (
78 <Box flexDirection="column">
79 <Box flexDirection="column" borderStyle="round" borderColor={g?.over ? 'red' : 'magenta'} width={bw * 2 + 2}>{rows}</Box>
80 {statusLine(Text, !!s?.banner, `${text} · best ${props?.best ?? 0}`)}
81 </Box>
82 )
83}
84hooks/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/invaders.ts 80 lines1// Space Invaders: pure game logic, drawn by the Invaders board in ../boards/invaders.tsx.
2// Units are board cells; one tick is one 100 ms frame. The ship sits on the bottom row.
3
4export type Pt = readonly [number, number]
5export type InvadersGame = {
6 w: number; h: number
7 ship: number
8 shots: Pt[]; bombs: Pt[]; aliens: Pt[]
9 dir: 1 | -1
10 ticks: number; score: number; lives: number; wave: number; over: boolean
11}
12
13const COLS = 8
14const ROWS = 3
15const BOMB_CHANCE = 0.12
16const same = (a: Pt, b: Pt) => a[0] === b[0] && a[1] === b[1]
17
18// a formation of COLS × ROWS aliens two cells apart, centred; each wave starts a row lower (at most 3)
19export function formation(w: number, wave: number): Pt[] {
20 const left = Math.max(0, Math.floor((w - COLS * 2) / 2))
21 const top = 1 + Math.min(wave - 1, 3)
22 const out: Pt[] = []
23 for (let r = 0; r < ROWS; r++) for (let c = 0; c < COLS; c++) out.push([left + c * 2, top + r])
24 return out
25}
26
27export const newInvaders = (w: number, h: number): InvadersGame => ({
28 w, h, ship: Math.floor(w / 2), shots: [], bombs: [], aliens: formation(w, 1), dir: 1, ticks: 0, score: 0, lives: 3, wave: 1, over: false,
29})
30
31// ticks between formation steps: fewer aliens and later waves step faster
32export const stepEvery = (g: InvadersGame) => Math.max(1, Math.round(g.aliens.length / 3) - (g.wave - 1))
33
34export const shift = (g: InvadersGame, dx: number): InvadersGame =>
35 g.over ? g : { ...g, ship: Math.max(0, Math.min(g.w - 1, g.ship + dx)) }
36
37// one shot on screen at a time, as in the original
38export const fire = (g: InvadersGame): InvadersGame => (g.over || g.shots.length ? g : { ...g, shots: [[g.ship, g.h - 2]] })
39
40// a shot and an alien in the same cell remove each other, ten points each
41function hits(g: InvadersGame): InvadersGame {
42 const shots = g.shots.filter(s => !g.aliens.some(a => same(a, s)))
43 const aliens = g.aliens.filter(a => !g.shots.some(s => same(a, s)))
44 return { ...g, shots, aliens, score: g.score + (g.aliens.length - aliens.length) * 10 }
45}
46
47export function tick(g: InvadersGame, rand = Math.random): InvadersGame {
48 if (g.over) return g
49 let next: InvadersGame = { ...g, ticks: g.ticks + 1, shots: g.shots.map(([x, y]): Pt => [x, y - 1]).filter(([, y]) => y >= 0) }
50 next = hits(next)
51
52 // the formation steps sideways, or down and back at an edge
53 if (next.aliens.length && next.ticks % stepEvery(next) === 0) {
54 const dir = next.dir
55 const edge = next.aliens.some(([x]) => x + dir < 0 || x + dir >= next.w)
56 next = edge
57 ? { ...next, dir: dir === 1 ? -1 : 1, aliens: next.aliens.map(([x, y]): Pt => [x, y + 1]) }
58 : { ...next, aliens: next.aliens.map(([x, y]): Pt => [x + dir, y]) }
59 next = hits(next)
60 }
61
62 // bombs fall; now and then the lowest alien of a random column drops a new one
63 let bombs = next.bombs.map(([x, y]): Pt => [x, y + 1]).filter(([, y]) => y < next.h)
64 if (next.aliens.length && rand() < BOMB_CHANCE) {
65 const picked = next.aliens[Math.floor(rand() * next.aliens.length)]
66 const lowest = next.aliens.filter(a => a[0] === picked[0]).reduce((m, a) => (a[1] > m[1] ? a : m))
67 bombs = [...bombs, [lowest[0], lowest[1] + 1]]
68 }
69 const hit = bombs.some(([x, y]) => y === next.h - 1 && x === next.ship)
70 const lives = next.lives - (hit ? 1 : 0)
71 const landed = next.aliens.some(([, y]) => y >= next.h - 1)
72 next = { ...next, bombs: hit ? [] : bombs, lives, over: lives <= 0 || landed }
73
74 // a cleared wave brings the next, a row lower and faster
75 if (!next.over && !next.aliens.length) {
76 next = { ...next, wave: next.wave + 1, aliens: formation(next.w, next.wave + 1), shots: [], bombs: [], dir: 1 }
77 }
78 return next
79}
80