Space Invaders above the Claude Code prompt where every wave is the code Claude just wrote: each line an Edit or Write added becomes a row of the formation and…

Space Invaders above the Claude Code prompt where every wave is the code Claude just wrote: each line an Edit or Write added becomes a row of the formation and every two characters one alien, so you shoot your own diff. Ship, one shot at a time, bombs, three lives, best score. Zero tokens: the mod answers every key and click itself and never asks the model anything.
/diff-invaders opens the board above the prompt (/diff-invaders stop or the close button closes it).a d move the ship, Space, ↑ or a click fires (one shot in the air at a time), p pauses, r shoots a demo wave.The waves come from Claude: every Edit or Write that goes through is read after it runs, its added lines (up to six) become the rows of the next formation, and every two characters of a line become one alien, spelled with those characters. Waves queue up while you clear the current one, so a long refactor is a long fight. r plays a built-in wave while Claude has not written anything yet.
Your best score is kept in the plugin's store across sessions.
npx claude-code-templates@latest --mod games/diff-invaders
claude
It is written to .claude/skills/diff-invaders/, which Claude Code auto-loads as diff-invaders@skills-dir. For one session with hot reload: claude --plugin-dir .claude/skills/diff-invaders. claude plugin validate .claude/skills/diff-invaders prints every event it hooks and every $ call it makes.
hooks/register.tsx is the hooks module: it registers /diff-invaders, mounts the board on the AbovePrompt band and keeps the score in $.store. hooks/games/*.ts is the game's logic, pure functions with no drawing, and hooks/boards/*.tsx is the surface module that draws it in two-character cells on the drawing thread and takes the keys and the mouse. Written for aitmpl.com against Anthropic's published function-hooks declarations.
claude -p, the desktop app or mobile.A clone of the genre, not affiliated with or endorsed by the original publisher.
hooks/register.tsx 112 lines1/* @jsx h */
2import type { Register } from 'claude-code'
3
4// Diff Invaders above the Claude Code prompt: Space Invaders where every wave is the code Claude
5// just wrote. `/diff-invaders` opens it, `/diff-invaders stop` closes it. This module watches Edit and Write,
6// turns the added lines into a wave the board queues (with a running number, so nothing repeats),
7// keeps the best score in $.store and tells the board when a turn ends. Zero tokens.
8
9const TITLE = 'Diff Invaders'
10const KEEP = 12
11const MAX_LINES = 6
12
13type Diff = { seq: number; file: string; lines: string[] }
14
15let open = false
16let turnsDone = 0
17let best = 0
18let seq = 0
19let diffs: Diff[] = []
20
21const baseName = (path: string) => path.split(/[\\/]/).pop() ?? path
22
23/** The lines an edit adds: what is in the new text and not in the old, in order. */
24function added(oldText: string, newText: string): string[] {
25 const before = new Set(oldText.split('\n').map(l => l.trim()))
26 return newText.split('\n').filter(l => l.trim() !== '' && !before.has(l.trim()))
27}
28
29export const register: Register = on => {
30 on('session.start', async ($, e, next) => {
31 const r = await next(e)
32 const score = Number(await $.store.get('best').catch(() => undefined))
33 if (score) best = score
34 await $.command.register({
35 name: 'diff-invaders',
36 description: 'Diff Invaders above the prompt: shoot the code Claude just wrote (stop closes)',
37 argumentHint: '[stop]',
38 immediate: true,
39 }).catch(err => $.ui.log(`diff-invaders: /diff-invaders not registered: ${err}`))
40 return r
41 })
42
43 on('command.run', { command: 'diff-invaders' }, async ($, e) => {
44 const arg = e.args.trim().toLowerCase()
45 if (arg === 'stop' || arg === 'close') {
46 open = false
47 $.ui.invalidate('ui.render')
48 return { text: `${TITLE} closed` }
49 }
50 open = true
51 $.ui.invalidate('ui.render')
52 return { text: `${TITLE} · click the board, ← → move, space fires · every Edit or Write Claude makes is a wave · /diff-invaders stop closes${best ? ` · best ${best}` : ''}` }
53 })
54
55 // an edit that went through becomes a wave: its added lines, one row each
56 on('tool.call', { tool: ['Edit', 'Write'] }, async ($, e, next) => {
57 const r = await next(e)
58 if (!open || r.deny !== undefined || r.isError) return r
59 const lines = e.tool === 'Edit' ? added(e.old_string, e.new_string) : e.content.split('\n').filter(l => l.trim() !== '')
60 if (lines.length > 0) {
61 seq++
62 diffs = [...diffs.slice(-(KEEP - 1)), { seq, file: baseName(e.file_path), lines: lines.slice(0, MAX_LINES) }]
63 $.ui.invalidate('ui.render')
64 }
65 return r
66 })
67
68 on('turn.complete', async ($, e, next) => {
69 const r = await next(e)
70 // a subagent's turn is not the one the person is waiting for
71 if (e.agentId) return r
72 if (open) {
73 turnsDone++
74 $.ui.invalidate('ui.render')
75 }
76 return r
77 })
78
79 on('ui.message', async ($, e, next) => {
80 const data = e.data as { game?: unknown; score?: unknown } | null
81 if (data?.game !== 'diff-invaders' || typeof data.score !== 'number') return next(e)
82 if (Number.isFinite(data.score) && data.score > best) {
83 best = data.score
84 await $.store.set('best', best).catch(err => $.ui.log(`diff-invaders: store write failed: ${err}`))
85 $.ui.toast(`${TITLE}: new best ${best}`)
86 }
87 return { props: { best, done: turnsDone, diffs } }
88 })
89
90 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
91 if (!open || e.props.hasSurvey || e.surface !== 'terminal') return next(e)
92 const { Box, Button, Client } = $.ui.resolve(e)
93 // the band's own width: the transcript column's while a Pane is docked beside it
94 const cols = e.props.bodyColumns || (e.viewport?.columns ?? 80)
95 // the board is 14 rows + border, plus the status line
96 const rows = Math.max(8, Math.min(e.props.maxRows - 2, 18))
97 const close = () => {
98 open = false
99 $.ui.invalidate('ui.render')
100 }
101 return (
102 <Box flexDirection="column">
103 <Box flexDirection="row" columnGap={1}>
104 <Button key="invaders:close" label="close" onPress={close} />
105 </Box>
106 <Client key="board:invaders" module="./boards/invaders.tsx" width={cols} height={rows} props={{ best, done: turnsDone, diffs }} />
107 {await next(e)}
108 </Box>
109 )
110 })
111}
112hooks/boards/invaders.tsx 85 lines1/* @jsx h */
2import type { ClientSurface } from 'claude-code'
3import { base, claudeDone, grid, isSpace, statusLine, type Base, type Props } from './common.tsx'
4import { fire, H, move, newInvaders, step, W, wave, type InvadersGame } from '../games/invaders.ts'
5
6// Waves arrive as props: the hooks module keeps the last few diffs with a running number, and the
7// board starts the next one it has not seen once the current wave is cleared.
8type Diff = { seq: number; file: string; lines: string[] }
9type InvadersProps = (Props & { diffs?: Diff[] }) | undefined
10type State = Base & { game: InvadersGame; seenSeq: number; queue: Diff[] }
11
12const TICK_MS = 110
13// a wave to shoot while Claude has not written anything yet
14const DEMO: Diff = { seq: 0, file: 'demo.ts', lines: ['export function invaders() {', ' const wave = diff.added', ' return shoot(wave)', '}'] }
15
16export default function Invaders(props: InvadersProps, surface: ClientSurface<State>) {
17 const { Box, Text } = surface.elements
18
19 if (surface.state === undefined) {
20 surface.setState({ ...base(props), playing: true, game: newInvaders(), seenSeq: props?.diffs?.at(-1)?.seq ?? 0, queue: [] })
21 surface.every(TICK_MS, () => {
22 const s = surface.state
23 if (!s || !s.playing || s.game.over) return
24 let game = step(s.game)
25 let queue = s.queue
26 if (game.cleared && queue.length > 0) {
27 const [next, ...rest] = queue
28 game = wave(game, next!.lines, next!.file)
29 queue = rest
30 }
31 if (game.over) surface.post({ game: 'diff-invaders', score: game.score })
32 surface.setState({ ...s, game, queue })
33 })
34 surface.onPointer(ev => {
35 const s = surface.state
36 if (!s || ev.type !== 'down') return
37 surface.setState({ ...s, game: fire(s.game), banner: false })
38 })
39 surface.onKey(({ key }) => {
40 const s = surface.state
41 if (!s) return
42 const k = key.toLowerCase()
43 if (k === 'r') surface.setState({ ...s, game: wave(newInvaders(), DEMO.lines, DEMO.file), queue: [], playing: true, banner: false })
44 else if (k === 'left' || k === 'a') surface.setState({ ...s, game: move(s.game, -1), banner: false })
45 else if (k === 'right' || k === 'd') surface.setState({ ...s, game: move(s.game, 1), banner: false })
46 else if (isSpace(k) || k === 'up' || k === 'w') surface.setState({ ...s, game: fire(s.game), banner: false })
47 else if (k === 'p') surface.setState({ ...s, playing: !s.playing, banner: false })
48 })
49 }
50 claudeDone(props, surface, false)
51
52 // queue the diffs that arrived since the last draw
53 const s0 = surface.state
54 if (s0) {
55 const fresh = (props?.diffs ?? []).filter(d => d.seq > s0.seenSeq)
56 if (fresh.length > 0) surface.setState({ ...s0, queue: [...s0.queue, ...fresh], seenSeq: fresh[fresh.length - 1]!.seq })
57 }
58
59 const s = surface.state
60 const g = s?.game ?? newInvaders()
61 const aliens = new Map<number, string>()
62 for (const a of g.aliens) if (a.alive) aliens.set(a.pos[1] * W + a.pos[0], a.glyph)
63 const bombs = new Set(g.bombs.map(([x, y]) => y * W + x))
64 const rows = grid(Text, W, H, (x, y) => {
65 const k = y * W + x
66 const glyph = aliens.get(k)
67 if (glyph) return [glyph, y % 2 ? 'greenBright' : 'green']
68 if (g.shot && g.shot[0] === x && g.shot[1] === y) return ['│ ', 'yellowBright']
69 if (bombs.has(k)) return ['▾ ', 'red']
70 if (y === H - 1 && x === g.ship) return [g.over ? '××' : '▟▙', 'cyan']
71 return [' ']
72 })
73 const pending = s?.queue.length ?? 0
74 const text = g.over ? `diff invaders · game over · score ${g.score} · r plays a demo wave`
75 : !s?.playing ? 'diff invaders · paused · p resumes'
76 : g.cleared ? (pending > 0 ? 'diff invaders · next wave loading' : `diff invaders · score ${g.score} · waiting for Claude to write code · r shoots a demo wave`)
77 : `diff invaders · ${g.file} · ← → move, space fires · ♥${g.lives} · score ${g.score}${pending ? ` · +${pending} waves queued` : ''}`
78 return (
79 <Box flexDirection="column">
80 <Box flexDirection="column" borderStyle="round" borderColor={g.over ? 'red' : g.cleared ? 'gray' : 'green'} width={W * 2 + 2}>{rows}</Box>
81 {statusLine(Text, !!s?.banner, `${text} · best ${props?.best ?? 0}`)}
82 </Box>
83 )
84}
85hooks/boards/common.tsx 53 lines1/* @jsx h */
2import type { ClientElements, ClientSurface } from 'claude-code'
3
4// Shared by the aitmpl game boards. A board is a surface module: it runs on the drawing thread
5// with its own frame clock (surface.every), keys once a click gives it focus (Esc gives the
6// prompt back) and the mouse. Never name a local `h` here: every JSX tag compiles to a call of `h`.
7
8/** What the hooks module hands every board: the best score, and a counter bumped per finished turn. */
9export type Props = { best?: number; done?: number } | undefined
10export type Base = { playing: boolean; banner: boolean; seenDone: number }
11/** A cell as drawn: two characters, a colour, an optional background. */
12export type Paint = readonly [glyph: string, color?: string, bg?: string]
13type TextTag = ClientElements['Text']
14
15export const base = (props: Props): Base => ({ playing: false, banner: false, seenDone: props?.done ?? 0 })
16
17/** Claude finished a turn (props.done moved): show the banner and pause, once per turn. */
18export function claudeDone<S extends Base>(props: Props, surface: ClientSurface<S>, pause = true) {
19 const s = surface.state
20 const done = props?.done ?? 0
21 if (s && done !== s.seenDone) surface.setState({ ...s, seenDone: done, banner: true, playing: pause ? false : s.playing })
22}
23
24export function statusLine(Text: TextTag, banner: boolean, text: string) {
25 return banner
26 ? <Text color="yellow" bold wrap="truncate-end">{`● Claude is done · ${text}`}</Text>
27 : <Text dimColor wrap="truncate-end">{text}</Text>
28}
29
30type Run = [text: string, color?: string, bg?: string]
31
32/** One row of cells as runs of equal colour, so a row costs a handful of nodes rather than one per cell. */
33function runs(cols: number, y: number, at: (x: number, y: number) => Paint): Run[] {
34 const out: Run[] = []
35 for (let x = 0; x < cols; x++) {
36 const [glyph, color, bg] = at(x, y)
37 const last = out[out.length - 1]
38 if (last && last[1] === color && last[2] === bg) last[0] += glyph
39 else out.push([glyph, color, bg])
40 }
41 return out
42}
43
44/** A board of two-character cells (a terminal cell is about twice as tall as wide, so two make a square). */
45export function grid(Text: TextTag, cols: number, rowCount: number, at: (x: number, y: number) => Paint) {
46 return Array.from({ length: Math.max(0, rowCount) }, (_, y) => (
47 <Text>{runs(cols, y, at).map(([t, c, b]) => (b ? <Text color={c} backgroundColor={b}>{t}</Text> : <Text color={c}>{t}</Text>))}</Text>
48 ))
49}
50
51/** The space bar arrives as the character or by name, depending on the terminal. */
52export const isSpace = (key: string) => key === ' ' || key === 'space'
53hooks/games/invaders.ts 129 lines1// Diff Invaders' rules, pure and testable. A wave is the code Claude just added: every added line
2// becomes a row of the formation and every two characters of it one alien, so you shoot your own
3// diff. The formation steps sideways, drops a row at the edge and rains bombs; you have a ship,
4// one shot in the air at a time, and three lives. A cleared wave waits for the next Edit.
5
6export type Pt = readonly [x: number, y: number]
7export type Alien = { pos: Pt; glyph: string; alive: boolean }
8export type InvadersGame = {
9 readonly w: number
10 readonly h: number
11 aliens: Alien[]
12 /** what this wave was made of, for the status line */
13 file: string
14 dir: 1 | -1
15 /** ticks between formation steps: fewer aliens, faster */
16 pace: number
17 clock: number
18 ship: number
19 shot?: Pt
20 bombs: Pt[]
21 lives: number
22 score: number
23 waves: number
24 over: boolean
25 cleared: boolean
26 tick: number
27}
28
29export const W = 30
30export const H = 14
31const MAX_ROWS = 6
32const MAX_COLS = 12
33
34export function newInvaders(score = 0, lives = 3, waves = 0): InvadersGame {
35 return { w: W, h: H, aliens: [], file: '', dir: 1, pace: 6, clock: 0, ship: Math.floor(W / 2), bombs: [], lives, score, waves, over: false, cleared: true, tick: 0 }
36}
37
38/** Two characters of code per alien; spaces at the edges are trimmed, a run of spaces inside stays a gap. */
39export function formation(lines: readonly string[]): Alien[] {
40 const rows = lines.map(l => l.replace(/\t/g, ' ').trim()).filter(l => l.length > 0).slice(0, MAX_ROWS)
41 const aliens: Alien[] = []
42 rows.forEach((line, y) => {
43 for (let i = 0, x = 0; i < line.length && x < MAX_COLS; i += 2, x++) {
44 const glyph = line.slice(i, i + 2).padEnd(2)
45 if (glyph.trim() === '') continue
46 aliens.push({ pos: [x + 1, y + 1], glyph, alive: true })
47 }
48 })
49 return aliens
50}
51
52/** Start a wave from added lines. Returns the game unchanged when the lines hold no code. */
53export function wave(g: InvadersGame, lines: readonly string[], file: string): InvadersGame {
54 const aliens = formation(lines)
55 if (aliens.length === 0) return g
56 return { ...g, aliens, file, dir: 1, pace: Math.max(2, 8 - Math.floor(g.waves / 2)), clock: 0, shot: undefined, bombs: [], cleared: false, waves: g.waves + 1 }
57}
58
59export function move(g: InvadersGame, dx: -1 | 1): InvadersGame {
60 if (g.over) return g
61 return { ...g, ship: Math.max(0, Math.min(W - 1, g.ship + dx)) }
62}
63
64export function fire(g: InvadersGame): InvadersGame {
65 if (g.over || g.shot) return g
66 return { ...g, shot: [g.ship, H - 2] }
67}
68
69export function step(g: InvadersGame, rnd: () => number = Math.random): InvadersGame {
70 if (g.over) return g
71 let out: InvadersGame = { ...g, tick: g.tick + 1 }
72
73 // the shot climbs two rows a tick and takes the first alien it meets
74 if (out.shot) {
75 const [sx, sy] = out.shot
76 let shot: Pt | undefined = [sx, sy - 2]
77 let aliens = out.aliens
78 let score = out.score
79 for (const y of [sy - 1, sy - 2]) {
80 const i = aliens.findIndex(a => a.alive && a.pos[0] === sx && a.pos[1] === y)
81 if (i !== -1) {
82 aliens = aliens.map((a, j) => (j === i ? { ...a, alive: false } : a))
83 score += 10
84 shot = undefined
85 break
86 }
87 }
88 if (shot && shot[1] < 0) shot = undefined
89 out = { ...out, shot, aliens, score }
90 }
91
92 const alive = out.aliens.filter(a => a.alive)
93 if (!out.cleared && alive.length === 0) {
94 return { ...out, cleared: true, bombs: [], score: out.score + 50 }
95 }
96
97 // the formation steps on its own clock; faster as it thins out
98 const pace = Math.max(1, out.pace - Math.floor((out.aliens.length - alive.length) / 8))
99 if (alive.length > 0 && out.tick % pace === 0) {
100 const xs = alive.map(a => a.pos[0])
101 const atEdge = out.dir === 1 ? Math.max(...xs) >= W - 1 : Math.min(...xs) <= 0
102 const aliens = out.aliens.map(a => (a.alive ? { ...a, pos: atEdge ? [a.pos[0], a.pos[1] + 1] as Pt : [a.pos[0] + out.dir, a.pos[1]] as Pt } : a))
103 out = { ...out, aliens, dir: atEdge ? (out.dir === 1 ? -1 : 1) : out.dir }
104 // a landed formation ends the game
105 if (aliens.some(a => a.alive && a.pos[1] >= H - 2)) return { ...out, over: true }
106 // the lowest alien of a random column drops a bomb, from where the formation now stands
107 const moved = aliens.filter(a => a.alive)
108 if (rnd() < 0.35 && moved.length > 0) {
109 const col = moved[Math.floor(rnd() * moved.length)]!.pos[0]
110 const lowest = moved.filter(a => a.pos[0] === col).sort((a, b) => b.pos[1] - a.pos[1])[0]!
111 out = { ...out, bombs: [...out.bombs, [lowest.pos[0], lowest.pos[1] + 1]] }
112 }
113 }
114
115 // bombs fall one row a tick; one on the ship costs a life
116 if (out.tick % 2 === 0) {
117 const bombs: Pt[] = []
118 let lives = out.lives
119 for (const [bx, by] of out.bombs) {
120 const ny = by + 1
121 if (ny >= H - 1 && bx === out.ship) lives--
122 else if (ny < H - 1) bombs.push([bx, ny])
123 }
124 out = { ...out, bombs, lives }
125 if (lives <= 0) return { ...out, lives: 0, over: true }
126 }
127 return out
128}
129