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…

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-arcadeinstead.
/doom opens the board above the prompt (/doom stop or the close button closes it).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.
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.
claude -p, the desktop app or mobile.Clones of the genre, not affiliated with or endorsed by the original publishers.
hooks/register.tsx 104 lines1/* @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}
104hooks/games/best.ts 10 lines1// 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}
10hooks/boards/doom.tsx 326 lines1/* @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}
326hooks/boards/common.tsx 61 lines1/* @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}
61hooks/games/doom.ts 479 lines1// 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