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.

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.

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.
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.
Every tool call Claude finishes is a strike, and the four slayers take turns so nobody sits out:
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.
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.
hooks/register.tsx 130 lines1import 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}
130hooks/shared/pixel/stage.tsx 199 lines1import 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}
199hooks/shared/noop.ts 4 lines1export function noop(): undefined {
2 return undefined
3}
4hooks/sim/constants.ts 32 lines1export 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
32hooks/sim/battle.ts 193 lines1import { 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>
193hooks/sim/types.ts 55 lines1export 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'
55hooks/story/campaign.ts 146 lines1import 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}
146hooks/story/progress.ts 49 lines1import 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}
49hooks/story/telling.ts 26 lines1import { 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}
26hooks/shared/pixel/bitmap.ts 83 lines1export 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}
83hooks/shared/pixel/cells.ts 44 lines1import { 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}
44hooks/draw/battleView.ts 44 lines1import 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