SLOPSHOPPER

arcade

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

newpanecommand
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · arcade
│ ┃ Doom ✕ › fix the failing auth test and add an audit log call │ ┃ ▣ client module ./doom.tsx │ ⏺ 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 │ │ › /doom │ ⎿ arcade: Doom opened. WASD/arrows to move, space to shoot, q to q │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Doom
▣ client module ./doom.tsx
Pane · Snake
▣ client module ./snake.tsx
README

arcade

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.

Install

/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

Use it

  • /doom: WASD/arrows move, space shoots, q quits.
  • /snake: WASD/arrows steer, r restarts, q quits.

What you'll see

Illustrative output.

/snake

┌──────────────┐
│              │
│   ████►   ●  │
│              │
└──────────────┘  score 3

Notes

  • Doom needs a pane of at least 40x16; 80x30 or larger looks best.
  • Keys only reach a game while its pane has focus. Esc closes it.
  • Key presses move in steps (terminals give no key-up), so tap rather than hold.
Source 5 files
hooks/register.tsx 53 lines
1import 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}
53
hooks/doom.tsx 78 lines
1import { 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}
78
hooks/snake.tsx 30 lines
1import { 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}
30
hooks/raycast.ts 205 lines
1// 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}
205
hooks/snake.ts 65 lines
1// 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