SLOPSHOPPER

tool-defense

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…

newbandguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tool-defense
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /defense ⎿ tool-defense: Tool Defense · click a cell to build a tower, a tower to upgrade it · each of Claude's tool calls sends an ene [ close ] ▣ client module ./boards/defense.tsx ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
[ close ] ▣ client module ./boards/defense.tsx ⟨Claude Code's own drawing⟩
README

tool-defense

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.

Play

  1. /defense opens the board above the prompt (/defense stop or the close button closes it).
  2. Click the board to give it the mouse and keyboard; Esc gives them back to the prompt.
  3. Click a free cell to build a tower (25 gold), click a tower to upgrade it (30, then 50): more range, more damage, faster fire. Space or p pauses, r restarts.
  4. Enemies walk the road from the left to your base on the right; ten get through and the base falls. Each kill pays gold and points.

Every tool call Claude makes releases one enemy the moment the call starts, shaped by the tool:

toolenemy
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.

Install

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.

How it is built

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.

Requirements

  • Claude Code 2.1.287 or later (mods are on by default from that version).
  • An interactive terminal that reports the mouse. Nothing draws in claude -p, the desktop app or mobile.
  • A terminal font with box-drawing and block characters.

A clone of the genre, not affiliated with or endorsed by the original publisher.

Source 4 files
hooks/register.tsx 108 lines
1/* @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}
108
hooks/boards/defense.tsx 93 lines
1/* @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}
93
hooks/boards/common.tsx 53 lines
1/* @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'
53
hooks/games/defense.ts 173 lines
1// 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