A tower defense above the Claude Code prompt where every enemy is one of Claude's real tool calls: Bash sends runners, an Edit or Write a slow tank, a web or…

A tower defense above the Claude Code prompt where every enemy is one of Claude's real tool calls: Bash sends runners, an Edit or Write a slow tank, a web or MCP call a flyer, an Agent a boss. Build and upgrade towers by clicking beside the road, hold the base through Claude's turn. Zero tokens: the mod answers every key and click itself and never asks the model anything.
/defense opens the board above the prompt (/defense stop or the close button closes it).p pauses, r restarts.Every tool call Claude makes releases one enemy the moment the call starts, shaped by the tool:
| tool | enemy |
|---|---|
| Bash, PowerShell | ▶▶ runner: fast, fragile |
| Edit, Write, NotebookEdit | ██ tank: slow, tough |
| WebFetch, WebSearch, any MCP tool | ◢◣ flyer: crosses the bottom row over the towers' heads |
| Agent, Workflow | ▓▓ boss |
| anything else (Read, Grep, Glob, ...) | ▪▪ scout |
Between Claude's calls a quiet trickle of scouts keeps the game alive, so it is playable on its own too. A long turn with many tool calls is the real wave: build before you press Enter.
Your best score is kept in the plugin's store across sessions.
npx claude-code-templates@latest --mod games/tool-defense
claude
It is written to .claude/skills/tool-defense/, which Claude Code auto-loads as tool-defense@skills-dir. For one session with hot reload: claude --plugin-dir .claude/skills/tool-defense. claude plugin validate .claude/skills/tool-defense prints every event it hooks and every $ call it makes.
hooks/register.tsx is the hooks module: it registers /defense, 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 108 lines1/* @jsx h */
2import type { Register } from 'claude-code'
3
4// Tool Defense above the Claude Code prompt: a tower defense where every enemy is one of Claude's
5// real tool calls. `/defense` opens it, `/defense stop` closes it. This module watches tool.call,
6// hands the board the calls it has not seen (with a running number, so nothing spawns twice),
7// keeps the best score in $.store and tells the board when a turn starts and ends. Zero tokens.
8
9const TITLE = 'Tool Defense'
10const KEEP = 24
11
12type Call = { seq: number; tool: string }
13
14let open = false
15let turnsDone = 0
16let working = false
17let best = 0
18let seq = 0
19let calls: Call[] = []
20
21export const register: Register = on => {
22 on('session.start', async ($, e, next) => {
23 const r = await next(e)
24 const score = Number(await $.store.get('best').catch(() => undefined))
25 if (score) best = score
26 await $.command.register({
27 name: 'defense',
28 description: 'Tool Defense above the prompt: every tool call Claude makes is an enemy (stop closes)',
29 argumentHint: '[stop]',
30 immediate: true,
31 }).catch(err => $.ui.log(`tool-defense: /defense not registered: ${err}`))
32 return r
33 })
34
35 on('command.run', { command: 'defense' }, 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: `${TITLE} closed` }
41 }
42 open = true
43 $.ui.invalidate('ui.render')
44 return { text: `${TITLE} · click a cell to build a tower, a tower to upgrade it · each of Claude's tool calls sends an enemy · /defense stop closes${best ? ` · best ${best}` : ''}` }
45 })
46
47 // every tool call is an enemy, released as the call starts so it walks while the tool runs
48 on('tool.call', async ($, e, next) => {
49 if (open) {
50 seq++
51 calls = [...calls.slice(-(KEEP - 1)), { seq, tool: e.tool }]
52 $.ui.invalidate('ui.render')
53 }
54 return next(e)
55 })
56
57 on('turn.start', async ($, e, next) => {
58 working = true
59 if (open) $.ui.invalidate('ui.render')
60 return next(e)
61 })
62
63 on('turn.complete', async ($, e, next) => {
64 const r = await next(e)
65 // a subagent's turn is not the one the person is waiting for
66 if (e.agentId) return r
67 working = false
68 if (open) {
69 turnsDone++
70 $.ui.invalidate('ui.render')
71 }
72 return r
73 })
74
75 on('ui.message', async ($, e, next) => {
76 const data = e.data as { game?: unknown; score?: unknown } | null
77 if (data?.game !== 'tool-defense' || typeof data.score !== 'number') return next(e)
78 if (Number.isFinite(data.score) && data.score > best) {
79 best = data.score
80 await $.store.set('best', best).catch(err => $.ui.log(`tool-defense: store write failed: ${err}`))
81 $.ui.toast(`${TITLE}: new best ${best}`)
82 }
83 return { props: { best, done: turnsDone, working, calls } }
84 })
85
86 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
87 if (!open || e.props.hasSurvey || e.surface !== 'terminal') return next(e)
88 const { Box, Button, Client } = $.ui.resolve(e)
89 // the band's own width: the transcript column's while a Pane is docked beside it
90 const cols = e.props.bodyColumns || (e.viewport?.columns ?? 80)
91 // the grid is 10 rows + border, plus two status lines
92 const rows = Math.max(8, Math.min(e.props.maxRows - 2, 15))
93 const close = () => {
94 open = false
95 $.ui.invalidate('ui.render')
96 }
97 return (
98 <Box flexDirection="column">
99 <Box flexDirection="row" columnGap={1}>
100 <Button key="defense:close" label="close" onPress={close} />
101 </Box>
102 <Client key="board:defense" module="./boards/defense.tsx" width={cols} height={rows} props={{ best, done: turnsDone, working, calls }} />
103 {await next(e)}
104 </Box>
105 )
106 })
107}
108hooks/boards/defense.tsx 93 lines1/* @jsx h */
2import type { ClientSurface } from 'claude-code'
3import { base, claudeDone, grid, isSpace, statusLine, type Base, type Props } from './common.tsx'
4import { BASE, build, enemyPos, H, isPath, KINDS, newDefense, spawn, step, TOWER_COST, UPGRADE_COST, W, type DefenseGame, type Pt } from '../games/defense.ts'
5
6// Tool calls arrive as props: the hooks module keeps the last few with a running number, and the
7// board spawns every one it has not seen yet, so a redraw never doubles an enemy.
8type Call = { seq: number; tool: string }
9type DefenseProps = (Props & { calls?: Call[]; working?: boolean }) | undefined
10type State = Base & { game: DefenseGame; seenSeq: number; note?: string; noteTtl: number }
11
12const TICK_MS = 125
13const LEVEL_GLYPH = ['', '▲▲', '◆◆', '★★'] as const
14const LEVEL_COLOR = ['', 'cyan', 'blueBright', 'yellowBright'] as const
15
16export default function Defense(props: DefenseProps, surface: ClientSurface<State>) {
17 const { Box, Text } = surface.elements
18
19 if (surface.state === undefined) {
20 surface.setState({ ...base(props), playing: true, game: newDefense(), seenSeq: props?.calls?.at(-1)?.seq ?? 0, noteTtl: 0 })
21 surface.every(TICK_MS, () => {
22 const s = surface.state
23 if (!s || !s.playing || s.game.over) return
24 const game = step(s.game)
25 if (game.over) surface.post({ game: 'tool-defense', score: game.score })
26 surface.setState({ ...s, game, noteTtl: Math.max(0, s.noteTtl - 1) })
27 })
28 surface.onPointer(ev => {
29 const s = surface.state
30 if (!s || ev.type !== 'down') return
31 // the border takes the first row and column; each cell is two columns wide
32 const cell: Pt = [Math.floor((ev.x - 1) / 2), ev.y - 1]
33 const { game, note } = build(s.game, cell)
34 surface.setState({ ...s, game, note, noteTtl: 20, banner: false })
35 })
36 surface.onKey(({ key }) => {
37 const s = surface.state
38 if (!s) return
39 const k = key.toLowerCase()
40 if (k === 'r') surface.setState({ ...s, game: newDefense(), playing: true, banner: false, note: undefined })
41 else if (isSpace(k) || k === 'p') surface.setState({ ...s, playing: !s.playing, banner: false })
42 })
43 }
44 // Claude finishing a turn is news here, not a pause: the enemies stop coming on their own
45 claudeDone(props, surface, false)
46
47 // spawn the tool calls that arrived since the last draw
48 const s0 = surface.state
49 if (s0) {
50 const fresh = (props?.calls ?? []).filter(c => c.seq > s0.seenSeq)
51 if (fresh.length > 0) {
52 let game = s0.game
53 for (const c of fresh) game = spawn(game, c.tool)
54 if (game.over) surface.post({ game: 'tool-defense', score: game.score })
55 surface.setState({ ...s0, game, seenSeq: fresh[fresh.length - 1]!.seq })
56 }
57 }
58
59 const s = surface.state
60 const g = s?.game ?? newDefense()
61 const towers = new Map<number, 1 | 2 | 3>()
62 for (const t of g.towers) towers.set(t.pos[1] * W + t.pos[0], t.level)
63 const enemies = new Map<number, { glyph: string; color: string }>()
64 for (const e of g.enemies) {
65 const p = enemyPos(e)
66 const k = KINDS[e.kind]
67 enemies.set(p[1] * W + p[0], { glyph: e.hp < e.maxHp / 2 ? k.glyph.slice(0, 1) + ' ' : k.glyph, color: k.color })
68 }
69 const hits = new Set(g.shots.map(sh => sh.to[1] * W + sh.to[0]))
70 const rows = grid(Text, W, H, (x, y) => {
71 const k = y * W + x
72 const enemy = enemies.get(k)
73 if (enemy) return hits.has(k) ? [enemy.glyph, enemy.color, 'white'] : [enemy.glyph, enemy.color]
74 if (x === BASE[0] && y === BASE[1]) return ['▐▌', g.lives > 3 ? 'yellow' : 'red']
75 const level = towers.get(k)
76 if (level) return [LEVEL_GLYPH[level], LEVEL_COLOR[level]]
77 if (isPath([x, y])) return ['░░', 'gray']
78 return [' ']
79 })
80 const note = s && s.noteTtl > 0 && s.note ? ` · ${s.note}` : ''
81 const last = g.enemies[g.enemies.length - 1]
82 const text = g.over ? `tool defense · the base fell · score ${g.score}, ${g.kills} kills · r plays again`
83 : !s?.playing ? `tool defense · paused · space resumes`
84 : `tool defense · gold ${g.gold} · ♥${g.lives} · score ${g.score}${note || (last ? ` · incoming: ${last.tool}` : props?.working ? ' · Claude is working: every tool call is an enemy' : ' · quiet: click a cell to build (25), a tower to upgrade')}`
85 return (
86 <Box flexDirection="column">
87 <Box flexDirection="column" borderStyle="round" borderColor={g.over ? 'red' : g.lives <= 3 ? 'yellow' : 'cyan'} width={W * 2 + 2}>{rows}</Box>
88 {statusLine(Text, !!s?.banner, `${text} · best ${props?.best ?? 0}`)}
89 <Text dimColor wrap="truncate-end">{`▶▶ Bash ██ Edit/Write ◢◣ web & MCP ▓▓ Agent ▪▪ other · tower ${TOWER_COST}, upgrades ${UPGRADE_COST[1]}/${UPGRADE_COST[2]}`}</Text>
90 </Box>
91 )
92}
93hooks/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/defense.ts 173 lines1// Tool Defense's rules, pure and testable. Enemies walk a fixed path across a grid toward your base;
2// you place and upgrade towers on the cells beside it. Every enemy is one of Claude's real tool calls,
3// shaped by the tool: Bash runs fast, an Edit or Write is slow and tough, a web call flies straight
4// over the towers, an Agent is a boss. Between Claude's calls a quiet trickle keeps the game alive.
5
6export type Pt = readonly [x: number, y: number]
7export type Kind = 'runner' | 'tank' | 'flyer' | 'boss' | 'scout'
8export type Enemy = {
9 id: number
10 kind: Kind
11 /** the tool that spawned it, for the status line */
12 tool: string
13 /** index along PATH (walkers) or x position (flyers), in cells; fractional between cells */
14 at: number
15 hp: number
16 maxHp: number
17 speed: number
18 reward: number
19}
20export type Tower = { pos: Pt; level: 1 | 2 | 3; cooldown: number }
21export type Shot = { from: Pt; to: Pt; ttl: number }
22export type DefenseGame = {
23 readonly w: number
24 readonly h: number
25 towers: Tower[]
26 enemies: Enemy[]
27 shots: Shot[]
28 gold: number
29 lives: number
30 score: number
31 kills: number
32 /** id of the next enemy */
33 nextId: number
34 /** ticks since the last spawn of any kind, for the idle trickle */
35 quiet: number
36 over: boolean
37 tick: number
38}
39
40export const W = 26
41export const H = 10
42export const TOWER_COST = 25
43export const UPGRADE_COST = [0, 30, 50, 0] as const
44const RANGE = [0, 2.5, 3.2, 4] as const
45const DAMAGE = [0, 1, 2, 4] as const
46const FIRE_EVERY = [0, 5, 4, 3] as const
47const TRICKLE_EVERY = 45
48
49/** The road, cell by cell: a serpentine from the left edge to the base on the right edge. */
50export const PATH: readonly Pt[] = (() => {
51 const out: Pt[] = []
52 const rows = [1, 4, 7]
53 for (let x = 0; x < W - 1; x++) out.push([x, rows[0]!])
54 for (let y = rows[0]! + 1; y <= rows[1]!; y++) out.push([W - 2, y])
55 for (let x = W - 3; x >= 1; x--) out.push([x, rows[1]!])
56 for (let y = rows[1]! + 1; y <= rows[2]!; y++) out.push([1, y])
57 for (let x = 2; x < W; x++) out.push([x, rows[2]!])
58 return out
59})()
60const PATH_KEYS = new Set(PATH.map(([x, y]) => y * W + x))
61export const BASE: Pt = PATH[PATH.length - 1]!
62export const FLY_ROW = 7
63
64export const KINDS: Record<Kind, { hp: number; speed: number; reward: number; glyph: string; color: string }> = {
65 scout: { hp: 2, speed: 0.35, reward: 3, glyph: '▪▪', color: 'white' },
66 runner: { hp: 3, speed: 0.6, reward: 5, glyph: '▶▶', color: 'greenBright' },
67 tank: { hp: 9, speed: 0.22, reward: 9, glyph: '██', color: 'magenta' },
68 flyer: { hp: 4, speed: 0.5, reward: 7, glyph: '◢◣', color: 'cyan' },
69 boss: { hp: 30, speed: 0.18, reward: 40, glyph: '▓▓', color: 'red' },
70}
71
72/** Which enemy a tool call becomes. */
73export function kindOf(tool: string): Kind {
74 if (tool === 'Bash' || tool === 'PowerShell') return 'runner'
75 if (tool === 'Edit' || tool === 'Write' || tool === 'NotebookEdit') return 'tank'
76 if (tool === 'WebFetch' || tool === 'WebSearch' || tool.startsWith('mcp__')) return 'flyer'
77 if (tool === 'Agent' || tool === 'Workflow') return 'boss'
78 return 'scout'
79}
80
81export function newDefense(): DefenseGame {
82 return { w: W, h: H, towers: [], enemies: [], shots: [], gold: 60, lives: 10, score: 0, kills: 0, nextId: 1, quiet: 0, over: false, tick: 0 }
83}
84
85export const isPath = (p: Pt) => PATH_KEYS.has(p[1] * W + p[0])
86export const towerAt = (g: DefenseGame, p: Pt) => g.towers.find(t => t.pos[0] === p[0] && t.pos[1] === p[1])
87
88/** Where an enemy is drawn. */
89export function enemyPos(e: Enemy): Pt {
90 if (e.kind === 'flyer') return [Math.min(W - 1, Math.floor(e.at)), FLY_ROW]
91 const i = Math.min(PATH.length - 1, Math.floor(e.at))
92 return PATH[i]!
93}
94
95export function spawn(g: DefenseGame, tool: string, kind = kindOf(tool)): DefenseGame {
96 if (g.over) return g
97 const k = KINDS[kind]
98 // later waves are tougher: +10% hp per 10 kills
99 const hp = Math.ceil(k.hp * (1 + g.kills / 100))
100 const enemy: Enemy = { id: g.nextId, kind, tool, at: 0, hp, maxHp: hp, speed: k.speed, reward: k.reward }
101 return { ...g, enemies: [...g.enemies, enemy], nextId: g.nextId + 1, quiet: 0 }
102}
103
104/** Click on a free cell: build (level 1). Click on a tower: upgrade. Returns the game and what happened. */
105export function build(g: DefenseGame, p: Pt): { game: DefenseGame; note?: string } {
106 if (g.over) return { game: g }
107 if (p[0] < 0 || p[1] < 0 || p[0] >= W || p[1] >= H) return { game: g }
108 const existing = towerAt(g, p)
109 if (existing) {
110 if (existing.level >= 3) return { game: g, note: 'that tower is maxed' }
111 const cost = UPGRADE_COST[existing.level]!
112 if (g.gold < cost) return { game: g, note: `upgrade costs ${cost} gold` }
113 const towers = g.towers.map(t => (t === existing ? { ...t, level: (t.level + 1) as 2 | 3 } : t))
114 return { game: { ...g, towers, gold: g.gold - cost }, note: `upgraded to level ${existing.level + 1}` }
115 }
116 if (isPath(p)) return { game: g, note: 'not on the road' }
117 if (g.gold < TOWER_COST) return { game: g, note: `a tower costs ${TOWER_COST} gold` }
118 return { game: { ...g, towers: [...g.towers, { pos: p, level: 1, cooldown: 0 }], gold: g.gold - TOWER_COST }, note: 'tower built' }
119}
120
121const dist = (a: Pt, b: Pt) => Math.hypot(a[0] - b[0], (a[1] - b[1]) * 1.0)
122
123/** One step of the clock (about 8 a second). */
124export function step(g: DefenseGame, rnd: () => number = Math.random): DefenseGame {
125 if (g.over) return g
126 let out: DefenseGame = { ...g, tick: g.tick + 1, quiet: g.quiet + 1 }
127
128 // the idle trickle: a scout now and then, so there is always something to shoot
129 if (out.quiet >= TRICKLE_EVERY && rnd() < 0.5) out = spawn(out, 'idle', 'scout')
130
131 // enemies advance; one that reaches the base costs a life
132 let lives = out.lives
133 const enemies: Enemy[] = []
134 for (const e of out.enemies) {
135 const at = e.at + e.speed
136 const end = e.kind === 'flyer' ? W - 1 : PATH.length - 1
137 if (at >= end) lives--
138 else enemies.push({ ...e, at })
139 }
140 out = { ...out, enemies, lives }
141 if (lives <= 0) return { ...out, lives: 0, over: true }
142
143 // towers fire at the nearest enemy in range
144 const shots: Shot[] = out.shots.map(s => ({ ...s, ttl: s.ttl - 1 })).filter(s => s.ttl > 0)
145 let gold = out.gold
146 let score = out.score
147 let kills = out.kills
148 let alive = out.enemies
149 const towers = out.towers.map(t => {
150 if (t.cooldown > 0) return { ...t, cooldown: t.cooldown - 1 }
151 const range = RANGE[t.level]!
152 let target: Enemy | undefined
153 let bestD = Infinity
154 for (const e of alive) {
155 const d = dist(t.pos, enemyPos(e))
156 if (d <= range && d < bestD) { bestD = d; target = e }
157 }
158 if (!target) return t
159 const hp = target.hp - DAMAGE[t.level]!
160 shots.push({ from: t.pos, to: enemyPos(target), ttl: 2 })
161 if (hp <= 0) {
162 alive = alive.filter(e => e.id !== target!.id)
163 gold += target.reward
164 score += target.reward * 10
165 kills++
166 } else {
167 alive = alive.map(e => (e.id === target!.id ? { ...e, hp } : e))
168 }
169 return { ...t, cooldown: FIRE_EVERY[t.level]! }
170 })
171 return { ...out, towers, enemies: alive, shots, gold, score, kills }
172}
173