SLOPSHOPPER

doom

Doom above the Claude Code prompt: a corridor shooter: imps, a shotgun, and the floor below. Plays while Claude works, pauses when the turn ends, keeps your…

newbandcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · doom
› 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 › /doom ⎿ doom: $Doom · click the board to play · Esc returns to the prompt · /doom stop closes [ close ] ▣ client module ./boards/doom.tsx ⟨Claude Code's own drawing⟩ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

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

doom

Doom above the Claude Code prompt, for the minutes Claude spends working: a corridor shooter: imps, a shotgun, and the floor below. The game pauses and its status line says so when Claude finishes the turn, so you never miss a reply. Playing costs no tokens: the mod answers every key and click itself.

A single game split out of sezaakgun/cc-arcade (MIT, by Seza Akgün). Want all nine games, a picker and the pet in one plugin? Install games/cc-arcade instead.

Play

  1. /doom opens the board above the prompt (/doom stop or the close button closes it).
  2. Click the board to give it the keyboard; Esc gives the keyboard back to the prompt.
  3. W S or ↑ ↓ walk, A D strafe, ← → or q e turn, mouse over the view aims and a click fires, Space or Enter fires, 1/2/3 select a weapon. /doom colorblind toggles a palette that does not lean on red against green.

Your best score is kept in the plugin's store across sessions.

Install

npx claude-code-templates@latest --mod games/doom
claude

It is written to .claude/skills/doom/, which Claude Code auto-loads as doom@skills-dir. For one session with hot reload: claude --plugin-dir .claude/skills/doom.

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.

Clones of the genre, not affiliated with or endorsed by the original publishers.

