SLOPSHOPPER

slayer-corps

Tanjiro, Nezuko, Zenitsu, and Inosuke take turns against demons up to Muzan as you and Claude work, and spar between strikes. The story continues every session.

newpaneguardcommandtoastprompt
★ 1v0.2.0MITupdated 2026-10-04theonly1me/claude-code-mods/plugins/slayer-corps
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · slayer-corps
│ ┃ Slayer Corps ✕ › 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 │ ┃ │ ┃ Chapter 1: The Mountain Pass · the Hollow W… ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ Tanjiro: Sun Dance hits for 8 (a finished t… ✻ Worked for 42s · done 4:20 PM │ ┃ Nezuko: Blood Demon Art hits for 3 (Bash). │ ┃ Inosuke: Beast Breathing hits for 3 (Bash). › /slayer │ ┃ Zenitsu: Thunderclap and Flash hits for 3 (… ⎿ slayer-corps: Slayer Corps is on. It comes back each session unt │ ┃ Tanjiro: Water Breathing hits for 3 (Write). ⎿ slayer-corps: Chapter 1: The Mountain Pass · the Hollow Woodcutt │ ⎿ slayer-corps: Snow fills the pass. A woodcutter who stopped bein │ ⎿ slayer-corps: Lifetime: 5 attacks, 0 demons defeated, 0 dawns. / │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Slayer Corps
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ Chapter 1: The Mountain Pass · the Hollow Woodcutter 20/40 Tanjiro: Sun Dance hits for 8 (a finished turn). Nezuko: Blood Demon Art hits for 3 (Bash). Inosuke: Beast Breathing hits for 3 (Bash). Zenitsu: Thunderclap and Flash hits for 3 (Bash). Tanjiro: Water Breathing hits for 3 (Write).
README

Slayer Corps

Tanjiro, Nezuko, Zenitsu, and Inosuke fight their way through the Long Night to Muzan, one demon at a time, while you and Claude work. The story picks up where it left off every time you start a session.

Slayer Corps preview

claude plugin marketplace add theonly1me/claude-code-mods
claude plugin install slayer-corps@claude-code-mods

Register the marketplace once. Run /reload-plugins in an open session after installing. Requires Claude Code 2.1.289 or later; no build step or runtime dependencies.

How to use

The fight is hidden until you ask for it.

  • /slayer shows the fight. It comes back in every new session until you run /slayer again to hide it.
  • /slayer on and /slayer off show or hide it directly.
  • /slayer stats prints the chapter, the demon's health, and your lifetime totals without showing or hiding anything.
  • /slayer-story tells the story so far and how the fight works.

The fight opens in a pane. In the fullscreen layout (start Claude Code with CLAUDE_CODE_NO_FLICKER=1) the pane docks beside the transcript. Otherwise it sits above the prompt. Under the scene is one line with the chapter and the demon's health, and in the docked pane a feed of what each slayer just did.

Only one game mod shows at a time. Showing another game (for example /dojo) hides this one, and /slayer brings it back. Closing the pane with its close mark also hides it for later sessions. A pane that opens by itself at session start needs a terminal at least 144 columns wide; /slayer opens it at any width. The scene needs 56 columns inside the pane. The desktop app shows the summary line.

How it plays

Every tool call Claude finishes is a strike, and the four slayers take turns so nobody sits out:

  • Reading and searching lean on Tanjiro's Water Breathing, edits on Inosuke's Beast Breathing, and commands on Zenitsu's Thunderclap and Flash. When the favourite just struck, whoever has rested longest steps in with their own form.
  • Subagents: the Flame Hashira runs in with a sweep of fire.
  • Each prompt you send: Nezuko's Blood Demon Art.
  • Each finished turn: Tanjiro's Sun Dance, the strongest form.
  • A failed or denied tool lets the demon strike back. The red pips under the Corps are their strength. If they run out, the Corps regroups and the demon recovers some health.

Between strikes the Corps never stands still. They spar with the demon in turns: Tanjiro shows his forms, Zenitsu dozes off and snaps awake into a lightning dash, Inosuke charges with both blades, Nezuko kicks, and everyone feints, dodges the demon's swipes, and shifts their stance. Sparring is for show. The demon blocks every sparring blow, and only real work lowers its health or moves the story. A real strike always cuts in ahead of sparring.

The story

Twelve chapters, from a mountain pass in the snow, through a lantern market, a drum house, a forest strung with thread, a night train, and the lantern quarter, to four Upper Moons and the Infinity Castle. Each demon lasts about one working session. While the fight is showing, a toast opens each chapter and closes it when the demon turns to ash. Beat Muzan and the sun rises over the Corps. Then a new night begins, and every demon comes back stronger. Work keeps moving the story even while the fight is hidden.

Fan content. The characters belong to Koyoharu Gotouge's Demon Slayer: Kimetsu no Yaiba. The demons and the story here are original.

See the marketplace README for configuration, privacy, updates, and removal.

Source 31 files
hooks/register.tsx 130 lines
1import type { EngineInterface, Register } from 'claude-code'
2
3import { configureStage, installStage, isStageShown } from './shared/pixel/stage'
4import { noop } from './shared/noop'
5import { FRAME_MS, MAX_COLUMNS, MIN_COLUMNS, STAGE_ROWS } from './sim/constants'
6import { createBattle } from './sim/battle'
7import type { StoryEvent } from './sim/types'
8import { CHAPTERS, chapterAt, maxHpFor } from './story/campaign'
9import { progressFromStore } from './story/progress'
10import { statusLine, storySoFar } from './story/telling'
11
12const PROGRESS_KEY = 'progress'
13const EVENT_POLL_MS = 500
14const STORY_TOAST_MS = 7000
15
16let battle = createBattle()
17let hasGreeted = false
18
19function toastFor(event: StoryEvent): string {
20  const progress = battle.progress()
21  if (event.kind === 'defeated') {
22    return `Chapter ${event.chapter + 1} complete. ${chapterAt(event.chapter).ending}`
23  }
24  if (event.kind === 'chapter') {
25    const chapter = chapterAt(event.chapter)
26    return `Chapter ${event.chapter + 1}: ${chapter.place}. ${chapter.opening}`
27  }
28  if (event.kind === 'dawn') {
29    return `Dawn. ${CHAPTERS[CHAPTERS.length - 1]?.ending ?? ''} The Corps rests. Night ${progress.cycle} will be harder.`
30  }
31  return 'The Corps regroups. Failed commands gave the demon time to recover.'
32}
33
34function greeting(): string {
35  const progress = battle.progress()
36  const chapter = chapterAt(progress.chapter)
37  return `Chapter ${progress.chapter + 1}: ${chapter.place}. ${chapter.demon} has ${progress.demonHp} of ${maxHpFor(progress)} left. ${chapter.opening}`
38}
39
40async function tellEvents($: EngineInterface): Promise<void> {
41  const isShown = isStageShown()
42  if (isShown && !hasGreeted) {
43    hasGreeted = true
44    $.ui.toast(greeting(), { timeoutMs: STORY_TOAST_MS })
45  }
46  const events = battle.takeEvents()
47  if (events.length === 0) {
48    return
49  }
50  if (isShown) {
51    events.forEach(event => $.ui.toast(toastFor(event), { timeoutMs: STORY_TOAST_MS }))
52  }
53  await $.store.set(PROGRESS_KEY, battle.progress())
54}
55
56function report(): string[] {
57  const progress = battle.progress()
58  return [
59    statusLine(progress),
60    chapterAt(progress.chapter).opening,
61    `Lifetime: ${progress.attacks} attacks, ${progress.defeated} demons defeated, ${progress.dawns} dawns. /slayer-story tells the story so far.`,
62  ]
63}
64
65export const register: Register = on => {
66  battle = createBattle()
67  hasGreeted = false
68  configureStage({
69    game: 'slayer-corps',
70    title: 'Slayer Corps',
71    command: { name: 'slayer', description: 'Show or hide the Slayer Corps fight (on, off, stats)', report },
72    rows: STAGE_ROWS,
73    minColumns: MIN_COLUMNS,
74    maxColumns: MAX_COLUMNS,
75    frameMs: FRAME_MS,
76    scene: {
77      begin: noop,
78      resize: columns => battle.resize(columns),
79      tick: dtMs => battle.tick(dtMs),
80      frame: () => battle.frame(),
81      summary: () => statusLine(battle.progress()),
82      log: () => battle.log(),
83    },
84  })
85  installStage(on)
86
87  on('session.start', async ($, e, next) => {
88    await $.command.register({
89      name: 'slayer-story',
90      description: 'Slayer Corps: the story so far and how the fight works',
91      immediate: true,
92    })
93    battle.restore(progressFromStore(await $.store.get(PROGRESS_KEY)))
94    $.clock.every(EVENT_POLL_MS, () => {
95      tellEvents($).catch(noop)
96    })
97    return next(e)
98  })
99
100  on('command.run', { command: 'slayer-story' }, async () => ({ text: storySoFar(battle.progress()) }))
101
102  on('tool.call', async ($, e, next) => {
103    const ran = await next(e)
104    if (e.agentId === undefined) {
105      battle.strike({ tool: String(e.tool), isFailure: ran.deny !== undefined || ran.isError === true })
106    }
107    return ran
108  })
109
110  on('prompt.submit', ($, e, next) => {
111    if (e.origin.kind === 'composer' && e.turnId === undefined) {
112      battle.summonNezuko()
113    }
114    return next(e)
115  })
116
117  on('turn.complete', async ($, e, next) => {
118    if (e.agentId === undefined && !e.isAborted) {
119      battle.finish()
120      await $.store.set(PROGRESS_KEY, battle.progress())
121    }
122    return next(e)
123  })
124
125  on('session.end', async ($, e, next) => {
126    await $.store.set(PROGRESS_KEY, battle.progress())
127    return next(e)
128  })
129}
130
hooks/shared/pixel/stage.tsx 199 lines
1import type { EngineInterface, On } from 'claude-code'
2
3import { noop } from '../noop'
4import type { Bitmap } from './bitmap'
5import { encodeCells } from './cells'
6
7export type StageScene = {
8  begin: (epochMs: number) => void
9  resize: (columns: number) => void
10  tick: (dtMs: number) => void
11  frame: () => Bitmap
12  summary: () => string
13  log: () => readonly string[]
14}
15
16export type StageOptions = {
17  game: string
18  title: string
19  command: { name: string; description: string; report: () => readonly string[] }
20  rows: number
21  minColumns: number
22  maxColumns: number
23  frameMs: number
24  scene: StageScene
25}
26
27const POLL_MS = 1000
28const PANE_COLUMNS = 62
29const ACTIVE_FILE = '.claude/mods/active-game'
30
31let stage: StageOptions | undefined
32let activePath = ''
33let isClockRunning = false
34let isPaneOpen = false
35let isRasterMounted = false
36let lastCells = ''
37let lastSummary = ''
38let sincePollMs = 0
39
40export function configureStage(options: StageOptions): void {
41  stage = options
42}
43
44export function isStageShown(): boolean {
45  return isPaneOpen
46}
47
48function wantsShow(options: { args: string; isActive: boolean }): boolean {
49  if (options.args === 'on') {
50    return true
51  }
52  return options.args === 'off' ? false : !options.isActive
53}
54
55async function readActive($: EngineInterface): Promise<string> {
56  if (activePath === '') {
57    return ''
58  }
59  return (await $.fs.read(activePath).catch(() => '')).trim()
60}
61
62export function installStage(on: On): void {
63  on('session.start', { isInteractive: true }, async ($, e, next) => {
64    const options = stage
65    if (!options) {
66      return next(e)
67    }
68    await $.command.register({ name: options.command.name, description: options.command.description, immediate: true })
69    activePath = `${(await $.env.get('HOME')) ?? '.'}/${ACTIVE_FILE}`
70    options.scene.begin(await $.clock.now())
71    isPaneOpen = (await $.ui.panes()).some(pane => pane.id === options.game)
72    const active = await readActive($)
73    if (active === options.game && !isPaneOpen) {
74      const opened = await $.ui.open({ id: options.game, title: options.title, rows: options.rows + 1, columns: PANE_COLUMNS })
75      isPaneOpen = true
76      if (!opened.isPlaced) {
77        $.ui.toast(`${options.title} waits for a wider terminal. Run /${options.command.name} to show it now.`)
78      }
79    }
80    if (isClockRunning) {
81      return next(e)
82    }
83    isClockRunning = true
84    $.clock.every(options.frameMs, () => {
85      options.scene.tick(options.frameMs)
86      sincePollMs += options.frameMs
87      if (sincePollMs >= POLL_MS) {
88        sincePollMs = 0
89        if (isPaneOpen && options.scene.summary() !== lastSummary) {
90          $.ui.invalidate('ui.render')
91        }
92        readActive($)
93          .then(current => (isPaneOpen && current !== options.game ? $.ui.close({ id: options.game }) : undefined))
94          .catch(noop)
95      }
96      if (!isPaneOpen || !isRasterMounted) {
97        return
98      }
99      const cells = encodeCells(options.scene.frame())
100      if (cells === lastCells) {
101        return
102      }
103      lastCells = cells
104      $.ui
105        .blit({ requestId: options.game, key: `${options.game}:stage`, cells })
106        .then(result => {
107          if (result.deny !== undefined) {
108            isRasterMounted = false
109          }
110        })
111        .catch(noop)
112    })
113    return next(e)
114  })
115
116  on('command.run', { command: stage?.command.name ?? '' }, async ($, e, next) => {
117    const options = stage
118    if (!options || e.command !== options.command.name) {
119      return next(e)
120    }
121    const args = e.args.trim().toLowerCase()
122    if (args === 'stats') {
123      return { text: options.command.report().join('\n') }
124    }
125    if (args !== '' && args !== 'on' && args !== 'off') {
126      return { text: [`Usage: /${options.command.name} [on|off|stats]`, ...options.command.report()].join('\n') }
127    }
128    const pane = (await $.ui.panes()).find(candidate => candidate.id === options.game)
129    const isActive = (await readActive($)) === options.game && pane?.isPlaced === true
130    if (wantsShow({ args, isActive })) {
131      await $.fs.write(activePath, options.game)
132      await $.ui.open({ id: options.game, title: options.title, rows: options.rows + 1, columns: PANE_COLUMNS })
133      isPaneOpen = true
134      return {
135        text: [`${options.title} is on. It comes back each session until you run /${options.command.name} again.`, ...options.command.report()].join('\n'),
136      }
137    }
138    await $.fs.write(activePath, '')
139    await $.ui.close({ id: options.game })
140    isPaneOpen = false
141    return { text: `${options.title} is off. Run /${options.command.name} to bring it back.` }
142  })
143
144  on('ui.close', async ($, e, next) => {
145    const options = stage
146    if (!options || e.id !== options.game) {
147      return next(e)
148    }
149    const closed = await next(e)
150    isPaneOpen = false
151    isRasterMounted = false
152    const active = await readActive($)
153    if (e.origin.kind === 'person' && active === options.game) {
154      await $.fs.write(activePath, '')
155    }
156    return closed
157  })
158
159  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
160    const options = stage
161    if (!options || e.requestId !== options.game) {
162      return next(e)
163    }
164    lastSummary = options.scene.summary()
165    const logRows = e.props.placement === 'dock' ? Math.max(0, e.props.scroll.bodyRows - options.rows - 3) : 0
166    const lines = options.scene.log().slice(0, logRows)
167    if (e.surface !== 'terminal' || e.props.bodyColumns < options.minColumns) {
168      const { Box, Text } = $.ui.resolve(e)
169      isRasterMounted = false
170      return (
171        <Box flexDirection="column">
172          <Text>{lastSummary}</Text>
173          {e.surface === 'terminal' ? <Text dimColor>Widen this pane to see the scene.</Text> : null}
174        </Box>
175      )
176    }
177    const { Box, Raster, Text } = $.ui.resolve(e)
178    const columns = Math.min(e.props.bodyColumns, options.maxColumns)
179    options.scene.resize(columns)
180    isRasterMounted = true
181    lastCells = encodeCells(options.scene.frame())
182    return (
183      <Box flexDirection="column">
184        <Raster key={`${options.game}:stage`} columns={columns} rows={options.rows} cells={lastCells} />
185        <Text dimColor wrap="truncate">{lastSummary}</Text>
186        {lines.length > 0 ? (
187          <Box flexDirection="column" marginTop={1}>
188            {lines.map((line, index) => (
189              <Text key={`log-${index}`} dimColor={index > 0} wrap="truncate">
190                {line}
191              </Text>
192            ))}
193          </Box>
194        ) : null}
195      </Box>
196    )
197  })
198}
199
hooks/shared/noop.ts 4 lines
1export function noop(): undefined {
2  return undefined
3}
4
hooks/sim/constants.ts 32 lines
1export const FRAME_MS = 40
2export const STAGE_ROWS = 8
3export const STAGE_HEIGHT = STAGE_ROWS * 2
4export const GROUND_TOP = 13
5export const MIN_COLUMNS = 56
6export const MAX_COLUMNS = 72
7
8export const SLAYER_WIDTH = 7
9export const SLAYER_HEIGHT = 12
10export const DEMON_WIDTH = 16
11export const DEMON_RIGHT_MARGIN = 2
12
13export const DASH_PIXELS_PER_SECOND = 260
14export const RETURN_PIXELS_PER_SECOND = 120
15export const STRIKE_MS = 360
16export const IMPACT_MS = 160
17export const STAGGER_MS = 420
18export const COUNTER_MS = 380
19export const FLASH_MS = 120
20
21export const MAX_QUEUE = 5
22export const MAX_HEARTS = 5
23export const REGROUP_HEAL = 0.1
24export const DYING_MS = 1600
25export const ARRIVING_MS = 1400
26export const DAWN_MS = 6000
27export const DOZE_MS = 1400
28export const HOP_MS = 560
29export const RECOIL_MS = 180
30export const AMBIENT_RETURN_PIXELS_PER_SECOND = 220
31export const LOG_LIMIT = 8
32
hooks/sim/battle.ts 193 lines
1import { clearBitmap, createBitmap } from '../shared/pixel/bitmap'
2import type { Bitmap } from '../shared/pixel/bitmap'
3import { drawBattle } from '../draw/battleView'
4import { createActors, interruptSparring, isAtRest, isBusy, isWorking, laneFor, launch, placeFormation, stagger } from './actors'
5import {
6  COUNTER_MS, DEMON_RIGHT_MARGIN, DEMON_WIDTH, GROUND_TOP, LOG_LIMIT, MAX_COLUMNS, MAX_QUEUE, RECOIL_MS, SLAYER_WIDTH,
7  STAGE_HEIGHT,
8} from './constants'
9import { createDirector } from './director'
10import { createLog } from './log'
11import { advanceActor } from './motion'
12import { NEZUKO_KICK, SUN_DANCE, isCorpsMember, rememberAttacker, workAttack } from './moves'
13import type { CorpsMember } from './moves'
14import { counterLine, sparringLine } from './narration'
15import { advanceParticles, ashBurst, attackBurst, clawBurst, parryBurst } from './particles'
16import { createProgression } from './progression'
17import type { Actor, Attack, Particle } from './types'
18
19export function createBattle() {
20  let width = MAX_COLUMNS
21  let bitmap = createBitmap({ width, height: STAGE_HEIGHT })
22  const log = createLog(LOG_LIMIT)
23  const story = createProgression(log)
24  const actors = createActors(width)
25  const director = createDirector()
26  let recent: CorpsMember[] = []
27  let queue: Attack[] = []
28  let counters = 0
29  let counterMs = 0
30  let swipeMs = 0
31  let recoilMs = 0
32  let particles: Particle[] = []
33  let clockMs = 0
34
35  const demonX = (): number => width - DEMON_WIDTH - DEMON_RIGHT_MARGIN
36
37  function damage(attack: Attack): void {
38    if (!story.damage(attack)) {
39      return
40    }
41    queue = []
42    counters = 0
43    interruptSparring(actors)
44    particles.push(...ashBurst({ x: demonX(), y: 1, width: DEMON_WIDTH, height: 12, seed: story.progress().chapter }))
45  }
46
47  function enqueue(attack: Attack): void {
48    interruptSparring(actors)
49    if (queue.length >= MAX_QUEUE) {
50      damage(attack)
51      return
52    }
53    queue.push(attack)
54  }
55
56  function crossesMidpoint(options: { remainingMs: number; dtMs: number }): boolean {
57    return options.remainingMs > COUNTER_MS / 2 && options.remainingMs - options.dtMs <= COUNTER_MS / 2
58  }
59
60  function counterHit(): void {
61    const target = actors.find(candidate => candidate.mode === 'home' && candidate.name !== 'hashira')
62    if (target) {
63      stagger(target)
64      particles.push(...clawBurst({ x: target.x + 1, y: GROUND_TOP - 10 }))
65      log.add(counterLine({ demon: story.chapter().demon, target: target.name }))
66    }
67    story.loseHeart()
68  }
69
70  function advanceSwipe(dtMs: number): void {
71    const dodger = actors.find(candidate => candidate.mode === 'hop')
72    if (swipeMs > 0 && dodger && crossesMidpoint({ remainingMs: swipeMs, dtMs })) {
73      particles.push(...clawBurst({ x: dodger.homeX + 1, y: GROUND_TOP - 10 }))
74    }
75    swipeMs = Math.max(0, swipeMs - dtMs)
76  }
77
78  function startSparring(): void {
79    const attack = director.plan()
80    const attacker = actors.find(candidate => candidate.name === attack.attacker)
81    if (!attacker || attacker.mode !== 'home') {
82      return
83    }
84    launch({ actor: attacker, attack })
85    swipeMs = attack.style === 'dodge' ? COUNTER_MS : 0
86    log.add(sparringLine({ attack, demon: story.chapter().demon }))
87  }
88
89  function advanceFight(dtMs: number): void {
90    if (counterMs > 0) {
91      const isHitNow = crossesMidpoint({ remainingMs: counterMs, dtMs })
92      counterMs -= dtMs
93      if (isHitNow) {
94        counterHit()
95      }
96      return
97    }
98    if (actors.some(isWorking)) {
99      return
100    }
101    if (counters > 0) {
102      counters -= 1
103      counterMs = COUNTER_MS
104      return
105    }
106    const [attack] = queue
107    if (attack) {
108      const attacker = actors.find(candidate => candidate.name === attack.attacker)
109      if (attacker && isAtRest(attacker)) {
110        queue.shift()
111        launch({ actor: attacker, attack })
112      }
113      return
114    }
115    if (!actors.some(isBusy) && swipeMs <= 0 && director.wait(dtMs)) {
116      startSparring()
117    }
118  }
119
120  function impact(options: { actor: Actor; attack: Attack }): void {
121    const { actor, attack } = options
122    if (attack.style === 'hit') {
123      particles.push(...attackBurst({ kind: attack.kind, x: demonX() + 4, y: GROUND_TOP - 7, seed: story.progress().attacks }))
124      damage(attack)
125    } else if (attack.style === 'clash' || attack.style === 'doze') {
126      particles.push(...parryBurst({ x: demonX() + 1, y: GROUND_TOP - 8, seed: clockMs }))
127      recoilMs = RECOIL_MS
128    } else {
129      particles.push(...attackBurst({ kind: attack.kind, x: actor.x + SLAYER_WIDTH + 2, y: GROUND_TOP - 7, seed: clockMs }))
130    }
131  }
132
133  return {
134    resize(columns: number): void {
135      if (columns !== width) {
136        width = columns
137        bitmap = createBitmap({ width, height: STAGE_HEIGHT })
138        placeFormation({ actors, width })
139      }
140    },
141    restore: story.restore,
142    progress: story.progress,
143    hearts: story.hearts,
144    phase: story.phase,
145    takeEvents: story.takeEvents,
146    log: log.lines,
147    actors: (): readonly Actor[] => actors,
148    strike(options: { tool: string; isFailure: boolean }): void {
149      interruptSparring(actors)
150      if (options.isFailure) {
151        counters = Math.min(MAX_QUEUE, counters + 1)
152        return
153      }
154      const attack = workAttack({ tool: options.tool, recent })
155      if (isCorpsMember(attack.attacker)) {
156        recent = rememberAttacker({ recent, attacker: attack.attacker })
157      }
158      enqueue(attack)
159    },
160    summonNezuko(): void {
161      recent = rememberAttacker({ recent, attacker: 'nezuko' })
162      enqueue(NEZUKO_KICK)
163    },
164    finish(): void {
165      story.gainHeart()
166      enqueue(SUN_DANCE)
167    },
168    tick(dtMs: number): void {
169      clockMs += dtMs
170      recoilMs = Math.max(0, recoilMs - dtMs)
171      particles = advanceParticles({ particles, dtMs })
172      story.advance(dtMs)
173      if (story.phase() === 'fighting') {
174        advanceSwipe(dtMs)
175        advanceFight(dtMs)
176      }
177      actors.forEach(actor => {
178        const step = advanceActor({ actor, dtMs, frontX: demonX() - SLAYER_WIDTH - 1, laneX: laneFor(width) })
179        if (step.didImpact && actor.attack) {
180          impact({ actor, attack: actor.attack })
181        }
182      })
183    },
184    frame(): Bitmap {
185      clearBitmap(bitmap)
186      drawBattle({ bitmap, story, actors, particles, clockMs, demonX: demonX(), lungeMs: Math.max(counterMs, swipeMs), isRecoiling: recoilMs > 0 })
187      return bitmap
188    },
189  }
190}
191
192export type Battle = ReturnType<typeof createBattle>
193
hooks/sim/types.ts 55 lines
1export type SlayerName = 'tanjiro' | 'nezuko' | 'zenitsu' | 'inosuke' | 'hashira'
2
3export type AttackKind = 'water' | 'flame' | 'thunder' | 'beast' | 'blaze' | 'sun'
4
5export type MoveStyle = 'hit' | 'clash' | 'feint' | 'form' | 'dodge' | 'doze'
6
7export type Attack = { attacker: SlayerName; kind: AttackKind; damage: number; style: MoveStyle; source: string }
8
9export type ActorMode = 'home' | 'dash' | 'strike' | 'return' | 'stagger' | 'offstage' | 'hop' | 'doze'
10
11export type Actor = {
12  name: SlayerName
13  homeX: number
14  x: number
15  lift: number
16  mode: ActorMode
17  modeMs: number
18  attack: Attack | undefined
19  hasStruck: boolean
20}
21
22export type DemonShape = 'brute' | 'lantern' | 'spider' | 'lanky' | 'vase' | 'moon' | 'muzan'
23
24export type Backdrop = 'snow' | 'market' | 'hall' | 'forest' | 'train' | 'town' | 'castle'
25
26export type ParticleKind = 'water' | 'ember' | 'spark' | 'ash' | 'blade' | 'snow' | 'claw' | 'parry'
27
28export type Particle = {
29  kind: ParticleKind
30  x: number
31  y: number
32  velocityX: number
33  velocityY: number
34  ageMs: number
35  lifeMs: number
36  color: number
37}
38
39export type StoryEvent =
40  | { kind: 'defeated'; chapter: number }
41  | { kind: 'chapter'; chapter: number }
42  | { kind: 'dawn' }
43  | { kind: 'regroup' }
44
45export type Progress = {
46  cycle: number
47  chapter: number
48  demonHp: number
49  attacks: number
50  defeated: number
51  dawns: number
52}
53
54export type BattlePhase = 'fighting' | 'dying' | 'arriving' | 'dawn'
55
hooks/story/campaign.ts 146 lines
1import type { Backdrop, DemonShape } from '../sim/types'
2
3export type DemonLook = { body: number; shade: number; accent: number; eye: number }
4
5export type Chapter = {
6  place: string
7  demon: string
8  shape: DemonShape
9  look: DemonLook
10  backdrop: Backdrop
11  hp: number
12  opening: string
13  ending: string
14}
15
16export const CHAPTERS: readonly [Chapter, ...Chapter[]] = [
17  {
18    place: 'The Mountain Pass',
19    demon: 'the Hollow Woodcutter',
20    shape: 'brute',
21    look: { body: 0x6b5b4e, shade: 0x40352c, accent: 0xc9c2b8, eye: 0xff3b3b },
22    backdrop: 'snow',
23    hp: 40,
24    opening: 'Snow fills the pass. A woodcutter who stopped being human waits at the treeline.',
25    ending: 'The axe falls into the snow, and the woodcutter turns to ash with it.',
26  },
27  {
28    place: 'The Lantern Market',
29    demon: 'the Paper Lantern Demon',
30    shape: 'lantern',
31    look: { body: 0xd62828, shade: 0x8a1c1c, accent: 0xffb703, eye: 0xfff3b0 },
32    backdrop: 'market',
33    hp: 55,
34    opening: 'In the night market, one lantern has teeth.',
35    ending: 'The lantern gutters out. The market lights stay on.',
36  },
37  {
38    place: 'The Drum House',
39    demon: 'the Taiko Ghoul',
40    shape: 'brute',
41    look: { body: 0x7b4fa0, shade: 0x4a2c66, accent: 0xe9c46a, eye: 0xffd166 },
42    backdrop: 'hall',
43    hp: 70,
44    opening: 'Every drumbeat turns the house. The rooms will not stay still.',
45    ending: 'The last drum splits. The rooms settle back into place.',
46  },
47  {
48    place: 'Thread Wood',
49    demon: 'the Thread Weaver',
50    shape: 'spider',
51    look: { body: 0xe9e4f0, shade: 0x9d94ad, accent: 0xff4d6d, eye: 0xff4d6d },
52    backdrop: 'forest',
53    hp: 85,
54    opening: 'The forest is strung with thread, and something pulls it from the dark.',
55    ending: 'The threads go slack. The forest breathes again.',
56  },
57  {
58    place: 'The Night Train',
59    demon: 'the Sleep Conductor',
60    shape: 'lanky',
61    look: { body: 0x2b4170, shade: 0x18264a, accent: 0xb8c0ff, eye: 0x9bf6ff },
62    backdrop: 'train',
63    hp: 100,
64    opening: 'The night train runs on dreams. Wake up, or ride forever.',
65    ending: 'The conductor punches his last ticket. Everyone on the train wakes up.',
66  },
67  {
68    place: 'The Lantern Quarter',
69    demon: 'the Sash Dancer',
70    shape: 'lanky',
71    look: { body: 0xf28482, shade: 0xb5485b, accent: 0xffd6e0, eye: 0xfff3b0 },
72    backdrop: 'town',
73    hp: 120,
74    opening: 'Silk sashes slip through the lantern quarter and cut like blades.',
75    ending: 'The sashes drift down, soft and harmless, like any silk.',
76  },
77  {
78    place: 'The Hot Spring Village',
79    demon: 'the Vase Hoarder',
80    shape: 'vase',
81    look: { body: 0x4cc9f0, shade: 0x277da1, accent: 0xf8f9fa, eye: 0xffd60a },
82    backdrop: 'forest',
83    hp: 140,
84    opening: 'Steam rises over the swordsmiths. A collector wants their blades for his vases.',
85    ending: 'The vases crack one by one. The swordsmiths go back to their forges.',
86  },
87  {
88    place: 'Upper Moon Six',
89    demon: 'the Twin Sickles',
90    shape: 'moon',
91    look: { body: 0x2d6a4f, shade: 0x1b4332, accent: 0xb7e4c7, eye: 0xffd60a },
92    backdrop: 'town',
93    hp: 160,
94    opening: 'The first Upper Moon steps out of the alley, a sickle in each hand.',
95    ending: 'Both sickles fall. Upper Moon Six is gone.',
96  },
97  {
98    place: 'Upper Moon Four',
99    demon: 'the Mirror Monk',
100    shape: 'moon',
101    look: { body: 0xe9c46a, shade: 0xa47e1b, accent: 0xffffff, eye: 0xd62828 },
102    backdrop: 'hall',
103    hp: 180,
104    opening: 'A monk with a mirror shows each slayer their worst night. They keep fighting.',
105    ending: 'The mirror shatters, and every reflection with it.',
106  },
107  {
108    place: 'Upper Moon Three',
109    demon: 'the Frost Lotus',
110    shape: 'moon',
111    look: { body: 0xbde0fe, shade: 0x6c8ebf, accent: 0xffffff, eye: 0x3a86ff },
112    backdrop: 'snow',
113    hp: 200,
114    opening: 'Ice lotuses bloom wherever the Frost Lotus steps. The cold is the weapon.',
115    ending: 'The lotuses melt. The snow is only snow again.',
116  },
117  {
118    place: 'Upper Moon One',
119    demon: 'the Crescent Ronin',
120    shape: 'moon',
121    look: { body: 0x5a189a, shade: 0x3c096c, accent: 0xe0aaff, eye: 0xff006e },
122    backdrop: 'castle',
123    hp: 230,
124    opening: 'Six eyes open in the Infinity Castle. The strongest Moon draws a crescent blade.',
125    ending: 'The crescent blade breaks. Only one demon is left.',
126  },
127  {
128    place: 'The Infinity Castle',
129    demon: 'Muzan Kibutsuji',
130    shape: 'muzan',
131    look: { body: 0x111111, shade: 0x2b2b2b, accent: 0xeae2e2, eye: 0xff0033 },
132    backdrop: 'castle',
133    hp: 300,
134    opening: 'The castle folds around the Corps. At its heart, Muzan waits for a sun that never rises.',
135    ending: 'The sun rises over the castle. Muzan is gone.',
136  },
137]
138
139export function chapterAt(index: number): Chapter {
140  return CHAPTERS[Math.min(CHAPTERS.length - 1, Math.max(0, index))] ?? CHAPTERS[0]
141}
142
143export function maxHpFor(options: { chapter: number; cycle: number }): number {
144  return Math.round(chapterAt(options.chapter).hp * (1 + (options.cycle - 1) * 0.5))
145}
146
hooks/story/progress.ts 49 lines
1import type { Progress } from '../sim/types'
2import { CHAPTERS, maxHpFor } from './campaign'
3
4export function freshProgress(): Progress {
5  return { cycle: 1, chapter: 0, demonHp: maxHpFor({ chapter: 0, cycle: 1 }), attacks: 0, defeated: 0, dawns: 0 }
6}
7
8function numberIn(options: { value: unknown; minimum: number; fallback: number }): number {
9  return typeof options.value === 'number' && Number.isFinite(options.value) && options.value >= options.minimum
10    ? Math.floor(options.value)
11    : options.fallback
12}
13
14export function progressFromStore(value: unknown): Progress {
15  const fresh = freshProgress()
16  if (typeof value !== 'object' || value === null) {
17    return fresh
18  }
19  const read = (key: string): unknown => (key in value ? Object.getOwnPropertyDescriptor(value, key)?.value : undefined)
20  const cycle = numberIn({ value: read('cycle'), minimum: 1, fallback: 1 })
21  const chapter = Math.min(CHAPTERS.length - 1, numberIn({ value: read('chapter'), minimum: 0, fallback: 0 }))
22  const maxHp = maxHpFor({ chapter, cycle })
23  return {
24    cycle,
25    chapter,
26    demonHp: Math.min(maxHp, numberIn({ value: read('demonHp'), minimum: 1, fallback: maxHp })),
27    attacks: numberIn({ value: read('attacks'), minimum: 0, fallback: 0 }),
28    defeated: numberIn({ value: read('defeated'), minimum: 0, fallback: 0 }),
29    dawns: numberIn({ value: read('dawns'), minimum: 0, fallback: 0 }),
30  }
31}
32
33export function nextChapter(progress: Progress): { progress: Progress; isDawn: boolean } {
34  const isDawn = progress.chapter >= CHAPTERS.length - 1
35  const cycle = isDawn ? progress.cycle + 1 : progress.cycle
36  const chapter = isDawn ? 0 : progress.chapter + 1
37  return {
38    isDawn,
39    progress: {
40      ...progress,
41      cycle,
42      chapter,
43      demonHp: maxHpFor({ chapter, cycle }),
44      defeated: progress.defeated + 1,
45      dawns: progress.dawns + (isDawn ? 1 : 0),
46    },
47  }
48}
49
hooks/story/telling.ts 26 lines
1import { FORM_NAMES } from '../sim/moves'
2import type { Progress } from '../sim/types'
3import { CHAPTERS, chapterAt, maxHpFor } from './campaign'
4
5export function statusLine(progress: Progress): string {
6  const chapter = chapterAt(progress.chapter)
7  const night = progress.cycle > 1 ? ` · night ${progress.cycle}` : ''
8  return `Chapter ${progress.chapter + 1}: ${chapter.place} · ${chapter.demon} ${progress.demonHp}/${maxHpFor(progress)}${night}`
9}
10
11export function storySoFar(progress: Progress): string {
12  const finished = CHAPTERS.slice(0, progress.chapter).map(
13    (chapter, index) => `${index + 1}. ${chapter.place}: ${chapter.opening} ${chapter.ending}`,
14  )
15  const current = chapterAt(progress.chapter)
16  const lines = [
17    progress.cycle > 1 ? `Night ${progress.cycle}. The demons came back stronger after dawn ${progress.cycle - 1}.` : 'The Long Night.',
18    ...finished,
19    `${progress.chapter + 1}. ${current.place} (now): ${current.opening} ${current.demon} has ${progress.demonHp} of ${maxHpFor(progress)} left.`,
20    '',
21    `How the fight works: every tool call Claude finishes is a strike, and the four slayers take turns. Reading leans on Tanjiro's ${FORM_NAMES.water}, edits on Inosuke's ${FORM_NAMES.beast}, commands on Zenitsu's ${FORM_NAMES.thunder}, but whoever struck least recently steps in when the favourite just went. Agents call the Flame Hashira. Each of your prompts brings Nezuko's ${FORM_NAMES.flame}, and a finished turn lands Tanjiro's ${FORM_NAMES.sun}. Failed tools let the demon strike back. Between strikes the Corps spars with the demon, but only real work wears it down.`,
22    `Lifetime: ${progress.attacks} attacks, ${progress.defeated} demons defeated, ${progress.dawns} dawns.`,
23  ]
24  return lines.join('\n')
25}
26
hooks/shared/pixel/bitmap.ts 83 lines
1export type Color = number
2
3export type Bitmap = {
4  width: number
5  height: number
6  pixels: (Color | null)[]
7}
8
9export type Tint = (color: Color) => Color
10
11export function createBitmap(options: { width: number; height: number }): Bitmap {
12  const { width, height } = options
13  return { width, height, pixels: Array.from({ length: width * height }, () => null) }
14}
15
16export function setPixel(options: {
17  bitmap: Bitmap
18  x: number
19  y: number
20  color: Color
21}): void {
22  const { bitmap, color } = options
23  const x = Math.round(options.x)
24  const y = Math.round(options.y)
25  if (x < 0 || y < 0 || x >= bitmap.width || y >= bitmap.height) {
26    return
27  }
28  bitmap.pixels[y * bitmap.width + x] = color
29}
30
31export function getPixel(options: { bitmap: Bitmap; x: number; y: number }): Color | null {
32  const { bitmap, x, y } = options
33  if (x < 0 || y < 0 || x >= bitmap.width || y >= bitmap.height) {
34    return null
35  }
36  return bitmap.pixels[y * bitmap.width + x] ?? null
37}
38
39export function fillRect(options: {
40  bitmap: Bitmap
41  x: number
42  y: number
43  width: number
44  height: number
45  color: Color
46}): void {
47  const { bitmap, color } = options
48  for (let row = 0; row < options.height; row += 1) {
49    for (let column = 0; column < options.width; column += 1) {
50      setPixel({ bitmap, x: options.x + column, y: options.y + row, color })
51    }
52  }
53}
54
55export function stamp(options: {
56  target: Bitmap
57  source: Bitmap
58  x: number
59  y: number
60  isFlipped?: boolean
61  tint?: Tint
62}): void {
63  const { target, source, tint } = options
64  source.pixels.forEach((color, index) => {
65    if (color === null) {
66      return
67    }
68    const column = index % source.width
69    const row = Math.floor(index / source.width)
70    const offset = options.isFlipped ? source.width - 1 - column : column
71    setPixel({
72      bitmap: target,
73      x: options.x + offset,
74      y: options.y + row,
75      color: tint ? tint(color) : color,
76    })
77  })
78}
79
80export function clearBitmap(bitmap: Bitmap): void {
81  bitmap.pixels.fill(null)
82}
83
hooks/shared/pixel/cells.ts 44 lines
1import { getPixel } from './bitmap'
2import type { Bitmap } from './bitmap'
3
4const TERMINAL_DEFAULT = 0x01000000
5const SPACE = 0x20
6const UPPER_HALF_BLOCK = 0x2580
7const LOWER_HALF_BLOCK = 0x2584
8
9export function cellRowsOf(bitmap: Bitmap): number {
10  return Math.floor(bitmap.height / 2)
11}
12
13export function toCellWords(bitmap: Bitmap): Uint32Array {
14  const rows = cellRowsOf(bitmap)
15  const words = new Uint32Array(bitmap.width * rows * 3)
16  for (let row = 0; row < rows; row += 1) {
17    for (let column = 0; column < bitmap.width; column += 1) {
18      const upper = getPixel({ bitmap, x: column, y: row * 2 })
19      const lower = getPixel({ bitmap, x: column, y: row * 2 + 1 })
20      const offset = (row * bitmap.width + column) * 3
21      if (upper === null && lower === null) {
22        words.set([SPACE, TERMINAL_DEFAULT, TERMINAL_DEFAULT], offset)
23      } else if (upper === null && lower !== null) {
24        words.set([LOWER_HALF_BLOCK, lower, TERMINAL_DEFAULT], offset)
25      } else if (upper !== null) {
26        words.set([UPPER_HALF_BLOCK, upper, lower ?? TERMINAL_DEFAULT], offset)
27      }
28    }
29  }
30  return words
31}
32
33function toBinaryString(bytes: Uint8Array): string {
34  let binary = ''
35  bytes.forEach(byte => {
36    binary += String.fromCharCode(byte)
37  })
38  return binary
39}
40
41export function encodeCells(bitmap: Bitmap): string {
42  return btoa(toBinaryString(new Uint8Array(toCellWords(bitmap).buffer)))
43}
44
hooks/draw/battleView.ts 44 lines
1import type { Bitmap } from '../shared/pixel/bitmap'
2import { ARRIVING_MS, COUNTER_MS, DAWN_MS, DYING_MS } from '../sim/constants'
3import type { Progression } from '../sim/progression'
4import type { Actor, Particle } from '../sim/types'
5import { drawScene } from './scene'
6
7const RECOIL_PIXELS = 2
8
9export function drawBattle(options: {
10  bitmap: Bitmap
11  story: Progression
12  actors: readonly Actor[]
13  particles: readonly Particle[]
14  clockMs: number
15  demonX: number
16  lungeMs: number
17  isRecoiling: boolean
18}): void {
19  const { story } = options
20  const chapter = story.chapter()
21  const phase = story.phase()
22  const phaseMs = story.phaseMs()
23  const lunge = options.lungeMs > 0 ? Math.round(Math.sin(((COUNTER_MS - options.lungeMs) / COUNTER_MS) * Math.PI) * -10) : 0
24  const arrival = phase === 'arriving' ? Math.round((1 - phaseMs / ARRIVING_MS) * 24) : 0
25  drawScene({
26    bitmap: options.bitmap,
27    clockMs: options.clockMs,
28    chapter,
29    cycle: story.progress().cycle,
30    phase,
31    dawn: phase === 'dawn' ? Math.min(1, phaseMs / 1500, (DAWN_MS - phaseMs) / 1500 + 0.3) : 0,
32    dissolve: phase === 'dying' ? Math.min(1, phaseMs / (DYING_MS * 0.7)) : 0,
33    actors: options.actors,
34    particles: options.particles,
35    demonX: options.demonX + lunge + arrival + (options.isRecoiling ? RECOIL_PIXELS : 0),
36    isDemonFlashing: story.isFlashing(),
37    isTrueForm: chapter.shape === 'muzan' && story.progress().demonHp < story.maxHp() / 2,
38    hearts: story.hearts(),
39    hp: story.progress().demonHp,
40    shownHp: story.shownHp(),
41    maxHp: story.maxHp(),
42  })
43}
44