Claude Code, played as an adventure: generative music that follows the agent's mood (/miod), a pixel knight who fights a monster for every edit and command…

<img src="docs/codyssey.png" alt="codyssey logo: an amber shield with a sword whose crossguard is a pair of code brackets, a scroll and a music note" width="200">
<h1 align="center">codyssey</h1>
[!IMPORTANT] miod's music only works on macOS.
I'm an engineer, but these days I solve the boring problems entirely by vibe coding. Somewhere along the way I felt I had lost the creativity and curiosity I used to have when writing code myself.
So I started thinking about how to make vibe coding fun again. codyssey turns a Claude Code session into a little adventure: while the agent does the work, you hear it as music, watch a pixel knight fight through it, and read it back as a quest log.
It's three mods in one plugin, and each one turns on and off on its own.
Three Claude Code mods in one plugin.
Generative music written on the spot while Claude works. No music files, no library, no AI model.

A pixel knight walks a narrow strip above the prompt while Claude works.

What you can meet on the road:
| When Claude… | The knight meets | What it brings |
|---|---|---|
| Reads or searches files (Read, Grep, Glob) | A scroll | Heals 1 heart |
| Edits or writes a file | A Slime, Goblin, Skeleton, Orc or Spider (the file name picks which) | 1 xp and 1 to 3 gold, plus 1 xp and 1 gold for each extra heart |
| Searches or fetches the web | A Bat or Ghost | Same as any monster |
| Runs a command | A Goblin or Wolf | Same as any monster |
| Hands work to a subagent | A Skeleton | Same as any monster |
| Calls an MCP tool | A Slime | Same as any monster |
| Runs a command that fails | A hit | Loses 1 heart |
| Runs a passing test or check after a failure | The Bug Dragon, 4 hearts | 2 xp and 5 gold or more per heart |
| Commits, pushes, merges, tags, or opens or merges a PR | A treasure chest | 5 to 24 gold |
| Has been playing for 2, 4, 6, 8, then 10 minutes | King Slime (3 hearts), Ogre (4), Lich (5), Demon (6), Shadow King (7) | 2 xp and 5 gold or more per heart |
The knight picks a weapon at random for every strike:
| Weapon | Range | Power | How often | How it hits |
|---|---|---|---|---|
| ⚔️ Sword | Close | 1 heart, or 2 with a special | Often | A slash. Now and then it becomes a glowing yellow special |
| 🪓 Axe | Close | 2 hearts | Sometimes | A heavy chop |
| 🔱 Lance | A little further | 1 heart | Sometimes | Reaches the monster a few steps early |
| 🗡️ Dagger | Far | 1 heart | Sometimes | Thrown early, flies to the monster |
| 🔥 Fireball | Furthest | 2 hearts | Rarely | Cast from far off, flies to the monster |
Thrown weapons only open a fight. Once a monster has been hit and is coming in close, the knight switches to the sword, axe or lance. Each weapon is one file in hooks/knightod/game/weapons/, with its range, power and how often it's picked.
How the game works:
| Mechanism | How it works |
|---|---|
| ❤️ Hearts | The knight starts with 5. Every monster and boss shows one heart and the number it has left beside it, like ♥3. Past 5 hearts, the knight's line shows the count too, like ♥7/12. |
| 📈 Monsters level up | Monsters start with 1 heart and gain one every 3 knight levels. Bosses gain one every 2 knight levels, on top of their own. |
| ⭐ Levels | Each level needs 8 more xp than the last. A new level adds a heart and heals the knight fully. |
| 👹 Boss fights | A boss swings back and can wound the knight, but never lands the last blow. Some of the knight's strikes are specials that hit for 2. |
| 💀 Falling | Losing the last heart to a failed command brings the knight back with full hearts and half the gold. |
| ⚡ Speed | The knight walks faster the quicker Claude spends tokens: normal, then double from 30k tokens a minute, then triple from 80k. |
| 📜 Progress | Level, hearts, kills, gold, quests, play time, bosses slain and falls add up from task to task, for the rest of the session. A new session starts a new knight. Between tasks the knight sits by a campfire. |
| ⏸️ Pausing | If Claude ends its turn while a background command or subagent is still running, the knight shows ⏸ paused and stays on the road. When the last one finishes, the knight makes camp. |
Restyles the transcript so the story is easier to follow.

