Tiny text-mode games in a pane: /doom (raycaster with wandering enemies) and /snake.

Doom-ish raycaster and Snake in a pane, for waiting on long runs.
Tiny text-mode games that run inside Claude Code. /doom is a raycaster with wandering enemies and a minimap; /snake is classic snake.
/plugin marketplace add cloudcruncher/claude-mod-garage
/plugin install arcade@mod-garage
Run /reload-plugins (or restart Claude Code) afterwards. To try it from a local clone without installing: claude --plugin-dir ./dev-mods/arcade
/doom: WASD/arrows move, space shoots, q quits./snake: WASD/arrows steer, r restarts, q quits.Illustrative output.
/snake
┌──────────────┐
│ │
│ ████► ● │
│ │
└──────────────┘ score 3
hooks/register.tsx 53 lines1import type { Register } from 'claude-code'
2
3const GAMES = {
4 doom: { title: 'Doom', module: './doom.tsx', description: 'Play a tiny text-mode raycaster (WASD, space, q)' },
5 snake: { title: 'Snake', module: './snake.tsx', description: 'Play snake in a pane (WASD, q)' },
6} as const
7
8export const register: Register = on => {
9 on('session.start', async ($, e, next) => {
10 for (const [name, g] of Object.entries(GAMES)) {
11 await $.command.register({ name, description: g.description })
12 }
13 return next(e)
14 })
15
16 on('command.run', { command: 'doom' }, async $ => {
17 await $.ui.open({ id: 'doom', title: 'Doom', focus: true, closeOnEscape: true, rows: 26 })
18 return { text: 'Doom opened. WASD/arrows to move, space to shoot, q to quit.' }
19 })
20
21 on('command.run', { command: 'snake' }, async $ => {
22 await $.ui.open({ id: 'snake', title: 'Snake', focus: true, closeOnEscape: true, rows: 16 })
23 return { text: 'Snake opened. WASD/arrows to steer, q to quit.' }
24 })
25
26 on('ui.render', { component: 'Pane', requestId: 'doom' }, async ($, e) => {
27 const { Box, Client } = $.ui.resolve(e)
28 return (
29 <Box flexDirection="column">
30 <Client key="doom" module="./doom.tsx" />
31 </Box>
32 )
33 })
34
35 on('ui.render', { component: 'Pane', requestId: 'snake' }, async ($, e) => {
36 const { Box, Client } = $.ui.resolve(e)
37 return (
38 <Box flexDirection="column">
39 <Client key="snake" module="./snake.tsx" />
40 </Box>
41 )
42 })
43
44 // The game modules post { type: 'close' } on q.
45 on('ui.message', async ($, e, next) => {
46 if ((e.data as { type?: string } | null)?.type === 'close') {
47 await $.ui.close({ id: e.requestId })
48 return {}
49 }
50 return next(e)
51 })
52}
53hooks/doom.tsx 78 lines1import { START, MAP, move, nextSeed, render, shoot, spawnEnemies, turn, wander } from './raycast'
2import type { Enemy, Player } from './raycast'
3
4type St = { p: Player; enemies: Enemy[]; seed: number; kills: number; msg: string; flash: number }
5
6const clamp = (n: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, n))
7
8function fresh(): St {
9 return { p: START, enemies: spawnEnemies(), seed: 12345, kills: 0, msg: 'Find the @ and press space.', flash: 0 }
10}
11
12// Surface module: owns the game loop (50 ms ~ 20 fps) and key handling.
13export default function Doom(_props: unknown, surface: any) {
14 const { Box, Text } = surface.elements
15 let st: St = surface.state ?? fresh()
16
17 if (surface.state === undefined) {
18 surface.setState(st)
19 const update = (fn: (s: St) => St) => surface.setState(fn(surface.state ?? st))
20 surface.every(50, () =>
21 update(s => {
22 let seed = s.seed
23 const rng = () => {
24 seed = nextSeed(seed)
25 return seed / 4294967296
26 }
27 const enemies = wander(MAP, s.enemies, rng)
28 return { ...s, enemies, seed, flash: Math.max(0, s.flash - 1) }
29 }),
30 )
31 surface.onKey((k: { key: string }) =>
32 update(s => {
33 switch (k.key) {
34 case 'w':
35 case 'up':
36 return { ...s, p: move(MAP, s.p, 0.25) }
37 case 's':
38 case 'down':
39 return { ...s, p: move(MAP, s.p, -0.25) }
40 case 'a':
41 return { ...s, p: move(MAP, s.p, 0, -0.25) }
42 case 'd':
43 return { ...s, p: move(MAP, s.p, 0, 0.25) }
44 case 'left':
45 return { ...s, p: turn(s.p, -0.15) }
46 case 'right':
47 return { ...s, p: turn(s.p, 0.15) }
48 case ' ':
49 case 'space': {
50 const i = shoot(MAP, s.p, s.enemies)
51 if (i < 0) return { ...s, msg: 'Miss.', flash: 3 }
52 const enemies = s.enemies.map((e, j) => (j === i ? { ...e, alive: false } : e))
53 const kills = s.kills + 1
54 return { ...s, enemies, kills, msg: enemies.some(e => e.alive) ? 'Hit!' : 'All clear. Press q to leave.', flash: 3 }
55 }
56 case 'q':
57 surface.post({ type: 'close' })
58 return s
59 default:
60 return s
61 }
62 }),
63 )
64 }
65
66 const cols = clamp((surface.columns || 60) - 1, 24, 120)
67 const rows = clamp((surface.rows || 20) - 3, 8, 40)
68 const lines = render(MAP, st.p, cols, rows, st.enemies)
69 return (
70 <Box flexDirection="column">
71 {lines.map((l: string) => (
72 <Text>{l}</Text>
73 ))}
74 <Text dimColor>{`WASD/arrows move, space shoot, q quit | kills ${st.kills}/${st.enemies.length} | ${st.msg}`}</Text>
75 </Box>
76 )
77}
78hooks/snake.tsx 30 lines1import { newGame, renderSnake, step, turnTo } from './snake'
2import type { Dir, Snake } from './snake'
3
4const KEYS: Record<string, Dir> = { w: 'up', up: 'up', s: 'down', down: 'down', a: 'left', left: 'left', d: 'right', right: 'right' }
5
6export default function SnakeGame(_props: unknown, surface: any) {
7 const { Box, Text } = surface.elements
8 const st: Snake = surface.state ?? newGame(24, 12)
9
10 if (surface.state === undefined) {
11 surface.setState(st)
12 const update = (fn: (s: Snake) => Snake) => surface.setState(fn(surface.state ?? st))
13 surface.every(140, () => update(step))
14 surface.onKey((k: { key: string }) => {
15 if (k.key === 'q') surface.post({ type: 'close' })
16 else if (k.key === 'r') update(s => newGame(s.w, s.h, s.seed + 1))
17 else if (KEYS[k.key]) update(s => turnTo(s, KEYS[k.key]))
18 })
19 }
20
21 return (
22 <Box flexDirection="column">
23 {renderSnake(st).map(l => (
24 <Text>{l}</Text>
25 ))}
26 <Text dimColor>{st.alive ? `score ${st.score} | WASD/arrows, q quit` : `Game over, score ${st.score} | r restart, q quit`}</Text>
27 </Box>
28 )
29}
30hooks/raycast.ts 205 lines1// Pure raycasting engine for /doom. No engine APIs here.
2
3export const MAP: string[] = [
4 '################',
5 '#..............#',
6 '#..##....###...#',
7 '#..#.......#...#',
8 '#..#.......#...#',
9 '#.......##.....#',
10 '#...##.........#',
11 '#...#....###...#',
12 '#........#.....#',
13 '#..###...#..##.#',
14 '#..............#',
15 '################',
16]
17
18export type Player = { x: number; y: number; a: number }
19export type Enemy = { x: number; y: number; alive: boolean; dir: number }
20export type Hit = { dist: number; side: 0 | 1 }
21
22export const FOV = Math.PI / 3
23export const MAX_DEPTH = 16
24// Darkest to nearest-first: index grows with distance.
25export const SHADES = ['█', '▓', '▒', '░', '·']
26
27export function isWall(map: string[], x: number, y: number): boolean {
28 const row = map[Math.floor(y)]
29 if (row === undefined) return true
30 const c = row[Math.floor(x)]
31 return c === undefined || c === '#'
32}
33
34/** DDA grid walk: distance along the ray to the first wall and which face was hit. */
35export function castRay(map: string[], px: number, py: number, angle: number): Hit {
36 const dx = Math.cos(angle)
37 const dy = Math.sin(angle)
38 let mx = Math.floor(px)
39 let my = Math.floor(py)
40 const ddx = dx === 0 ? Infinity : Math.abs(1 / dx)
41 const ddy = dy === 0 ? Infinity : Math.abs(1 / dy)
42 const sx = dx < 0 ? -1 : 1
43 const sy = dy < 0 ? -1 : 1
44 let tx = dx === 0 ? Infinity : (dx < 0 ? px - mx : mx + 1 - px) * ddx
45 let ty = dy === 0 ? Infinity : (dy < 0 ? py - my : my + 1 - py) * ddy
46 let side: 0 | 1 = 0
47 let dist = 0
48 for (let i = 0; i < 256; i++) {
49 if (tx < ty) {
50 dist = tx
51 tx += ddx
52 mx += sx
53 side = 0
54 } else {
55 dist = ty
56 ty += ddy
57 my += sy
58 side = 1
59 }
60 if (isWall(map, mx, my)) return { dist, side }
61 if (dist > MAX_DEPTH) break
62 }
63 return { dist: MAX_DEPTH, side }
64}
65
66/** Shade glyph for a wall at dist; side-1 faces read one step darker. */
67export function shade(dist: number, side: 0 | 1 = 0): string {
68 const step = Math.min(SHADES.length - 1, Math.floor((dist / MAX_DEPTH) * (SHADES.length + 1)) + side)
69 return SHADES[Math.max(0, Math.min(SHADES.length - 1, step))]
70}
71
72export function normAngle(a: number): number {
73 let r = a % (2 * Math.PI)
74 if (r > Math.PI) r -= 2 * Math.PI
75 if (r < -Math.PI) r += 2 * Math.PI
76 return r
77}
78
79export function turn(p: Player, delta: number): Player {
80 return { ...p, a: normAngle(p.a + delta) }
81}
82
83/** Move forward/back and strafe, sliding is not attempted: blocked axes stay put. */
84export function move(map: string[], p: Player, forward: number, strafe = 0): Player {
85 const dx = Math.cos(p.a) * forward + Math.cos(p.a + Math.PI / 2) * strafe
86 const dy = Math.sin(p.a) * forward + Math.sin(p.a + Math.PI / 2) * strafe
87 const pad = 0.15
88 const nx = p.x + dx
89 const ny = p.y + dy
90 const okX = !isWall(map, nx + Math.sign(dx) * pad, p.y)
91 const okY = !isWall(map, p.x, ny + Math.sign(dy) * pad)
92 return { ...p, x: okX ? nx : p.x, y: okY ? ny : p.y }
93}
94
95/** Where an enemy appears on screen: column and apparent size, or null if out of view. */
96export function project(p: Player, e: Enemy, w: number): { col: number; dist: number } | null {
97 const dx = e.x - p.x
98 const dy = e.y - p.y
99 const dist = Math.hypot(dx, dy)
100 const rel = normAngle(Math.atan2(dy, dx) - p.a)
101 if (Math.abs(rel) > FOV / 2 || dist < 0.2) return null
102 return { col: Math.round((0.5 + rel / FOV) * (w - 1)), dist }
103}
104
105/** Index of the enemy hit by a shot straight down the view centre, or -1. */
106export function shoot(map: string[], p: Player, enemies: Enemy[]): number {
107 const wall = castRay(map, p.x, p.y, p.a).dist
108 let best = -1
109 let bestDist = Infinity
110 enemies.forEach((e, i) => {
111 if (!e.alive) return
112 const dx = e.x - p.x
113 const dy = e.y - p.y
114 const dist = Math.hypot(dx, dy)
115 const rel = Math.abs(normAngle(Math.atan2(dy, dx) - p.a))
116 const tolerance = Math.atan2(0.3, dist) // enemy is roughly 0.6 wide
117 if (rel <= tolerance && dist < wall && dist < bestDist) {
118 best = i
119 bestDist = dist
120 }
121 })
122 return best
123}
124
125/** One wander step. rng() must return [0,1); injected so this stays pure. */
126export function wander(map: string[], enemies: Enemy[], rng: () => number, speed = 0.08): Enemy[] {
127 return enemies.map(e => {
128 if (!e.alive) return e
129 let dir = e.dir
130 if (rng() < 0.1) dir += (rng() - 0.5) * 2
131 const nx = e.x + Math.cos(dir) * speed
132 const ny = e.y + Math.sin(dir) * speed
133 if (isWall(map, nx, ny)) return { ...e, dir: dir + Math.PI / 2 + rng() }
134 return { ...e, x: nx, y: ny, dir }
135 })
136}
137
138export function minimap(map: string[], p: Player, enemies: Enemy[]): string[] {
139 const arrows = ['>', 'v', '<', '^']
140 const quarter = Math.round(normAngle(p.a) / (Math.PI / 2))
141 const arrow = arrows[((quarter % 4) + 4) % 4]
142 return map.map((row, y) =>
143 [...row]
144 .map((c, x) => {
145 if (Math.floor(p.x) === x && Math.floor(p.y) === y) return arrow
146 if (enemies.some(e => e.alive && Math.floor(e.x) === x && Math.floor(e.y) === y)) return '@'
147 return c === '#' ? '#' : ' '
148 })
149 .join(''),
150 )
151}
152
153/** Render one frame: (map, player) -> rows of exactly w characters. */
154export function render(map: string[], p: Player, w: number, h: number, enemies: Enemy[] = []): string[] {
155 const grid: string[][] = Array.from({ length: h }, () => Array<string>(w).fill(' '))
156 const depth: number[] = Array(w).fill(MAX_DEPTH)
157 for (let col = 0; col < w; col++) {
158 const ray = p.a - FOV / 2 + (col / Math.max(1, w - 1)) * FOV
159 const hit = castRay(map, p.x, p.y, ray)
160 const dist = Math.max(0.1, hit.dist * Math.cos(ray - p.a)) // fisheye fix
161 depth[col] = hit.dist
162 const wallH = Math.min(h, Math.floor(h / dist))
163 const top = Math.floor((h - wallH) / 2)
164 for (let row = 0; row < h; row++) {
165 if (row >= top && row < top + wallH) grid[row][col] = shade(hit.dist, hit.side)
166 else if (row >= h / 2) grid[row][col] = row > h * 0.75 ? ':' : '.'
167 }
168 }
169 for (const e of [...enemies].filter(x => x.alive).sort((a, b) => Math.hypot(b.x - p.x, b.y - p.y) - Math.hypot(a.x - p.x, a.y - p.y))) {
170 const pr = project(p, e, w)
171 if (!pr) continue
172 const size = Math.max(1, Math.min(h, Math.floor((h / pr.dist) * 0.6)))
173 const half = Math.max(0, Math.floor(size / 2))
174 const top = Math.floor((h - size) / 2)
175 for (let col = pr.col - half; col <= pr.col + half; col++) {
176 if (col < 0 || col >= w || depth[col] < pr.dist) continue
177 for (let row = top; row < top + size; row++) if (row >= 0 && row < h) grid[row][col] = '@'
178 }
179 }
180 const mm = minimap(map, p, enemies)
181 if (w >= mm[0].length + 4 && h >= mm.length + 2) {
182 mm.forEach((r, i) => {
183 for (let j = 0; j < r.length; j++) grid[i + 1][w - mm[0].length - 1 + j] = r[j]
184 })
185 }
186 if (h >= 3 && w >= 3) grid[Math.floor(h / 2)][Math.floor(w / 2)] = '+'
187 return grid.map(r => r.join(''))
188}
189
190export function spawnEnemies(): Enemy[] {
191 return [
192 { x: 5.5, y: 4.5, alive: true, dir: 0.5 },
193 { x: 10.5, y: 6.5, alive: true, dir: 2.0 },
194 { x: 13.5, y: 9.5, alive: true, dir: 4.0 },
195 { x: 7.5, y: 10.5, alive: true, dir: 1.0 },
196 ]
197}
198
199export const START: Player = { x: 2.5, y: 1.5, a: 0.6 }
200
201/** Tiny deterministic LCG so the game state stays serialisable. */
202export function nextSeed(seed: number): number {
203 return (Math.imul(seed, 1664525) + 1013904223) >>> 0
204}
205hooks/snake.ts 65 lines1// Pure snake logic for /snake.
2
3export type Pt = { x: number; y: number }
4export type Dir = 'up' | 'down' | 'left' | 'right'
5export type Snake = { body: Pt[]; dir: Dir; food: Pt; w: number; h: number; alive: boolean; score: number; seed: number }
6
7const D: Record<Dir, Pt> = { up: { x: 0, y: -1 }, down: { x: 0, y: 1 }, left: { x: -1, y: 0 }, right: { x: 1, y: 0 } }
8const OPPOSITE: Record<Dir, Dir> = { up: 'down', down: 'up', left: 'right', right: 'left' }
9
10function lcg(seed: number): number {
11 return (Math.imul(seed, 1664525) + 1013904223) >>> 0
12}
13
14export function newGame(w: number, h: number, seed = 1): Snake {
15 const body = [{ x: Math.floor(w / 2), y: Math.floor(h / 2) }]
16 return placeFood({ body, dir: 'right', food: { x: 0, y: 0 }, w, h, alive: true, score: 0, seed })
17}
18
19function placeFood(s: Snake): Snake {
20 let seed = s.seed
21 for (let i = 0; i < 200; i++) {
22 seed = lcg(seed)
23 const x = seed % s.w
24 seed = lcg(seed)
25 const y = seed % s.h
26 if (!s.body.some(p => p.x === x && p.y === y)) return { ...s, food: { x, y }, seed }
27 }
28 return { ...s, seed }
29}
30
31export function turnTo(s: Snake, dir: Dir): Snake {
32 return dir === OPPOSITE[s.dir] && s.body.length > 1 ? s : { ...s, dir }
33}
34
35export function step(s: Snake): Snake {
36 if (!s.alive) return s
37 const head = s.body[0]
38 const nx = head.x + D[s.dir].x
39 const ny = head.y + D[s.dir].y
40 const hitsWall = nx < 0 || ny < 0 || nx >= s.w || ny >= s.h
41 const eats = nx === s.food.x && ny === s.food.y
42 const body = [{ x: nx, y: ny }, ...s.body]
43 if (!eats) body.pop()
44 const hitsSelf = body.slice(1).some(p => p.x === nx && p.y === ny)
45 if (hitsWall || hitsSelf) return { ...s, alive: false }
46 const next = { ...s, body, score: s.score + (eats ? 1 : 0) }
47 return eats ? placeFood(next) : next
48}
49
50export function renderSnake(s: Snake): string[] {
51 const rows: string[] = []
52 for (let y = 0; y < s.h; y++) {
53 let row = ''
54 for (let x = 0; x < s.w; x++) {
55 if (s.body[0].x === x && s.body[0].y === y) row += 'O'
56 else if (s.body.some(p => p.x === x && p.y === y)) row += 'o'
57 else if (s.food.x === x && s.food.y === y) row += '*'
58 else row += '.'
59 }
60 rows.push(`|${row}|`)
61 }
62 const edge = `+${'-'.repeat(s.w)}+`
63 return [edge, ...rows, edge]
64}
65