Source 5 files
hooks/register.tsx 104 lines
1/* @jsx h */
2import type { Register } from 'claude-code'
3import { isBetter } from './games/best.ts'
4
5// Doom above the Claude Code prompt, split out of cc-arcade (https://github.com/sezaakgun/cc-arcade,
6// MIT, by Seza Akgün) as a single-game mod. `/doom` opens it, `/doom stop` closes it. The board in
7// ./boards is a surface module that runs on the drawing thread; this module mounts it, keeps the best
8// score in $.store and pauses the game when Claude finishes a turn. Playing costs no tokens.
9
10const GAME = 'doom' // what the board posts its score under
11const TITLE = 'Doom'
12const LOWER_IS_BETTER = false
13const UNIT = ''
14
15let open = false
16// bumped at each finished turn; the open game pauses when it sees a new value
17let turnsDone = 0
18let best = 0
19// a palette and pattern that do not lean on red against green
20let safe = false
21
22export const register: Register = on => {
23  on('session.start', async ($, e, next) => {
24    const r = await next(e)
25    // a store that cannot be read costs the best score, never the game
26    const score = Number(await $.store.get('best').catch(() => undefined))
27    if (score) best = score
28    safe = (await $.store.get('colorblind').catch(() => undefined)) === true
29    await $.command.register({
30      name: 'doom',
31      description: `$Doom above the prompt, while Claude works (stop closes)`,
32      argumentHint: '[stop | colorblind]',
33      immediate: true,
34    }).catch(err => $.ui.log(`doom: /doom not registered: ${err}`))
35    return r
36  })
37
38  on('command.run', { command: 'doom' }, async ($, e) => {
39    const arg = e.args.trim().toLowerCase()
40    if (arg === 'stop' || arg === 'close') {
41      open = false
42      $.ui.invalidate('ui.render')
43      return { text: `$Doom closed` }
44    }
45    if (arg === 'colorblind' || arg === 'cb') {
46      safe = !safe
47      await $.store.set('colorblind', safe).catch(err => $.ui.log(`doom: store write failed: ${err}`))
48      $.ui.invalidate('ui.render')
49      return { text: `colour-blind mode ${safe ? 'on' : 'off'}` }
50    }
51    open = true
52    $.ui.invalidate('ui.render')
53    const score = best ? ` · best ${best}$` : ''
54    return { text: `$Doom · click the board to play · Esc returns to the prompt · /doom stop closes${score}` }
55  })
56
57  on('turn.complete', async ($, e, next) => {
58    const r = await next(e)
59    // a subagent's turn is not the one the person is waiting for
60    if (e.agentId) return r
61    if (open) {
62      turnsDone++
63      $.ui.invalidate('ui.render')
64    }
65    return r
66  })
67
68  // a finished round, posted by the board: keep the best score and hand it back with the next props
69  on('ui.message', async ($, e, next) => {
70    const data = e.data as { game?: unknown; score?: unknown } | null
71    if (data?.game !== GAME || typeof data.score !== 'number') return next(e)
72    if (isBetter(data.score, best || undefined, LOWER_IS_BETTER)) {
73      best = data.score
74      await $.store.set('best', best).catch(err => $.ui.log(`doom: store write failed: ${err}`))
75      $.ui.toast(`$Doom: new best ${best}$`)
76    }
77    return { props: { best, done: turnsDone, safe } }
78  })
79
80  on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
81    // the board needs a terminal's keys and mouse; the desktop and mobile surfaces draw their own band
82    if (!open || e.props.hasSurvey || e.surface !== 'terminal') return next(e)
83    const { Box, Button, Client } = $.ui.resolve(e)
84    // the band's own width: the transcript column's while a Pane is docked beside it
85    const cols = e.props.bodyColumns || (e.viewport?.columns ?? 80)
86    // one row for the close button, one for whatever else draws in the band
87    const rows = Math.max(8, e.props.maxRows - 2)
88    const tall = (max: number) => Math.min(rows, max)
89    const close = () => {
90      open = false
91      $.ui.invalidate('ui.render')
92    }
93    return (
94      <Box flexDirection="column">
95        <Box flexDirection="row" columnGap={1}>
96          <Button key="doom:close" label="close" onPress={close} />
97        </Box>
98        <Client key="board:doom" module="./boards/doom.tsx" width={cols} height={rows} props={{ best, done: turnsDone, safe }} />
99        {await next(e)}
100      </Box>
101    )
102  })
103}
104
hooks/games/best.ts 10 lines
1// Which score counts as a new best. Minesweeper's best is its fastest clear, so lower wins there;
2// everywhere else higher wins. A score of zero or less never counts: a Minesweeper board whose
3// first click clears it takes 0 s and would otherwise lock the best forever, and a Pong match you
4// lost posts a negative margin.
5export function isBetter(score: number, prev: number | undefined, lowerIsBetter = false): boolean {
6  if (!Number.isFinite(score) || score <= 0) return false
7  if (prev === undefined || prev <= 0) return true
8  return lowerIsBetter ? score < prev : score > prev
9}
10
hooks/boards/doom.tsx 326 lines
1/* @jsx h */
2import type { ClientSurface } from 'claude-code'
3import { base, claudeDone, grid, statusLine, type Base, type Paint, type Props } from './common.tsx'
4import { cast, fire, move, newDoom, select, STEP, SWING, tick, turn, wrap, WEAPON, type DoomGame, type ItemKind, type Kind, type Weapon } from '../games/doom.ts'
5
6// The view is a pixel buffer twice as tall as the band: each cell is drawn as an upper half block,
7// so its foreground is the top pixel and its background the bottom one. Columns are cast one ray
8// each; monsters, pickups and fireballs are billboards tested against the wall depth of their
9// column. Every sprite is an 8 × 8 mask, sampled at whatever size the distance calls for.
10
11type Rgb = readonly [number, number, number]
12type State = Base & { game?: DoomGame }
13type Sprite = { art: string[]; rgb: Rgb; scale: number; float?: boolean }
14
15const FOV = (70 * Math.PI) / 180
16export const WALL: Record<string, Rgb> = {
17  '1': [165, 95, 45], '2': [130, 132, 145], '3': [70, 150, 85],
18  D: [175, 55, 55], X: [60, 220, 110],
19}
20// Colour-blind mode. Red and green carry meaning all over this game — a red door against a green
21// exit switch is the textbook case — so the mode swaps in colours that stay apart on the axes
22// dichromats keep, spreads them by brightness too, and gives the two wall markers a pattern each,
23// because no palette is a substitute for a cue that is not colour at all.
24// the walls go dark, because they are the background: everything that matters stands in front of
25// them, and a dichromat reads a bright thing on a dark wall whatever the two hues do
26const SAFE_WALL: Record<string, Rgb> = {
27  '1': [78, 78, 78], '2': [96, 102, 108], '3': [52, 66, 82],
28  D: [230, 159, 0], X: [86, 180, 233],
29}
30// spread along brightness as well as hue: hue alone is what a dichromat is short of
31const SAFE_TINT: Record<Drawn, Rgb> = {
32  imp: [255, 109, 31], demon: [170, 90, 200], zombie: [130, 205, 245],
33  health: [255, 255, 255], key: [240, 228, 66], shells: [230, 159, 0],
34  bullets: [48, 150, 224], shotgun: [0, 158, 115], chaingun: [154, 160, 166],
35  ball: [255, 225, 90],
36}
37// What a player has to tell apart in a hurry, and what it costs to get wrong. Three kinds of colour
38// blindness leave about one hue axis and brightness between them, which is not room for nine
39// separate colours, so the promise is made where a mix-up costs something:
40//   the door from the exit    — walk into the wrong one and you are lost, and both are flat wall
41//   one monster from another  — how fast it closes and how much it takes decides what you do
42//   anything from the wall    — seeing that something is there at all
43// Pickups are not on the list against each other. You walk over what you step on; there is no
44// choice to get wrong, and their shapes differ anyway. Where colour runs out, pattern takes over.
45export const CRITICAL: [string, string][] = [
46  ['wall:D', 'wall:X'],
47  ['imp', 'demon'], ['imp', 'zombie'], ['demon', 'zombie'],
48  ['imp', 'wall:2'], ['demon', 'wall:2'], ['zombie', 'wall:2'],
49  ['health', 'wall:2'], ['key', 'wall:2'], ['shells', 'wall:2'], ['bullets', 'wall:2'],
50]
51export const safeRgb = (name: string): Rgb =>
52  name.startsWith('wall:') ? SAFE_WALL[name.slice(5)] : SAFE_TINT[name as Drawn]
53
54const CEILING: Rgb = [46, 46, 58]
55const GROUND: Rgb = [78, 62, 48]
56const METAL: Rgb = [96, 98, 108]
57const BARREL: Rgb = [168, 150, 112]
58
59const art = (...rows: string[]) => rows
60type Drawn = Kind | ItemKind | 'ball'
61export const SPRITE: Record<Drawn, Sprite> = {
62  imp: { rgb: [200, 72, 40], scale: 0.8, art: art(
63    '..#..#..', '.#.##.#.', '.######.', '.#.##.#.', '..####..', '.##..##.', '.#.##.#.', '##....##') },
64  demon: { rgb: [225, 120, 140], scale: 0.95, art: art(
65    '#......#', '#.#..#.#', '.######.', '.#.##.#.', '.######.', '.##..##.', '.#....#.', '##....##') },
66  zombie: { rgb: [150, 145, 110], scale: 0.75, art: art(
67    '...##...', '...##...', '..####..', '.#####..', '..###.##', '..####..', '..#..#..', '..#..#..') },
68  health: { rgb: [60, 200, 90], scale: 0.34, art: art(
69    '........', '...##...', '...##...', '.######.', '.######.', '...##...', '...##...', '........') },
70  shells: { rgb: [215, 180, 40], scale: 0.3, art: art(
71    '........', '........', '.######.', '.#.##.#.', '.#.##.#.', '.######.', '........', '........') },
72  bullets: { rgb: [190, 140, 60], scale: 0.26, art: art(
73    '........', '........', '..####..', '..#..#..', '..#..#..', '..####..', '........', '........') },
74  shotgun: { rgb: [175, 140, 95], scale: 0.34, art: art(
75    '........', '........', '..#####.', '.######.', '.##.....', '........', '........', '........') },
76  chaingun: { rgb: [150, 150, 165], scale: 0.36, art: art(
77    '........', '..####..', '.#####..', '.####...', '.###....', '.##.....', '........', '........') },
78  key: { rgb: [230, 50, 50], scale: 0.3, art: art(
79    '..###...', '..#.#...', '..###...', '...#....', '...#....', '...##...', '...#....', '...##...') },
80  ball: { rgb: [255, 190, 60], scale: 0.22, float: true, art: art(
81    '..####..', '.######.', '########', '########', '########', '########', '.######.', '..####..') },
82}
83// the weapon in your hands, bottom centre of the view
84const HANDS: Record<Weapon, string[]> = {
85  pistol: art('...##...', '...##...', '..####..', '..####..', '.######.', '########'),
86  shotgun: art('....##..', '...####.', '..#====#', '.##====#', '######==', '########'),
87  chaingun: art('..#..#..', '.##==##.', '.##==##.', '.######.', '########', '########'),
88}
89
90// the engine counts the nodes in a frame, and every change of colour along a row is one more span,
91// so the shades are quantised: neighbouring columns of the same wall come out equal and merge
92export const SHADES = 24
93const hex = (rgb: Rgb, k: number) =>
94  `#${rgb.map(c => Math.max(0, Math.min(255, Math.round((c * k) / SHADES) * SHADES)).toString(16).padStart(2, '0')).join('')}`
95// further is darker, and never quite black
96const fog = (d: number) => Math.max(0.16, 1 / (1 + d * 0.14))
97// a still frame of a corridor looks exactly like a running one, so a stopped game is dimmed: it is
98// the only way to see at a glance that this view is not playing
99const dimmed = new Map<string, string>()
100const dim = (color: string) => {
101  // paused is when the person is reading Claude's answer, and it is the frame that dims every
102  // pixel. The palette is quantised, so the colours coming through here are a small fixed set.
103  const known = dimmed.get(color)
104  if (known !== undefined) return known
105  const out = `#${[1, 3, 5].map(i => Math.round(parseInt(color.slice(i, i + 2), 16) * 0.42).toString(16).padStart(2, '0')).join('')}`
106  dimmed.set(color, out)
107  return out
108}
109
110export default function Doom(props: Props, surface: ClientSurface<State>) {
111  const { Box, Text } = surface.elements
112  const ensure = (s: State): DoomGame => (s.game && !s.game.over ? s.game : newDoom())
113  // the view, in pixels: one per column, two per row
114  const view = () => ({ w: Math.max(8, surface.columns - 2), ph: Math.max(6, (surface.rows - 3) * 2) })
115
116  const act = (fn: (g: DoomGame) => DoomGame) => {
117    const s = surface.state
118    if (!s) return
119    surface.setState({ ...s, banner: false, playing: true, game: fn(ensure(s)) })
120  }
121
122  if (surface.state === undefined) {
123    // the floor is there from the first frame; nothing moves until you press or click
124    surface.setState({ ...base(props), game: newDoom() })
125    // 20 frames a second: the movement is driven by the frame, not by how fast a key repeats
126    surface.every(50, () => {
127      const s = surface.state
128      if (!s?.game || !s.playing || s.game.over) return
129      const game = tick(s.game)
130      if (game.over) surface.post({ game: 'doom', score: game.kills })
131      surface.setState({ ...s, game })
132    })
133    surface.onKey(({ key }) => {
134      const s = surface.state
135      if (!s) return
136      const k = key === 'space' ? ' ' : key.toLowerCase()
137      if (k === 'p') {
138        if (s.game && !s.game.over) surface.setState({ ...s, banner: false, playing: !s.playing })
139        return
140      }
141      if (k === 'r') {
142        surface.setState({ ...s, banner: false, playing: true, game: newDoom() })
143        return
144      }
145      const weapon: Weapon | undefined = k === '1' ? 'pistol' : k === '2' ? 'shotgun' : k === '3' ? 'chaingun' : undefined
146      // one press, one step: what the terminal repeats while you hold a key is what walks you
147      const step =
148        weapon ? (g: DoomGame) => select(g, weapon)
149        : k === 'w' || k === 'up' ? (g: DoomGame) => move(g, STEP)
150        : k === 's' || k === 'down' ? (g: DoomGame) => move(g, -STEP)
151        : k === 'a' ? (g: DoomGame) => move(g, 0, -STEP)
152        : k === 'd' ? (g: DoomGame) => move(g, 0, STEP)
153        : k === 'left' || k === 'q' ? (g: DoomGame) => turn(g, -SWING)
154        : k === 'right' || k === 'e' ? (g: DoomGame) => turn(g, SWING)
155        : k === ' ' || k === 'return' ? fire
156        : undefined
157      if (step) act(step)
158    })
159    // the pointer aims: moving over the view turns toward it, a click fires
160    surface.onPointer(ev => {
161      const s = surface.state
162      if (!s) return
163      if (ev.type !== 'move' && ev.type !== 'down') return
164      if (ev.type === 'move' && (!s.playing || s.game?.over)) return
165      const w = view().w
166      const dpp = w / 2 / Math.tan(FOV / 2)
167      const aim = (g: DoomGame) => turn(g, Math.atan2(ev.x - 1 - w / 2, dpp) * 0.5)
168      act(ev.type === 'down' ? g => fire(aim(g)) : aim)
169    })
170  }
171  claudeDone(props, surface)
172
173  const s = surface.state
174  const g = s?.game
175  const { w, ph } = view()
176  const rows = ph / 2
177  const dpp = w / 2 / Math.tan(FOV / 2)
178  // one colour per pixel, filled by the wall pass and overdrawn by the sprites and the weapon
179  const buf: string[] = Array.from({ length: w * ph })
180  const depth = new Float64Array(w)
181
182  const safe = props?.safe ?? false
183  const walls = safe ? SAFE_WALL : WALL
184  const tint = (name: Drawn, fallback: Rgb) => (safe ? SAFE_TINT[name] : fallback)
185
186  if (g) {
187    // one colour per row for the floor and the ceiling: they fade with the row's own distance and
188    // nothing else, so building them per pixel rebuilt the same string tens of thousands of times.
189    // Every column then shares the identical string, which makes the run merge in grid() a pointer
190    // compare rather than a character one.
191    const sky = Array.from({ length: ph }, (_, y) =>
192      hex(y < ph / 2 ? CEILING : GROUND, fog(ph / 2 / Math.max(0.5, Math.abs(y - ph / 2)))))
193
194    // Wide bars, not every other column: a door filling a wide view would otherwise emit one run
195    // per two columns, and the run cap in grid() folds pairs by keeping the left one's colour —
196    // which collapses an alternating pattern back to a single flat colour. A dozen bands across
197    // the view stays well under the cap at any width, so the cue survives on a big terminal.
198    const barW = Math.max(2, Math.round(w / 12))
199
200    for (let x = 0; x < w; x++) {
201      const angle = g.dir + Math.atan2(x - w / 2 + 0.5, dpp)
202      const ray = cast(g.map, g.px, g.py, angle)
203      // the cosine takes the fisheye out: what matters is the distance to the wall's plane
204      const d = Math.max(0.05, ray.dist * Math.cos(angle - g.dir))
205      depth[x] = d
206      const h = dpp / d
207      const top = Math.round(ph / 2 - h / 2)
208      const bottom = Math.round(ph / 2 + h / 2)
209      // the two faces of a corner catch the light differently, which is what makes corners read
210      const lit = fog(d) * (ray.side === 1 ? 0.68 : 1)
211      const base = walls[ray.kind] ?? walls['2']
212      // in colour-blind mode the door is barred the short way and the exit switch the long way, so
213      // the two of them read apart with the colour taken away entirely
214      const bars = safe && ray.kind === 'D' ? (Math.floor(x / barW) & 1 ? 1.35 : 0.6) : 1
215      const wall = hex(base, lit * bars)
216      const banded = safe && ray.kind === 'X'
217      for (let y = 0; y < ph; y++) {
218        buf[y * w + x] = y >= top && y < bottom
219          ? (banded ? hex(base, lit * ((y >> 1) & 1 ? 1.35 : 0.6)) : wall)
220          : sky[y]
221      }
222    }
223
224    // billboards, furthest first so a near one covers what stands behind it
225    const billboard = (name: Drawn, x: number, y: number, hurt = false) =>
226      ({ x, y, art: SPRITE[name].art, scale: SPRITE[name].scale, float: SPRITE[name].float, rgb: tint(name, SPRITE[name].rgb), hurt })
227    const board = [
228      ...g.enemies.map(e => billboard(e.kind, e.x, e.y, e.hurt > 0)),
229      ...g.items.map(i => billboard(i.kind, i.x, i.y)),
230      ...g.balls.map(b => billboard('ball', b.x, b.y)),
231    ]
232      .map(sp => ({ ...sp, d: Math.hypot(sp.x - g.px, sp.y - g.py) }))
233      .sort((a, b) => b.d - a.d)
234    for (const sp of board) {
235      const a = wrap(Math.atan2(sp.y - g.py, sp.x - g.px) - g.dir)
236      if (Math.abs(a) > FOV) continue
237      // the depth buffer holds perpendicular distances, so the sprite has to be measured the same
238      // way or it is culled by a wall it stands in front of, and drawn at a different scale from
239      // the walls beside it. Shading still wants the true distance: fog is about how far away it is.
240      const perp = Math.max(0.1, sp.d * Math.cos(a))
241      const full = dpp / perp
242      const size = Math.max(1, Math.round(full * sp.scale))
243      const left = w / 2 + Math.tan(a) * dpp - size / 2
244      // a monster or a pickup stands on the floor; a fireball flies at eye level
245      const top = sp.float ? ph / 2 - size / 2 : ph / 2 + full / 2 - size
246      const color = hex(sp.rgb, sp.hurt ? 1.6 : Math.max(0.5, fog(sp.d) * 1.4))
247      // walk the part of the screen the sprite covers, not the sprite: something almost in your
248      // face is scaled by 1/distance and would otherwise be a loop the size of the square of it
249      for (let x = Math.max(0, Math.ceil(left)); x < Math.min(w, left + size); x++) {
250        if (depth[x] <= perp) continue
251        const col = Math.floor(((x - left) / size) * 8)
252        for (let y = Math.max(0, Math.ceil(top)); y < Math.min(ph, top + size); y++) {
253          if (sp.art[Math.floor(((y - top) / size) * 8)][col] === '.') continue
254          buf[y * w + x] = color
255        }
256      }
257    }
258
259    // the crosshair, then the weapon across the bottom, then the muzzle flash over both
260    const cx = Math.floor(w / 2)
261    const cy = Math.floor(ph / 2)
262    for (const [dx, dy] of [[0, -2], [0, 2], [-2, 0], [2, 0]] as const) {
263      const x = cx + dx
264      const y = cy + dy
265      if (x >= 0 && x < w && y >= 0 && y < ph) buf[y * w + x] = '#d8d8d8'
266    }
267    const hands = HANDS[g.weapon]
268    const gh = Math.max(4, Math.round(ph * 0.3))
269    const gw = gh * 2
270    for (let iy = 0; iy < gh; iy++) for (let ix = 0; ix < gw; ix++) {
271      const mark = hands[Math.floor((iy / gh) * hands.length)][Math.floor((ix / gw) * hands[0].length)]
272      if (mark === '.') continue
273      const x = Math.round(cx - gw / 2 + ix)
274      const y = ph - gh + iy
275      if (x < 0 || x >= w || y < 0 || y >= ph) continue
276      buf[y * w + x] = hex(mark === '=' ? BARREL : METAL, g.flash > 0 ? 1.5 : 1)
277    }
278    if (g.flash > 0) {
279      const blast = Math.max(2, Math.round(gh * 0.7))
280      for (let iy = -blast; iy <= blast; iy++) for (let ix = -blast; ix <= blast; ix++) {
281        if (Math.hypot(ix, iy) > blast) continue
282        const x = cx + ix
283        const y = ph - gh + iy
284        if (x < 0 || x >= w || y < 0 || y >= ph) continue
285        buf[y * w + x] = Math.hypot(ix, iy) > blast * 0.6 ? '#ffb020' : '#fff0c0'
286      }
287    }
288  }
289
290  const stopped = !s?.playing || !!g?.over
291  const shade = (color: string) => (stopped ? dim(color) : color)
292  const paint = (x: number, y: number): Paint =>
293    ['▀', shade(buf[y * 2 * w + x] ?? '#101014'), shade(buf[(y * 2 + 1) * w + x] ?? '#101014')]
294  // what is left of the engine's node budget for this frame, shared out over the rows
295  const maxRuns = Math.max(6, Math.floor(1500 / Math.max(1, rows)) - 1)
296  const border = g?.over ? 'red' : (g?.pain ?? 0) > 0 ? 'redBright' : g?.flash ? 'yellow' : 'gray'
297  // a pickup, a door or a new floor says so for a couple of seconds
298  const said = g && g.said && g.ticks - g.saidAt < 50 ? ` · ${g.said}` : ''
299  // once a floor is under way the line keeps showing it, paused or not: what you want to read when
300  // you come back is your health, not the controls again
301  const text = g?.over ? `doom · you died on E1M${g.level} · ${g.kills} killed · r starts again`
302    : (g?.ticks ?? 0) === 0 && !s?.playing && !s?.banner ? 'doom · click the view, then WASD moves · ←→ turn · space fires · 1 2 3 weapons'
303    : `doom · E1M${g?.level ?? 1} · health ${g?.health ?? 0} · ${g?.weapon ?? 'pistol'} ${g?.[WEAPON[g?.weapon ?? 'pistol'].ammo] ?? 0} · killed ${g?.kills ?? 0}${g?.key ? ' · red key' : ''}${said}`
304  // a stopped game says so across the middle of the view, where the click has to land anyway: the
305  // keyboard goes to the prompt until a click gives it to the board, and a status line is easy to miss
306  const label = !stopped ? undefined
307    : g?.over ? 'you died · press r to start again'
308    : s?.banner ? '● Claude is done · click here or press a key to carry on'
309    : (g?.ticks ?? 0) === 0 ? 'CLICK HERE TO PLAY · then WASD moves, space fires'
310    : 'paused · click here or press p'
311  const view3d = grid(Text, w, rows, paint, maxRuns)
312  if (label) {
313    const pad = Math.max(0, Math.floor((w - label.length) / 2))
314    const line = (' '.repeat(pad) + label).padEnd(w).slice(0, w)
315    view3d[Math.floor(rows / 2)] = <Text backgroundColor="#101014" color={s?.banner ? '#ffd24a' : '#ffffff'} bold>{line}</Text>
316  }
317  return (
318    <Box flexDirection="column">
319      <Box flexDirection="column" borderStyle="round" borderColor={border} width={w + 2}>
320        {view3d}
321      </Box>
322      {statusLine(Text, !!s?.banner, `${text} · best ${props?.best ?? 0}`)}
323    </Box>
324  )
325}
326
hooks/boards/common.tsx 61 lines
1/* @jsx h */
2import type { ClientElements, ClientSurface } from 'claude-code'
3
4// Shared by every board. A board is a surface module: it runs on the drawing thread with its own
5// frame clock, keys (after a click gives it focus; Esc gives the prompt back) and mouse.
6//
7// Never name a local `h` in a board file: every JSX tag compiles to a call of `h`.
8
9export type Props = { best?: number; done?: number; safe?: boolean } | undefined
10export type Base = { playing: boolean; banner: boolean; seenDone: number }
11export type Paint = readonly [glyph: string, color?: string, bg?: string]
12type TextTag = ClientElements['Text']
13
14export const base = (props: Props): Base => ({ playing: false, banner: false, seenDone: props?.done ?? 0 })
15
16// the hooks module bumps props.done when Claude finishes a turn: pause and say so, once per turn
17export function claudeDone<S extends Base>(props: Props, surface: ClientSurface<S>) {
18  const s = surface.state
19  const done = props?.done ?? 0
20  if (s && done !== s.seenDone) surface.setState({ ...s, seenDone: done, banner: true, playing: false })
21}
22
23export function statusLine(Text: TextTag, banner: boolean, text: string) {
24  return banner
25    ? <Text color="yellow" bold wrap="truncate-end">{`● Claude is done · ${text}`}</Text>
26    : <Text dimColor wrap="truncate-end">{text}</Text>
27}
28
29export type Run = [text: string, color?: string, bg?: string]
30
31// one run per stretch of cells sharing both colors. A drawing the engine cannot fit in its node
32// budget is worse than a coarser one, so maxRuns folds neighbours together, pair by pair, until
33// the row fits: a merged pair keeps the left one's colors and every glyph.
34export function runs(cols: number, y: number, at: (x: number, y: number) => Paint, maxRuns = Infinity): Run[] {
35  let out: Run[] = []
36  for (let x = 0; x < cols; x++) {
37    const [glyph, color, bg] = at(x, y)
38    const last = out[out.length - 1]
39    if (last && last[1] === color && last[2] === bg) last[0] += glyph
40    else out.push([glyph, color, bg])
41  }
42  while (out.length > Math.max(1, maxRuns)) {
43    const folded: Run[] = []
44    for (let i = 0; i < out.length; i += 2) {
45      const [t, c, b] = out[i]
46      const next = out[i + 1]
47      folded.push(next ? [t + next[0], c, b] : [t, c, b])
48    }
49    out = folded
50  }
51  return out
52}
53
54// a board as rows of two-character cells (a terminal cell is about twice as tall as wide, so two
55// make a square), one styled span per run of the same colors
56export function grid(Text: TextTag, cols: number, rowCount: number, at: (x: number, y: number) => Paint, maxRuns = Infinity) {
57  return Array.from({ length: Math.max(0, rowCount) }, (_, y) => (
58    <Text>{runs(cols, y, at, maxRuns).map(([t, c, b]) => (b ? <Text color={c} backgroundColor={b}>{t}</Text> : <Text color={c}>{t}</Text>))}</Text>
59  ))
60}
61
hooks/games/doom.ts 479 lines
1// Doom: a raycaster corridor shooter, drawn by the Doom board in ../boards/doom.tsx.
2// One map cell is one square unit, the player is a point with a radius, angles are radians and
3// grow clockwise on screen (+x east, +y south). One tick is one 50 ms frame.
4//
5// One key press is one step. A terminal sends presses and nothing else — no release, and it repeats
6// only the key held last — so anything that keeps moving you after a press has to guess when you
7// stopped, and guessing wrong feels worse than a short step. Hold a key and the terminal's own
8// repeat walks you; spam the keys and you go exactly as far as you pressed.
9//
10// A floor is a block of text: walls are 1 (brown), 2 (grey), 3 (green), D a locked door and X the
11// exit switch. Everything else stands on the floor and is lifted out when the level is parsed —
12// S the spawn, i d z the monsters, + a b g c k the pickups.
13
14export type Kind = 'imp' | 'demon' | 'zombie'
15// bite counts down to the next time this one can claw you; hurt is the white flash of being shot
16type Enemy = { kind: Kind; x: number; y: number; hp: number; hurt: number; bite?: number }
17export type ItemKind = 'health' | 'shells' | 'bullets' | 'shotgun' | 'chaingun' | 'key'
18type Item = { kind: ItemKind; x: number; y: number }
19export type Weapon = 'pistol' | 'shotgun' | 'chaingun'
20type Ball = { x: number; y: number; dx: number; dy: number }
21type Ray = { dist: number; side: 0 | 1; kind: string }
22type Pt = readonly [number, number]
23export type DoomGame = {
24  level: number
25  map: string[]
26  exits: Pt[]
27  px: number; py: number; dir: number
28  enemies: Enemy[]; balls: Ball[]; items: Item[]
29  health: number; bullets: number; shells: number
30  weapon: Weapon; owned: Weapon[]; key: boolean
31  // frames until the weapon can fire again, frames left on the muzzle flash and on the pain flash
32  cool: number; flash: number; pain: number
33  kills: number; ticks: number; over: boolean
34  // what just happened, and the tick it happened on, for the status line
35  said: string; saidAt: number
36}
37
38const MONSTER: Record<Kind, { hp: number; speed: number; melee: number; reach: number; shot: number }> = {
39  // an imp throws fireballs, a demon only bites but is fast and takes a beating, a zombie shoots
40  // Speeds are cells a frame. A key press moves you 0.3 and you can press several times a frame by
41  // holding the key, so these sit well under that: a monster that keeps pace with someone spamming
42  // keys is a monster you can never get away from. Measured against a bot playing at 7, 10 and 20
43  // presses a second; the slowest of those has to be able to finish a floor.
44  imp: { hp: 60, speed: 0.038, melee: 7, reach: 0, shot: 0 },
45  demon: { hp: 110, speed: 0.07, melee: 12, reach: 0, shot: 0 },
46  zombie: { hp: 30, speed: 0.03, melee: 4, reach: 10, shot: 5 },
47}
48export const WEAPON: Record<Weapon, { damage: number; pellets: number; spread?: number; cool: number; ammo: 'bullets' | 'shells' }> = {
49  pistol: { damage: 18, pellets: 1, cool: 6, ammo: 'bullets' },
50  shotgun: { damage: 22, pellets: 3, spread: 0.09, cool: 12, ammo: 'shells' },
51  chaingun: { damage: 12, pellets: 1, cool: 2, ammo: 'bullets' },
52}
53const PICKUP: Record<string, ItemKind> = { '+': 'health', a: 'shells', b: 'bullets', g: 'shotgun', c: 'chaingun', k: 'key' }
54const SPAWNS: Record<string, Kind> = { i: 'imp', d: 'demon', z: 'zombie' }
55
56const RADIUS = 0.28
57const BALL_SPEED = 0.11
58const BALL_HIT = 9
59const THROW_CHANCE = 0.0075
60const SHOOT_CHANCE = 0.01
61const REACH = 1.1
62// a second between one monster's claws, counted per monster
63const BITE_EVERY = 20
64// how far one press takes you, and how far one turns you
65export const STEP = 0.3
66export const SWING = 0.17
67const GRAB = 0.6
68
69// E1M1 through E1M4, in the sense that there are four of them and the first one is a hangar
70export const LEVELS = [
71  [
72    '222222222222222222222222',
73    '2....S.2.......b.......2',
74    '2......2....3333333....2',
75    '2......2....3..a..3....2',
76    '2..+...1....3..i..3....2',
77    '2222.222....3.....3....2',
78    '2...........33...33....2',
79    '2....1111.......z......2',
80    '2....1..1....2222222...2',
81    '2..i...g1....2..X..2...2',
82    '2....1..1....2..i..D...2',
83    '2....1111....2.....2...2',
84    '2............2.2.222...2',
85    '2.......k..............2',
86    '2..33333333333...3333..2',
87    '2..3....z....a...3..b..2',
88    '2..3.........i...3.....2',
89    '2..3333333.3333333.....2',
90    '2......+...........i...2',
91    '222222222222222222222222',
92  ],
93  [
94    '111111111111111111111111',
95    '1....1S.....i.....b....1',
96    '1....1..1111111111.....1',
97    '1....1..1........1..z..1',
98    '1....D..1..a..d..1.....1',
99    '1....1..1........1.....1',
100    '1.b..1..1.111111.1..1111',
101    '1....1..1.1....1.1.....1',
102    '11.111..1.1.k..1.1111..1',
103    '1.......1.1....1....1..1',
104    '1..zzz..1.1.1111....1..1',
105    '1.......1.1.1..i....1..1',
106    '111.11111.1.1..111111..1',
107    '1.........1.1.......d..1',
108    '1..c......1.1..1111111.1',
109    '1.........1.1..1XXX.1..1',
110    '1111111.111.1..1...1...1',
111    '1......+....1..1.i.D..a1',
112    '1...i.......1..11111...1',
113    '111111111111111111111111',
114  ],
115  [
116    '333333333333333333333333',
117    '3.................b....3',
118    '3.....2..2..2..2.......3',
119    '3..+..2..2..2..2....d..3',
120    '3S....................a3',
121    '3..2..111111111111..2..3',
122    '3..2..1...i....z.1..2..3',
123    '3..2..1..........1..2..3',
124    '3.....1..2222.2..1.....3',
125    '3..i..1..2..X.2..1..i..3',
126    '3..+..1..2....2..1.....3',
127    '3..2..1..22.222..1..2..3',
128    '3..2..1..........1..2..3',
129    '3..2..1..z....i..1..2..3',
130    '3.....1111111D1111.....3',
131    '3..a...................3',
132    '3.....2..2..k..2..2....3',
133    '3..b..2..2..2..2..2..+.3',
134    '3.z.......d.........i..3',
135    '333333333333333333333333',
136  ],
137  [
138    '222222222222222222222222',
139    '2...b..2S......2....a..2',
140    '2......2...d...2.......2',
141    '2......2.......2...i...2',
142    '2.11.112.22.222D2222.222',
143    '2.1....2....a....2.....2',
144    '2.1.z..D....k....2..d..2',
145    '2.1....2.........2.....2',
146    '2.111112.2222222.2.....2',
147    '2...+....2.....2.2222.22',
148    '2..i..d..2..i..2.......2',
149    '2........2.....2...zz..2',
150    '22.2222.22..a..2.......2',
151    '2...b...2.......2222.222',
152    '2...c...2..111111..D...2',
153    '2.......2..1XXX.1..1.d.2',
154    '2.222.222..1...11..1...2',
155    '2.....+....1.i.....1.b.2',
156    '2..z.......111111111...2',
157    '222222222222222222222222',
158  ],
159]
160
161export const at = (map: string[], x: number, y: number): string => map[y]?.[x] ?? '2'
162export const solid = (cell: string) => cell !== '.' && cell !== 'S'
163const put = (map: string[], x: number, y: number, ch: string): string[] =>
164  map.map((row, ry) => (ry === y ? row.slice(0, x) + ch + row.slice(x + 1) : row))
165
166// the player's box, not its centre, so you cannot clip a corner
167export const blocked = (map: string[], x: number, y: number): boolean => {
168  for (const dx of [-RADIUS, RADIUS]) for (const dy of [-RADIUS, RADIUS]) {
169    if (solid(at(map, Math.floor(x + dx), Math.floor(y + dy)))) return true
170  }
171  return false
172}
173
174// DDA: step to the next grid line on whichever axis is nearer until a wall. The distance is the
175// Euclidean ray length; a view multiplies it by cos(angle - dir) to undo the fisheye.
176export function cast(map: string[], x: number, y: number, angle: number, max = 40): Ray {
177  const dx = Math.cos(angle)
178  const dy = Math.sin(angle)
179  const ddx = dx === 0 ? Infinity : Math.abs(1 / dx)
180  const ddy = dy === 0 ? Infinity : Math.abs(1 / dy)
181  let mx = Math.floor(x)
182  let my = Math.floor(y)
183  let sdx = dx < 0 ? (x - mx) * ddx : (mx + 1 - x) * ddx
184  let sdy = dy < 0 ? (y - my) * ddy : (my + 1 - y) * ddy
185  let side: 0 | 1 = 0
186  for (let steps = 0; steps < 256; steps++) {
187    if (sdx < sdy) { sdx += ddx; mx += dx < 0 ? -1 : 1; side = 0 }
188    else { sdy += ddy; my += dy < 0 ? -1 : 1; side = 1 }
189    const dist = side === 0 ? sdx - ddx : sdy - ddy
190    if (dist > max) break
191    const cell = at(map, mx, my)
192    if (solid(cell)) return { dist: Math.max(1e-4, dist), side, kind: cell }
193  }
194  return { dist: max, side: 0, kind: '2' }
195}
196
197// is b in plain sight from a, and how far
198const sight = (map: string[], ax: number, ay: number, bx: number, by: number) => {
199  const d = Math.hypot(bx - ax, by - ay)
200  return { d, clear: cast(map, ax, ay, Math.atan2(by - ay, bx - ax), d + 1).dist >= d }
201}
202
203// to -PI..PI, so a difference of angles reads as left or right
204export const wrap = (a: number) => Math.atan2(Math.sin(a), Math.cos(a))
205
206type Level = { map: string[]; exits: Pt[]; px: number; py: number; enemies: Enemy[]; items: Item[] }
207
208// the text of a floor, with everything that stands on it lifted out and the cell left as floor
209export function parseLevel(src: string[]): Level {
210  const enemies: Enemy[] = []
211  const items: Item[] = []
212  const exits: Pt[] = []
213  let px = 1.5
214  let py = 1.5
215  const map = src.map((row, y) =>
216    [...row].map((cell, x) => {
217      if (cell === 'X') { exits.push([x, y]); return cell }
218      if (cell === 'S') { px = x + 0.5; py = y + 0.5; return '.' }
219      const monster = SPAWNS[cell]
220      if (monster) { enemies.push({ kind: monster, x: x + 0.5, y: y + 0.5, hp: MONSTER[monster].hp, hurt: 0 }); return '.' }
221      const item = PICKUP[cell]
222      if (item) { items.push({ kind: item, x: x + 0.5, y: y + 0.5 }); return '.' }
223      return cell
224    }).join(''),
225  )
226  return { map, exits, px, py, enemies, items }
227}
228
229const levelAt = (level: number) => LEVELS[(level - 1) % LEVELS.length]
230
231// Which way to look when a floor starts. Pointing straight at the door is not enough — on most
232// floors a wall stands between you and it, so the bearing lands on masonry. Walk the route to the
233// door instead and look as far along it as you can actually see: the way out, from the first frame.
234function route(map: string[], from: Pt, to: Pt): Pt[] {
235  const came = new Map<string, Pt | null>([[`${from[0]},${from[1]}`, null]])
236  const queue: Pt[] = [from]
237  while (queue.length) {
238    const cur = queue.shift()!
239    if (cur[0] === to[0] && cur[1] === to[1]) {
240      const path: Pt[] = []
241      for (let at: Pt | null = cur; at; at = came.get(`${at[0]},${at[1]}`) ?? null) path.unshift(at)
242      return path
243    }
244    for (const [dx, dy] of [[1, 0], [-1, 0], [0, 1], [0, -1]] as const) {
245      const next: Pt = [cur[0] + dx, cur[1] + dy]
246      const cell = at(map, next[0], next[1])
247      // a locked door is on the way to itself, so it counts as walkable here
248      if (came.has(`${next[0]},${next[1]}`) || (solid(cell) && cell !== 'D')) continue
249      came.set(`${next[0]},${next[1]}`, cur)
250      queue.push(next)
251    }
252  }
253  return []
254}
255
256export function facing(map: string[], exits: Pt[], px: number, py: number): number {
257  const doors: Pt[] = []
258  for (let y = 0; y < map.length; y++) for (let x = 0; x < map[y].length; x++) {
259    if (at(map, x, y) === 'D') doors.push([x, y])
260  }
261  const marks = doors.length ? doors : exits
262  if (!marks.length) return 0
263  const near = (m: Pt) => Math.hypot(m[0] + 0.5 - px, m[1] + 0.5 - py)
264  const target = marks.reduce((best, m) => (near(m) < near(best) ? m : best))
265  const path = route(map, [Math.floor(px), Math.floor(py)], target)
266  const bearing = (m: Pt) => Math.atan2(m[1] + 0.5 - py, m[0] + 0.5 - px)
267  if (!path.length) return bearing(target)
268
269  // the furthest step of the route still in plain sight: a corridor mouth, a doorway, the door
270  let look = bearing(target)
271  for (const step of path) {
272    const d = Math.hypot(step[0] + 0.5 - px, step[1] + 0.5 - py)
273    if (d < 1) continue
274    const angle = bearing(step)
275    if (cast(map, px, py, angle, d + 1).dist < d) break
276    look = angle
277  }
278  return look
279}
280
281export function newDoom(level = 1): DoomGame {
282  const l = parseLevel(levelAt(level))
283  return {
284    level, map: l.map, exits: l.exits, px: l.px, py: l.py, dir: facing(l.map, l.exits, l.px, l.py),
285    enemies: l.enemies, balls: [], items: l.items,
286    health: 100, bullets: 50, shells: 0,
287    weapon: 'pistol', owned: ['pistol'], key: false,
288    cool: 0, flash: 0, pain: 0, kills: 0, ticks: 0, over: false,
289    said: '', saidAt: 0,
290  }
291}
292
293// the exit switch keeps what you are carrying and hands you the next floor
294function nextLevel(g: DoomGame): DoomGame {
295  const l = parseLevel(levelAt(g.level + 1))
296  return {
297    ...g,
298    level: g.level + 1, map: l.map, exits: l.exits, px: l.px, py: l.py, dir: facing(l.map, l.exits, l.px, l.py),
299    enemies: l.enemies, balls: [], items: l.items,
300    key: false, cool: 0, flash: 0, pain: 0,
301    bullets: g.bullets + 10,
302    said: `floor ${g.level + 1}`,
303    saidAt: g.ticks,
304  }
305}
306
307// what just happened, stamped with the frame it happened on so the view can fade it. Stamping at
308// the source matters: picking up a second health box says 'health' again, and a message compared
309// against the last one would take the repeat for no news at all.
310const say = (g: DoomGame, said: string): DoomGame => ({ ...g, said, saidAt: g.ticks })
311
312export const turn = (g: DoomGame, radians: number): DoomGame =>
313  g.over ? g : { ...g, dir: wrap(g.dir + radians) }
314
315// swapping weapons takes a moment, so it is not a free dodge out of an empty gun
316export const select = (g: DoomGame, weapon: Weapon): DoomGame =>
317  g.over || !g.owned.includes(weapon) ? g : say({ ...g, weapon, cool: Math.max(g.cool, 4) }, weapon)
318
319// each axis on its own, so a wall you walk into slides you along it instead of stopping you dead
320export function move(g: DoomGame, forward: number, strafe = 0): DoomGame {
321  if (g.over) return g
322  const dx = Math.cos(g.dir) * forward - Math.sin(g.dir) * strafe
323  const dy = Math.sin(g.dir) * forward + Math.cos(g.dir) * strafe
324  const px = blocked(g.map, g.px + dx, g.py) ? g.px : g.px + dx
325  const py = blocked(g.map, g.px, g.py + dy) ? g.py : g.py + dy
326  if (px !== g.px || py !== g.py) return { ...g, px, py }
327
328  // Nothing moved: the corner of your shoulder is on the corner of a wall, which is the one place
329  // walking forward does nothing at all and feels broken. Step a little to whichever side opens the
330  // way — and only if it does, so walking into a flat wall still just stops.
331  const len = Math.hypot(dx, dy)
332  if (!len) return g
333  for (const side of [1, -1]) {
334    const ox = (-dy / len) * RADIUS * side
335    const oy = (dx / len) * RADIUS * side
336    if (blocked(g.map, g.px + ox, g.py + oy)) continue
337    if (blocked(g.map, g.px + ox + dx, g.py + oy + dy)) continue
338    return { ...g, px: g.px + ox, py: g.py + oy }
339  }
340  return g
341}
342
343// one pellet: the nearest monster inside its cone and in front of the wall takes the damage
344function pellet(g: DoomGame, enemies: Enemy[], angle: number, damage: number): { enemies: Enemy[]; killed: number } {
345  const wall = cast(g.map, g.px, g.py, angle).dist
346  let best: { i: number; d: number } | undefined
347  enemies.forEach((e, i) => {
348    const d = Math.hypot(e.x - g.px, e.y - g.py)
349    if (d > wall) return
350    // a monster is about half a cell wide, so it covers less of the view the further away it is
351    if (Math.abs(wrap(Math.atan2(e.y - g.py, e.x - g.px) - angle)) > Math.atan2(0.45, d)) return
352    if (!best || d < best.d) best = { i, d }
353  })
354  if (!best) return { enemies, killed: 0 }
355  const hit = enemies.map((e, i) => (i === best?.i ? { ...e, hp: e.hp - damage, hurt: 2 } : e))
356  const alive = hit.filter(e => e.hp > 0)
357  return { enemies: alive, killed: hit.length - alive.length }
358}
359
360// what to reach for when the gun in your hands clicks empty: the heaviest thing with ammo in it,
361// derived from the table so a weapon added there is never quietly left out of the fallback
362const FALLBACK = (Object.keys(WEAPON) as Weapon[])
363  .sort((a, b) => WEAPON[b].damage * WEAPON[b].pellets - WEAPON[a].damage * WEAPON[a].pellets)
364
365export function fire(g: DoomGame): DoomGame {
366  if (g.over || g.cool > 0) return g
367  const spec = WEAPON[g.weapon]
368  if (g[spec.ammo] <= 0) {
369    // an empty gun switches itself rather than doing nothing while something walks into you
370    const next = FALLBACK.find(w => w !== g.weapon && g.owned.includes(w) && g[WEAPON[w].ammo] > 0)
371    return next
372      ? say(select(g, next), `out of ${spec.ammo} · ${next}`)
373      : say({ ...g, cool: 8 }, `out of ${spec.ammo}`)
374  }
375  let enemies = g.enemies
376  let kills = g.kills
377  for (let p = 0; p < spec.pellets; p++) {
378    // the pellets of a shell spread either side of where you aimed
379    const angle = g.dir + (spec.pellets === 1 ? 0 : (p - (spec.pellets - 1) / 2) * (spec.spread ?? 0))
380    const shot = pellet(g, enemies, angle, spec.damage)
381    enemies = shot.enemies
382    kills += shot.killed
383  }
384  return { ...g, enemies, kills, cool: spec.cool, flash: 4, [spec.ammo]: g[spec.ammo] - 1 }
385}
386
387const take = (g: DoomGame, item: Item): DoomGame => {
388  switch (item.kind) {
389    case 'health': return say({ ...g, health: Math.min(100, g.health + 25) }, 'health')
390    case 'shells': return say({ ...g, shells: g.shells + 8 }, 'shells')
391    case 'bullets': return say({ ...g, bullets: g.bullets + 20 }, 'bullets')
392    case 'key': return say({ ...g, key: true }, 'red key')
393    case 'shotgun':
394    case 'chaingun': return say({
395      ...g,
396      owned: g.owned.includes(item.kind) ? g.owned : [...g.owned, item.kind],
397      weapon: item.kind,
398      shells: item.kind === 'shotgun' ? g.shells + 8 : g.shells,
399      bullets: item.kind === 'chaingun' ? g.bullets + 20 : g.bullets,
400    }, item.kind)
401  }
402}
403
404// a locked door in reach opens once, and stays open, when you carry the key
405function openDoors(g: DoomGame): DoomGame {
406  if (!g.key) return g
407  for (let y = Math.floor(g.py) - 1; y <= Math.floor(g.py) + 1; y++) {
408    for (let x = Math.floor(g.px) - 1; x <= Math.floor(g.px) + 1; x++) {
409      if (at(g.map, x, y) === 'D') return say({ ...g, map: put(g.map, x, y, '.') }, 'the door opens')
410    }
411  }
412  return g
413}
414
415export function tick(g: DoomGame, rand = Math.random): DoomGame {
416  if (g.over) return g
417  const ticks = g.ticks + 1
418  let health = g.health
419  const balls: Ball[] = []
420
421  const enemies = g.enemies.map(e => {
422    const spec = MONSTER[e.kind]
423    const { d, clear } = sight(g.map, e.x, e.y, g.px, g.py)
424    const hurt = Math.max(0, e.hurt - 1)
425    const bite = e.bite ?? BITE_EVERY
426    if (!clear || d > 16) return { ...e, hurt, bite: BITE_EVERY }
427    // its own cooldown, not a shared frame phase: on a global phase three monsters reaching you
428    // together all land on the same frame, and one arriving just before it comes round bites at
429    // once while another waits most of a second. The counter only runs while it is on you, so
430    // closing in always costs it the same wind-up.
431    if (d < REACH) {
432      if (bite > 0) return { ...e, hurt, bite: bite - 1 }
433      health -= spec.melee
434      return { ...e, hurt, bite: BITE_EVERY }
435    }
436    if (e.kind === 'imp' && d > 2.5 && rand() < THROW_CHANCE) {
437      const a = Math.atan2(g.py - e.y, g.px - e.x)
438      balls.push({ x: e.x, y: e.y, dx: Math.cos(a) * BALL_SPEED, dy: Math.sin(a) * BALL_SPEED })
439    }
440    if (spec.reach && d < spec.reach && rand() < SHOOT_CHANCE) health -= spec.shot
441    // toward you, each axis on its own so a monster rounds a corner instead of hugging it
442    const sx = e.x + Math.sign(g.px - e.x) * spec.speed
443    const sy = e.y + Math.sign(g.py - e.y) * spec.speed
444    return {
445      ...e,
446      hurt,
447      bite: BITE_EVERY,
448      x: solid(at(g.map, Math.floor(sx), Math.floor(e.y))) ? e.x : sx,
449      y: solid(at(g.map, Math.floor(e.x), Math.floor(sy))) ? e.y : sy,
450    }
451  })
452
453  for (const b of g.balls) {
454    const x = b.x + b.dx
455    const y = b.y + b.dy
456    if (solid(at(g.map, Math.floor(x), Math.floor(y)))) continue
457    if (Math.hypot(x - g.px, y - g.py) < 0.5) { health -= BALL_HIT; continue }
458    balls.push({ ...b, x, y })
459  }
460
461  let next: DoomGame = {
462    ...g, ticks, enemies, balls, health,
463    pain: health < g.health ? 6 : Math.max(0, g.pain - 1),
464    flash: Math.max(0, g.flash - 1),
465    cool: Math.max(0, g.cool - 1),
466    over: health <= 0,
467  }
468  if (next.over) return say({ ...next, health: 0 }, 'you died')
469
470  // what you walk over you pick up, what you walk into opens, and the exit switch ends the floor
471  for (const item of next.items) {
472    if (Math.hypot(item.x - next.px, item.y - next.py) > GRAB) continue
473    next = { ...take(next, item), items: next.items.filter(i => i !== item) }
474  }
475  next = openDoors(next)
476  if (next.exits.some(([x, y]) => Math.hypot(x + 0.5 - next.px, y + 0.5 - next.py) < 1.2)) next = nextLevel(next)
477  return next
478}
479