Needs Claude Code 2.1.290 or newer.
claude plugin marketplace add delexw/codyssey
claude plugin install codyssey@codyssey
Then start a new session. All three mods start on.
If you loaded miod, knightod or scrollod on their own before (from delexw/miod, or a folder in CLAUDE_CODE_PLUGIN_DIRS), remove those first, or their commands are registered twice.
All three come in the one plugin, and each turns on and off with its own command:
/miod off, /knightod off or /scrollod off, and on to bring it back. The choice is remembered in later sessions./scrollod style plain; it stays plain in later sessions until you pick /scrollod style game./miod is the music on or off?
/miod off stop the music
/miod on play again from the next task
/miod wave list the wave looks: bars, line, mirror, dots, pulse
/miod wave dots switch the wave to the dots look
/knightod this session's game: level, hearts, kills, gold, bosses
/knightod off put the knight away
/knightod on send the knight out again
/knightod reset start over with a new knight
/scrollod on or off, and which style
/scrollod off show the transcript as Claude Code draws it
/scrollod on restyle it again
/scrollod style plain the quieter plain look
/scrollod style game back to badges, nameplates and quest rows
Each mod lives in its own folder under hooks/, and hooks/register.tsx turns all three on.
hooks/miod/moods.ts, each flavour one row in hooks/miod/flavours.ts, each sound style one entry in hooks/miod/styles.ts, and each wave look one file in hooks/miod/waves/.hooks/knightod/game/weapons/; monsters and bosses are sprites in hooks/knightod/sprites/; which tool raises which encounter is in hooks/knightod/game/cues.ts, and the boss order and timing in hooks/knightod/game/bosses.ts.hooks/scrollod/badge.ts, and the styles in hooks/scrollod/style.ts.Then check it:
claude plugin validate .
claude plugin test .
Run a checkout without installing it with claude --plugin-dir <path to this repo>.
afplay.hooks/register.tsx 12 lines1import type { Register } from 'claude-code'
2
3import { registerKnightod } from './knightod/register'
4import { registerMiod } from './miod/register'
5import { registerScrollod } from './scrollod/register'
6
7export const register: Register = (on, options) => {
8 registerKnightod(on, options)
9 registerMiod(on, options)
10 registerScrollod(on, options)
11}
12hooks/knightod/register.tsx 209 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import { drawBand, STRIP_KEY } from './band'
5import { BOSS_TIERS, dueBoss, withBoss } from './game/bosses'
6import type { Cue } from './game/cues'
7import { cueForCommand, cueForTool, isTestCommand } from './game/cues'
8import { hashText } from './game/random'
9import { finishQuest, NEW_SAVE, playMinutes } from './game/save'
10import { overflowIds, restScene, startScene, unsettledIdsExceptBosses, withCue, withoutUnfoughtBosses } from './game/scene'
11import { settledIdsOf, settleEvents, woundEvents, woundsOf } from './game/settle'
12import { speedFromRate } from './game/speed'
13import type { Spend } from '../shared/tokenRate'
14import { RATE_WINDOW_MS, tokensPerMinute } from '../shared/tokenRate'
15import { hearts } from './game/label'
16import { levelledCue } from './game/level'
17
18const STORE_KEY = 'knightod.isEnabled'
19const isEnabled = atom({ plugin: 'codyssey', key: 'knightod.isEnabled' } as const, true)
20const save = atom({ plugin: 'codyssey', key: 'knightod.save' } as const, NEW_SAVE)
21const scene = atom({ plugin: 'codyssey', key: 'knightod.scene' } as const, null)
22
23const CAMP_MS = 12_000
24
25type Session = { turnId: string | null; spends: Spend[]; wasFailing: boolean; campTimer: Timer | null; backgroundTasks: number }
26
27const session: Session = { turnId: null, spends: [], wasFailing: false, campTimer: null, backgroundTasks: 0 }
28
29async function startQuest($: EngineInterface, prompt: string, turnId: string) {
30 session.campTimer?.cancel()
31 session.campTimer = null
32 session.turnId = turnId
33 session.spends = []
34 session.wasFailing = false
35 const now = await $.clock.now()
36 await update($, scene, previous => startScene(hashText(`${now}:${prompt}`), previous, now))
37}
38
39async function settle($: EngineInterface, ids: readonly number[]) {
40 const current = await read($, scene)
41 if (current === null || ids.length === 0) return
42 const settled = settleEvents(await read($, save), current, ids)
43 await update($, save, () => settled.save)
44 await update($, scene, () => settled.scene)
45}
46
47async function wound($: EngineInterface, wounds: Readonly<Record<string, number>>) {
48 const current = await read($, scene)
49 if (current === null || Object.keys(wounds).length === 0) return
50 const wounded = woundEvents(await read($, save), current, wounds)
51 await update($, save, () => wounded.save)
52 await update($, scene, () => wounded.scene)
53}
54
55async function recordCue($: EngineInterface, cue: Cue) {
56 const current = await read($, scene)
57 if (current === null || !current.isRunning) return
58 const next = withCue(current, levelledCue(cue, (await read($, save)).level))
59 await update($, scene, () => next)
60 await settle($, overflowIds(next))
61}
62
63async function summonDueBoss($: EngineInterface) {
64 const current = await read($, scene)
65 if (current === null) return
66 const tier = dueBoss(await read($, save), current, await $.clock.now())
67 if (tier === null) return
68 const next = withBoss(current, tier, (await read($, save)).level)
69 await update($, scene, () => next)
70 await settle($, overflowIds(next))
71}
72
73async function endQuest($: EngineInterface) {
74 session.turnId = null
75 const current = await read($, scene)
76 if (current === null || !current.isRunning) return
77 await settle($, unsettledIdsExceptBosses(current))
78 const endedAt = await $.clock.now()
79 await update($, save, previous => finishQuest(previous, endedAt - current.startedAt))
80 const isPaused = session.backgroundTasks > 0
81 await update($, scene, now => (now === null ? now : restScene(withoutUnfoughtBosses(now), isPaused)))
82 if (!isPaused) makeCamp($)
83}
84
85function makeCamp($: EngineInterface) {
86 session.campTimer = $.clock.after(CAMP_MS, () => {
87 session.campTimer = null
88 update($, scene, now => (now !== null && !now.isRunning ? null : now)).catch(() => undefined)
89 })
90}
91
92function startsInBackground(input: unknown): boolean {
93 return typeof input === 'object' && input !== null && (input as { run_in_background?: unknown }).run_in_background === true
94}
95
96async function finishBackgroundTask($: EngineInterface) {
97 if (session.backgroundTasks === 0) return
98 session.backgroundTasks -= 1
99 if (session.backgroundTasks > 0 || session.turnId !== null) return
100 const current = await read($, scene)
101 if (current === null || current.isPaused !== true) return
102 await update($, scene, now => (now === null || now.isRunning ? now : restScene(now)))
103 makeCamp($)
104}
105
106export const registerKnightod: Register = on => {
107 on('session.start', { cwd: /^/ }, async ($, e, next) => {
108 session.backgroundTasks = 0
109 const saved = await $.store.get(STORE_KEY)
110 if (typeof saved === 'boolean') await update($, isEnabled, () => saved)
111 await $.command.register({
112 name: 'knightod',
113 description: 'knightod, the pixel knight: show the saved game, or turn it on, off, or start over.',
114 argumentHint: '[on|off|reset]',
115 immediate: true,
116 })
117 return next(e)
118 })
119
120 on('command.run', { command: 'knightod' }, async ($, e) => {
121 const choice = e.args.trim().toLowerCase()
122 if (choice === 'on' || choice === 'off') {
123 await update($, isEnabled, () => choice === 'on')
124 await $.store.set(STORE_KEY, choice === 'on')
125 if (choice === 'off') await update($, scene, () => null)
126 return { text: `knightod ${choice}.` }
127 }
128 if (choice === 'reset') {
129 await update($, save, () => NEW_SAVE)
130 return { text: 'A new knight sets out.' }
131 }
132 const now = await read($, save)
133 const state = (await read($, isEnabled)) ? 'on' : 'off'
134 return {
135 text: `knightod ${state}. Lv ${now.level} · ${hearts(now)} · ${now.kills} slain · ${now.gold} gold · ${now.quests} quests · played ${playMinutes(now)}m · ${now.bossesSlain} of ${BOSS_TIERS.length} bosses slain · fell ${now.deaths} times. Use /knightod on, off or reset.`,
136 }
137 })
138
139 on('turn.start', { turnId: /^/ }, async ($, e, next) => {
140 const result = await next(e)
141 if (session.turnId === null && (await read($, isEnabled))) await startQuest($, e.text, e.turnId)
142 return result
143 })
144
145 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
146 const ran = await next(e)
147 if (startsInBackground(e) && ran.deny === undefined && ran.isError !== true) session.backgroundTasks += 1
148 const hasFailed = ran.deny === undefined && ran.isError === true
149 await recordCue($, cueForCommand(e.command, hasFailed, session.wasFailing))
150 await summonDueBoss($)
151 if (hasFailed) session.wasFailing = true
152 else if (isTestCommand(e.command)) session.wasFailing = false
153 return ran
154 }).catch(($, e, next) => next(e))
155
156 on('tool.call', { tool: /^/ }, async ($, e, next) => {
157 if (e.tool === 'Bash') return next(e)
158 await recordCue($, cueForTool(e.tool, e as unknown as Record<string, unknown>))
159 await summonDueBoss($)
160 const ran = await next(e)
161 if (startsInBackground(e) && ran.deny === undefined && ran.isError !== true) session.backgroundTasks += 1
162 return ran
163 }).catch(($, e, next) => next(e))
164
165 on('prompt.submit', { origin: { kind: 'task-notification' } }, async ($, e, next) => {
166 const result = await next(e)
167 await finishBackgroundTask($)
168 return result
169 }).catch(($, e, next) => next(e))
170
171 on('turn.step', { turnId: /^/ }, async function* ($, e, next) {
172 const result = yield* next(e)
173 if (session.turnId !== null && result.usage) {
174 const { input_tokens, output_tokens, cache_creation_input_tokens } = result.usage
175 const now = await $.clock.now()
176 session.spends = [...session.spends.filter(spend => now - spend.at < RATE_WINDOW_MS), { at: now, tokens: input_tokens + output_tokens + cache_creation_input_tokens }]
177 const speed = speedFromRate(tokensPerMinute(session.spends, now))
178 await update($, scene, current => (current === null || current.speed === speed ? current : { ...current, speed }))
179 await summonDueBoss($)
180 }
181 return result
182 })
183
184 on('turn.complete', { turnId: /^/ }, async ($, e, next) => {
185 const result = await next(e)
186 if (e.turnId === session.turnId) await endQuest($)
187 return result
188 })
189
190 on('ui.message', async ($, e, next) => {
191 if (e.element !== STRIP_KEY) return next(e)
192 await wound($, woundsOf(e.data))
193 await settle($, settledIdsOf(e.data))
194 return {}
195 })
196
197 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
198 const below = await next(e)
199 const current = await read($, scene)
200 if (current === null || e.props.hasSurvey) return below
201 return drawBand($.ui.resolve(e), await read($, save), current, e.props.bodyColumns, below)
202 })
203
204 on('session.end', { sessionId: /^/ }, async ($, e, next) => {
205 session.campTimer?.cancel()
206 return next(e)
207 })
208}
209hooks/miod/register.tsx 316 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import { activityForCommand, activityForTool, strongestActivity } from './activity'
5import type { Activity } from './activity'
6import { drawBand } from './band'
7import { composeFinish, composeFixed, composePhrase, energyFromRate, soundingScale } from './compose'
8import type { Moment, Note } from './compose'
9import { nextMood } from './moods'
10import type { MoodChoice } from './moods'
11import { seededRandom } from './random'
12import { renderClip, toBase64, WAVE_POINTS_PER_SECOND } from './synth'
13import type { Fade } from './synth'
14import { describeTask, phraseSeconds, startTask } from './task'
15import { DEFAULT_WAVE, isWaveName, WAVE_NAMES } from './waves'
16import type { Task } from './task'
17import type { ScaleName } from './theory'
18import type { NowPlaying } from '../../types'
19import type { Spend } from '../shared/tokenRate'
20import { RATE_WINDOW_MS, tokensPerMinute } from '../shared/tokenRate'
21
22const STORE_KEY = 'miod.isEnabled'
23const isEnabled = atom({ plugin: 'codyssey', key: 'miod.isEnabled' } as const, true)
24const nowPlaying = atom({ plugin: 'codyssey', key: 'miod.nowPlaying' } as const, null)
25const chosenWave = atom({ plugin: 'codyssey', key: 'miod.wave' } as const, DEFAULT_WAVE)
26
27const GAIN = 0.1
28const FINISH_LOOK: Look = { label: 'finished', color: '#F5A623' }
29const FIXED_LOOK: Look = { label: 'fixed', color: '#81C784' }
30
31type Look = { label: string; color: string }
32
33type PlayOptions = { fade?: Fade }
34
35type Session = {
36 task: Task | null
37 turnId: string | null
38 phraseNumber: number
39 ticker: Timer | null
40 playing: Set<AbortController>
41 spends: Spend[]
42 toolCalls: number
43 contextFill: number
44 seenThisPhrase: Activity[]
45 isFailing: boolean
46 helpers: number
47 clipCount: number
48 lastTask: Task | null
49 moodChoice: MoodChoice | null
50 phraseEndsAt: number
51 finishTimer: Timer | null
52}
53
54const session: Session = {
55 task: null,
56 turnId: null,
57 phraseNumber: 0,
58 ticker: null,
59 playing: new Set(),
60 spends: [],
61 toolCalls: 0,
62 contextFill: 0,
63 seenThisPhrase: [],
64 isFailing: false,
65 helpers: 0,
66 clipCount: 0,
67 lastTask: null,
68 moodChoice: null,
69 phraseEndsAt: 0,
70 finishTimer: null,
71}
72
73function play($: EngineInterface, task: Task, notes: Note[], seconds: number, look: Look, options: PlayOptions = {}) {
74 const controller = new AbortController()
75 session.playing.add(controller)
76 const clip = renderClip(task.style, notes, seconds, task.seed, options.fade)
77 session.clipCount += 1
78 showWave($, { clipId: session.clipCount, ...look, levels: clip.levels, pointsPerSecond: WAVE_POINTS_PER_SECOND })
79 $.audio
80 .play({ base64: toBase64(clip.wav), mime: 'audio/wav' }, { gain: GAIN, signal: controller.signal })
81 .catch(() => $.ui.toast('miod: could not play audio'))
82 .finally(() => session.playing.delete(controller))
83}
84
85function silence() {
86 session.playing.forEach(controller => controller.abort())
87 session.playing.clear()
88}
89
90function showWave($: EngineInterface, clip: NowPlaying | null) {
91 update($, nowPlaying, () => clip).catch(() => undefined)
92}
93
94async function readMoment($: EngineInterface, now: number): Promise<Moment> {
95 session.spends = session.spends.filter(spend => now - spend.at < RATE_WINDOW_MS)
96 const seen = session.isFailing ? ['failing' as const] : session.seenThisPhrase
97
98 const task = session.task
99 const random = seededRandom((task?.seed ?? 0) + session.phraseNumber * 7919)
100 session.moodChoice = nextMood(session.moodChoice, strongestActivity(seen), random)
101 const moment: Moment = {
102 mood: session.moodChoice.mood,
103 scale: session.moodChoice.scale,
104 energy: energyFromRate(tokensPerMinute(session.spends, now)),
105 contextFill: session.contextFill,
106 toolCalls: session.toolCalls,
107 helpers: session.helpers,
108 }
109 session.toolCalls = 0
110 session.seenThisPhrase = []
111 return moment
112}
113
114async function playNextPhrase($: EngineInterface) {
115 const task = session.task
116 if (!task) {
117 return
118 }
119 const now = await $.clock.now()
120 const moment = await readMoment($, now)
121 const mood = moment.mood
122 const seconds = phraseSeconds(task)
123 const isFirst = session.phraseNumber === 0
124 const look = { label: `${mood.feel} · ${describeTask(task, soundingScale(task, moment.scale))} · energy ${Math.round(moment.energy * 100)}%`, color: mood.color }
125 play($, task, composePhrase(task, session.phraseNumber, moment), seconds, look, {
126 fade: isFirst ? { inSeconds: seconds / 2 } : undefined,
127 })
128 session.phraseNumber += 1
129 session.phraseEndsAt = now + seconds * 1000
130}
131
132async function startMusic($: EngineInterface, prompt: string, turnId: string) {
133 session.finishTimer?.cancel()
134 session.finishTimer = null
135 const task = startTask(prompt, await $.clock.now(), session.lastTask ?? undefined)
136 Object.assign(session, {
137 task,
138 turnId,
139 phraseNumber: 0,
140 spends: [],
141 toolCalls: 0,
142 seenThisPhrase: [],
143 isFailing: false,
144 helpers: 0,
145 moodChoice: null,
146 })
147 await playNextPhrase($)
148 session.ticker = $.clock.every(Math.round(phraseSeconds(task) * 1000), () => {
149 playNextPhrase($).catch(() => undefined)
150 })
151}
152
153async function stopMusic($: EngineInterface, shouldPlayFinish: boolean) {
154 session.ticker?.cancel()
155 session.ticker = null
156 session.finishTimer?.cancel()
157 session.finishTimer = null
158 const task = session.task
159 const scale = session.moodChoice?.scale
160 if (task && scale && shouldPlayFinish) {
161 session.lastTask = task
162 const wait = Math.max(0, session.phraseEndsAt - (await $.clock.now()))
163 session.finishTimer = $.clock.after(Math.round(wait), () => playFinish($, task, scale))
164 } else {
165 silence()
166 showWave($, null)
167 }
168 session.task = null
169 session.turnId = null
170}
171
172function playFinish($: EngineInterface, task: Task, scale: ScaleName) {
173 session.finishTimer = null
174 if (session.task) {
175 return
176 }
177 const seconds = phraseSeconds(task)
178 play($, task, composeFinish(task, soundingScale(task, scale)), seconds, FINISH_LOOK, { fade: { outSeconds: seconds / 2 } })
179 session.finishTimer = $.clock.after(Math.round(seconds * 1000), () => {
180 session.finishTimer = null
181 if (!session.task) {
182 showWave($, null)
183 }
184 })
185}
186
187function noteActivity(activity: Activity) {
188 if (session.task) {
189 session.seenThisPhrase.push(activity)
190 }
191}
192
193function noteCommandResult($: EngineInterface, hasFailed: boolean) {
194 const task = session.task
195 if (!task) {
196 return
197 }
198 if (hasFailed) {
199 session.isFailing = true
200 return
201 }
202 if (session.isFailing) {
203 session.isFailing = false
204 play($, task, composeFixed(task), phraseSeconds(task) / 2, FIXED_LOOK)
205 }
206}
207
208export const registerMiod: Register = on => {
209 on('session.start', async ($, e, next) => {
210 const saved = await $.store.get(STORE_KEY)
211 if (typeof saved === 'boolean') await update($, isEnabled, () => saved)
212 await $.command.register({
213 name: 'miod',
214 description: 'Turn the task music on or off.',
215 argumentHint: '[on|off|wave <name>]',
216 immediate: true,
217 })
218 return next(e)
219 })
220
221 on('command.run', { command: 'miod' }, async ($, e) => {
222 const [choice = '', name = ''] = e.args.trim().toLowerCase().split(/\s+/)
223 if (choice === 'wave') {
224 if (!isWaveName(name)) {
225 return { text: `waves: ${WAVE_NAMES.join(', ')}. Now: ${await read($, chosenWave)}. Use /miod wave <name>.` }
226 }
227 await update($, chosenWave, () => name)
228 return { text: `wave is ${name}.` }
229 }
230 if (choice === 'on' || choice === 'off') {
231 await update($, isEnabled, () => choice === 'on')
232 await $.store.set(STORE_KEY, choice === 'on')
233 if (choice === 'off') {
234 await stopMusic($, false)
235 }
236 return { text: `${choice}.` }
237 }
238 const state = (await read($, isEnabled)) ? 'on' : 'off'
239 return { text: `${state}. Use /miod on, /miod off, or /miod wave <name>.` }
240 })
241
242 on('turn.start', async ($, e, next) => {
243 const result = await next(e)
244 if (!session.task && (await read($, isEnabled))) {
245 await startMusic($, e.text, e.turnId)
246 }
247 return result
248 })
249
250 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
251 noteActivity(activityForCommand(e.command))
252 const ran = await next(e)
253 noteCommandResult($, ran.deny === undefined && ran.isError === true)
254 return ran
255 }).catch(($, e, next) => next(e))
256
257 on('tool.call', async ($, e, next) => {
258 if (e.tool === 'Bash') {
259 return next(e)
260 }
261 const activity = activityForTool(e.tool)
262 noteActivity(activity)
263 if (activity !== 'delegating') {
264 return next(e)
265 }
266 session.helpers += 1
267 try {
268 return await next(e)
269 } finally {
270 session.helpers = Math.max(0, session.helpers - 1)
271 }
272 }).catch(($, e, next) => next(e))
273
274 on('turn.step', async function* ($, e, next) {
275 const result = yield* next(e)
276 if (session.task && result.usage) {
277 const { input_tokens, output_tokens, cache_creation_input_tokens } = result.usage
278 session.spends.push({
279 at: await $.clock.now(),
280 tokens: input_tokens + output_tokens + cache_creation_input_tokens,
281 })
282 session.toolCalls += result.toolUses.length
283 }
284 return result
285 })
286
287 on('session.measure', ($, e, next) => {
288 if (e.context.percent !== undefined) {
289 session.contextFill = e.context.percent / 100
290 }
291 return next(e)
292 })
293
294 on('turn.complete', async ($, e, next) => {
295 const result = await next(e)
296 if (session.task && e.turnId === session.turnId) {
297 await stopMusic($, e.reason === 'answer')
298 }
299 return result
300 })
301
302 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
303 const below = await next(e)
304 const clip = await read($, nowPlaying)
305 if (!clip || e.props.hasSurvey) {
306 return below
307 }
308 return drawBand($.ui.resolve(e), clip, await read($, chosenWave), e.props.bodyColumns, below)
309 })
310
311 on('session.end', async ($, e, next) => {
312 await stopMusic($, false)
313 return next(e)
314 })
315}
316hooks/scrollod/register.tsx 96 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { drawNoticeRow } from './notice-row'
5import { drawPromptBox } from './prompt-box'
6import { drawReply } from './reply'
7import { markRunStarts, MAX_RUN_STARTS } from './runs'
8import { DEFAULT_STYLE, isStyle, STYLES } from './style'
9import { drawToolRow } from './tool-row'
10
11const STORE_KEY = 'scrollod.isOn'
12const STYLE_STORE_KEY = 'scrollod.style'
13const isOn = atom({ plugin: 'codyssey', key: 'scrollod.isOn' } as const, true)
14const style = atom({ plugin: 'codyssey', key: 'scrollod.style' } as const, DEFAULT_STYLE)
15const runStarts = atom({ plugin: 'codyssey', key: 'scrollod.runStarts' } as const, [])
16const lastBlock = atom({ plugin: 'codyssey', key: 'scrollod.lastBlock' } as const, null)
17
18export const registerScrollod: Register = on => {
19 on('session.start', { cwd: /^/ }, async ($, e, next) => {
20 const saved = await $.store.get(STORE_KEY)
21 if (typeof saved === 'boolean') await update($, isOn, () => saved)
22 const savedStyle = await $.store.get(STYLE_STORE_KEY)
23 if (isStyle(savedStyle)) await update($, style, () => savedStyle)
24 await $.command.register({
25 name: 'scrollod',
26 description: 'Quiet transcript: show its state, turn it on or off, or pick the plain or game style.',
27 argumentHint: '[on|off|style <plain|game>]',
28 immediate: true,
29 })
30 return next(e)
31 })
32
33 on('command.run', { command: 'scrollod' }, async ($, e) => {
34 const [choice = '', name = ''] = e.args.trim().toLowerCase().split(/\s+/)
35 if (choice === 'style') {
36 if (!isStyle(name)) return { text: `styles: ${STYLES.join(', ')}. Now: ${await read($, style)}. Use /scrollod style <name>.` }
37 await update($, style, () => name)
38 await $.store.set(STYLE_STORE_KEY, name)
39 return { text: `scrollod style is ${name}.` }
40 }
41 if (choice === 'on' || choice === 'off') {
42 await update($, isOn, () => choice === 'on')
43 await $.store.set(STORE_KEY, choice === 'on')
44 return { text: `scrollod ${choice}.` }
45 }
46 return { text: `scrollod is ${(await read($, isOn)) ? 'on' : 'off'}, style ${await read($, style)}. Use /scrollod on, off or style <plain|game>.` }
47 })
48
49 on('session.append', async ($, e, next) => {
50 const result = await next(e)
51 if (e.agentId !== undefined) return result
52 if (e.door === 'prompt') {
53 await update($, lastBlock, () => 'prose')
54 } else if (e.door === 'response') {
55 const marked = markRunStarts(await read($, lastBlock), e.message.content as readonly { type: string; id?: unknown }[])
56 await update($, lastBlock, () => marked.last)
57 if (marked.starts.length > 0) await update($, runStarts, ids => [...ids, ...marked.starts].slice(-MAX_RUN_STARTS))
58 }
59 return result
60 }).catch(($, e, next) => next(e))
61
62 on('ui.render', { component: 'ToolUse' }, async ($, e, next) => {
63 if (e.surface !== 'terminal' || !(await read($, isOn))) return next(e)
64 return drawToolRow($.ui.resolve(e), {
65 id: e.props.tool_use_id,
66 tool: e.props.tool,
67 input: e.props.input,
68 isRunning: e.props.isRunning,
69 isErrored: e.props.isErrored,
70 isInterrupted: e.props.isInterrupted,
71 style: await read($, style),
72 startsRun: (await read($, runStarts)).includes(e.props.tool_use_id),
73 })
74 })
75
76 on('ui.render', { component: 'ToolResult' }, async ($, e, next) => {
77 if (e.surface !== 'terminal' || e.props.isErrored || !(await read($, isOn))) return next(e)
78 const { Box } = $.ui.resolve(e)
79 return <Box />
80 })
81
82 on('ui.render', { component: 'UserMessage' }, async ($, e, next) => {
83 if (e.surface !== 'terminal' || !(await read($, isOn)) || (await read($, style)) !== 'game') return next(e)
84 if (e.props.origin.kind === 'composer') return drawPromptBox($.ui.resolve(e), e.props.text)
85 if (e.props.origin.kind === 'task-notification') {
86 return drawNoticeRow($.ui.resolve(e), { text: e.props.text, status: e.props.task?.status, durationMs: e.props.task?.durationMs })
87 }
88 return next(e)
89 })
90
91 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
92 if (e.surface !== 'terminal' || e.props.isSummary || !(await read($, isOn))) return next(e)
93 return drawReply($.ui.resolve(e), { text: e.props.text, isFirstOfReply: e.props.isFirstOfReply, style: await read($, style) })
94 })
95}
96hooks/knightod/band.tsx 29 lines1import type { ElementTable, RenderElement } from 'claude-code'
2
3import type { Save, Scene } from '../../types'
4import { statusLine } from './game/label'
5import { STRIP_PIXEL_ROWS } from './world/world'
6
7export const STRIP_MAX_COLUMNS = 64
8export const STRIP_MIN_COLUMNS = 16
9export const STRIP_KEY = 'knightod-strip'
10
11export function drawBand(elements: ElementTable, save: Save, scene: Scene, columns: number, below: RenderElement): RenderElement {
12 const { Box, Text } = elements
13 const stripColumns = Math.min(STRIP_MAX_COLUMNS, columns)
14 const strip =
15 'Client' in elements && stripColumns >= STRIP_MIN_COLUMNS ? (
16 <elements.Client key={STRIP_KEY} module="./strip.tsx" props={{ scene }} width={stripColumns} height={STRIP_PIXEL_ROWS / 2} />
17 ) : null
18
19 return (
20 <Box flexDirection="column">
21 <Text color={scene.isRunning ? '#FFC107' : 'subtle'} wrap="truncate-end">
22 {statusLine(save, scene)}
23 </Text>
24 {strip}
25 {below}
26 </Box>
27 )
28}
29hooks/knightod/game/bosses.ts 34 lines1import type { BossSpecies, Save, Scene, Species } from '../../../types'
2import { levelledCue } from './level'
3import { withCue } from './scene'
4
5export type BossTier = { species: BossSpecies; hearts: number; atMs: number }
6
7const MINUTE_MS = 60_000
8
9export const BOSS_TIERS: readonly BossTier[] = [
10 { species: 'king-slime', hearts: 3, atMs: 2 * MINUTE_MS },
11 { species: 'ogre', hearts: 4, atMs: 4 * MINUTE_MS },
12 { species: 'lich', hearts: 5, atMs: 6 * MINUTE_MS },
13 { species: 'demon', hearts: 6, atMs: 8 * MINUTE_MS },
14 { species: 'shadow-king', hearts: 7, atMs: 10 * MINUTE_MS },
15]
16
17export function playedMs(save: Save, scene: Scene, now: number): number {
18 return save.playMs + (scene.isRunning ? Math.max(0, now - scene.startedAt) : 0)
19}
20
21export function isTimedBoss(species: Species | null): boolean {
22 return BOSS_TIERS.some(tier => tier.species === species)
23}
24
25export function dueBoss(save: Save, scene: Scene, now: number): BossTier | null {
26 const tier = BOSS_TIERS[save.bossesSlain]
27 if (!scene.isRunning || tier === undefined || playedMs(save, scene, now) < tier.atMs) return null
28 return scene.events.some(event => event.kind === 'boss' && !event.isSettled) ? null : tier
29}
30
31export function withBoss(scene: Scene, tier: BossTier, level = 1): Scene {
32 return withCue(scene, levelledCue({ kind: 'boss', species: tier.species, target: '', hearts: tier.hearts }, level))
33}
34hooks/knightod/game/cues.ts 118 lines1import type { EventKind, GameEvent, Species } from '../../../types'
2import { heartCount } from './label'
3import { hashText } from './random'
4
5export type Cue = { kind: EventKind; species: Species | null; target: string; hearts?: number }
6
7const READING_TOOLS = ['Read', 'Grep', 'Glob', 'LS', 'NotebookRead']
8const EXPLORING_TOOLS = ['WebFetch', 'WebSearch']
9const EDITING_TOOLS = ['Edit', 'Write', 'MultiEdit', 'NotebookEdit']
10const DELEGATING_TOOLS = ['Agent', 'Task']
11const EDIT_SPECIES: readonly Species[] = ['slime', 'goblin', 'skeleton', 'orc', 'spider']
12const WEB_SPECIES: readonly Species[] = ['bat', 'ghost']
13const SHELL_SPECIES: readonly Species[] = ['goblin', 'wolf']
14const BUG_DRAGON_HEARTS = 4
15
16const SHIPPING_COMMAND = /\bgit (commit|push|merge|tag)\b|\bgh pr (create|merge)\b/
17const TEST_COMMAND = /\b(test|tests|spec|jest|vitest|pytest|rspec|mocha|check|lint|tsc)\b/
18
19export function isTestCommand(command: string): boolean {
20 return TEST_COMMAND.test(command)
21}
22
23function baseName(path: string): string {
24 return path.split('/').filter(Boolean).at(-1) ?? path
25}
26
27function textField(input: Record<string, unknown>, ...names: string[]): string {
28 for (const name of names) {
29 const value = input[name]
30 if (typeof value === 'string' && value.trim() !== '') return value.trim()
31 }
32 return ''
33}
34
35function pickSpecies(species: readonly Species[], target: string): Species {
36 return species[hashText(target) % species.length] ?? 'slime'
37}
38
39function hostOf(url: string): string {
40 const match = /^[a-z]+:\/\/([^/]+)/i.exec(url)
41 return match?.[1] ?? url
42}
43
44export function cueForTool(tool: string, input: Record<string, unknown>): Cue {
45 if (READING_TOOLS.includes(tool)) {
46 return { kind: 'scroll', species: null, target: baseName(textField(input, 'file_path', 'notebook_path', 'path', 'pattern')) || tool }
47 }
48 if (EXPLORING_TOOLS.includes(tool)) {
49 const target = hostOf(textField(input, 'url', 'query')) || 'the web'
50 return { kind: 'monster', species: pickSpecies(WEB_SPECIES, target), target }
51 }
52 if (EDITING_TOOLS.includes(tool)) {
53 const target = baseName(textField(input, 'file_path', 'notebook_path')) || tool
54 return { kind: 'monster', species: pickSpecies(EDIT_SPECIES, target), target }
55 }
56 if (DELEGATING_TOOLS.includes(tool)) {
57 return { kind: 'monster', species: 'skeleton', target: textField(input, 'description') || 'the crypt' }
58 }
59 if (tool.startsWith('mcp__')) {
60 return { kind: 'monster', species: 'slime', target: tool.split('__')[1] ?? tool }
61 }
62 return { kind: 'monster', species: 'slime', target: tool }
63}
64
65export function cueForCommand(command: string, hasFailed: boolean, wasFailing: boolean): Cue {
66 const target = command.trim().split(/\s+/).slice(0, 2).join(' ') || 'the shell'
67 if (hasFailed) return { kind: 'hurt', species: null, target }
68 if (SHIPPING_COMMAND.test(command)) return { kind: 'chest', species: null, target }
69 if (isTestCommand(command) && wasFailing) return { kind: 'boss', species: 'dragon', target, hearts: BUG_DRAGON_HEARTS }
70 return { kind: 'monster', species: pickSpecies(SHELL_SPECIES, target), target }
71}
72
73const SPECIES_NAMES: Record<Species, string> = {
74 slime: 'Slime',
75 goblin: 'Goblin',
76 bat: 'Bat',
77 skeleton: 'Skeleton',
78 wolf: 'Wolf',
79 spider: 'Spider',
80 ghost: 'Ghost',
81 orc: 'Orc',
82 'king-slime': 'King Slime',
83 ogre: 'Ogre',
84 lich: 'Lich',
85 demon: 'Demon',
86 'shadow-king': 'Shadow King',
87 dragon: 'Bug Dragon',
88}
89
90export function cueName(cue: Cue): string {
91 switch (cue.kind) {
92 case 'monster':
93 return `the ${SPECIES_NAMES[cue.species ?? 'slime']} of "${cue.target}"`
94 case 'boss':
95 return `the ${SPECIES_NAMES[cue.species ?? 'dragon']} ${heartCount(cue.hearts ?? 1)}`
96 case 'chest':
97 return 'a treasure chest'
98 case 'scroll':
99 return `a scroll of "${cue.target}"`
100 case 'hurt':
101 return `hit by "${cue.target}"`
102 }
103}
104
105export function settledName(event: GameEvent): string {
106 switch (event.kind) {
107 case 'monster':
108 case 'boss':
109 return `slew ${event.name}`
110 case 'chest':
111 return `opened ${event.name}`
112 case 'scroll':
113 return `picked up ${event.name}`
114 case 'hurt':
115 return event.name
116 }
117}
118hooks/knightod/game/random.ts 20 lines1export function hashText(text: string): number {
2 let hash = 2166136261
3 for (let index = 0; index < text.length; index += 1) {
4 hash ^= text.charCodeAt(index)
5 hash = Math.imul(hash, 16777619)
6 }
7 return hash >>> 0
8}
9
10export function seededRandom(seed: number): () => number {
11 let state = seed >>> 0
12 return () => {
13 state = (state + 0x6d2b79f5) >>> 0
14 let mixed = state
15 mixed = Math.imul(mixed ^ (mixed >>> 15), mixed | 1)
16 mixed ^= mixed + Math.imul(mixed ^ (mixed >>> 7), mixed | 61)
17 return ((mixed ^ (mixed >>> 14)) >>> 0) / 4294967296
18 }
19}
20hooks/knightod/game/save.ts 44 lines1import type { EventKind, Save } from '../../../types'
2
3export const NEW_SAVE: Save = { level: 1, xp: 0, hp: 5, maxHp: 5, kills: 0, gold: 0, deaths: 0, quests: 0, playMs: 0, bossesSlain: 0 }
4
5export function playMinutes(save: Save): number {
6 return Math.floor(save.playMs / 60_000)
7}
8
9export function xpForNextLevel(level: number): number {
10 return level * 8
11}
12
13function gainXp(save: Save, xp: number): Save {
14 let next = { ...save, xp: save.xp + xp }
15 while (next.xp >= xpForNextLevel(next.level)) {
16 next = { ...next, xp: next.xp - xpForNextLevel(next.level), level: next.level + 1, maxHp: next.maxHp + 1 }
17 next = { ...next, hp: next.maxHp }
18 }
19 return next
20}
21
22export function applyEvent(save: Save, kind: EventKind, random: () => number, hearts = 1): Save {
23 switch (kind) {
24 case 'monster':
25 return gainXp({ ...save, kills: save.kills + 1, gold: save.gold + hearts + Math.floor(random() * 3) }, hearts)
26 case 'boss':
27 return gainXp({ ...save, kills: save.kills + 1, gold: save.gold + 5 * hearts + Math.floor(random() * 10) }, 2 * hearts)
28 case 'chest':
29 return { ...save, gold: save.gold + 5 + Math.floor(random() * 20) }
30 case 'scroll':
31 return { ...save, hp: Math.min(save.maxHp, save.hp + 1) }
32 case 'hurt':
33 return save.hp > 1 ? { ...save, hp: save.hp - 1 } : { ...save, hp: save.maxHp, deaths: save.deaths + 1, gold: Math.floor(save.gold / 2) }
34 }
35}
36
37export function woundByBoss(save: Save, hits: number): Save {
38 return { ...save, hp: Math.max(Math.min(save.hp, 1), save.hp - hits) }
39}
40
41export function finishQuest(save: Save, playedMs: number): Save {
42 return { ...save, quests: save.quests + 1, playMs: save.playMs + Math.max(0, playedMs) }
43}
44hooks/knightod/game/scene.ts 46 lines1import type { GameEvent, Scene } from '../../../types'
2import type { Cue } from './cues'
3import { cueName } from './cues'
4
5export const MAX_EVENTS = 12
6export const MAX_UNSETTLED = 24
7
8export function startScene(seed: number, previous: Scene | null, now: number): Scene {
9 return {
10 isRunning: true,
11 seed,
12 speed: 1,
13 events: [],
14 nextId: previous?.nextId ?? 1,
15 latest: previous ? 'back on the road' : 'sets out',
16 startedAt: now,
17 }
18}
19
20export function withCue(scene: Scene, cue: Cue): Scene {
21 const event: GameEvent = { id: scene.nextId, kind: cue.kind, species: cue.species, name: cueName(cue), isSettled: false }
22 if (cue.hearts !== undefined) event.hearts = cue.hearts
23 const events = [...scene.events, event]
24 while (events.length > MAX_EVENTS && events[0]?.isSettled === true) events.shift()
25 return { ...scene, events, nextId: scene.nextId + 1, latest: event.name }
26}
27
28export function unsettledIdsExceptBosses(scene: Scene): number[] {
29 return scene.events.filter(event => !event.isSettled && event.kind !== 'boss').map(event => event.id)
30}
31
32export function withoutUnfoughtBosses(scene: Scene): Scene {
33 return { ...scene, events: scene.events.filter(event => event.isSettled || event.kind !== 'boss') }
34}
35
36export function overflowIds(scene: Scene): number[] {
37 const waiting = unsettledIdsExceptBosses(scene)
38 return waiting.slice(0, Math.max(0, waiting.length - MAX_UNSETTLED))
39}
40
41export const PAUSED_TEXT = 'paused, waiting on background work'
42
43export function restScene(scene: Scene, isPaused = false): Scene {
44 return { ...scene, isRunning: false, isPaused, latest: isPaused ? PAUSED_TEXT : 'progress saved' }
45}
46hooks/knightod/game/settle.ts 48 lines1import type { Save, Scene } from '../../../types'
2import { settledName } from './cues'
3import { seededRandom } from './random'
4import { isTimedBoss } from './bosses'
5import { counterHits } from './fight'
6import { applyEvent, woundByBoss } from './save'
7
8export function settleEvents(save: Save, scene: Scene, ids: readonly number[]): { save: Save; scene: Scene } {
9 let next = save
10 let latest = scene.latest
11 const events = scene.events.map(event => {
12 if (event.isSettled || !ids.includes(event.id)) return event
13 if (event.kind === 'boss') next = woundByBoss(next, counterHits(scene.seed, event.id, event.hearts ?? 1) - (event.woundsTaken ?? 0))
14 if (event.kind === 'boss' && isTimedBoss(event.species)) next = { ...next, bossesSlain: next.bossesSlain + 1 }
15 next = applyEvent(next, event.kind, seededRandom(scene.seed + event.id * 7919), event.hearts)
16 latest = settledName(event)
17 return { ...event, isSettled: true }
18 })
19 return { save: next, scene: { ...scene, events, latest } }
20}
21
22export function woundEvents(save: Save, scene: Scene, wounds: Readonly<Record<string, number>>): { save: Save; scene: Scene } {
23 let next = save
24 const events = scene.events.map(event => {
25 const landed = wounds[String(event.id)]
26 if (event.kind !== 'boss' || event.isSettled || landed === undefined) return event
27 const total = Math.min(landed, counterHits(scene.seed, event.id, event.hearts ?? 1))
28 const fresh = total - (event.woundsTaken ?? 0)
29 if (fresh <= 0) return event
30 next = woundByBoss(next, fresh)
31 return { ...event, woundsTaken: total }
32 })
33 return { save: next, scene: { ...scene, events } }
34}
35
36export function woundsOf(data: unknown): Record<string, number> {
37 if (typeof data !== 'object' || data === null || !('wounds' in data)) return {}
38 const wounds = (data as { wounds: unknown }).wounds
39 if (typeof wounds !== 'object' || wounds === null) return {}
40 return Object.fromEntries(Object.entries(wounds).filter((entry): entry is [string, number] => typeof entry[1] === 'number'))
41}
42
43export function settledIdsOf(data: unknown): number[] {
44 if (typeof data !== 'object' || data === null || !('settled' in data)) return []
45 const settled = (data as { settled: unknown }).settled
46 return Array.isArray(settled) ? settled.filter((id): id is number => typeof id === 'number') : []
47}
48hooks/knightod/game/speed.ts 6 lines1export function speedFromRate(perMinute: number): number {
2 if (perMinute >= 80_000) return 3
3 if (perMinute >= 30_000) return 2
4 return 1
5}
6