A Claude samurai cuts down a Codex, Gemini, or ChatGPT foe for every tool call, and meditates, trains, and duels between them through four seasons.

A Claude samurai guards your session. Every tool call Claude makes sends a Codex, Gemini, or ChatGPT foe into the dojo, and the samurai cuts it down when the call finishes. Between calls the samurai never stands still: it meditates, practices kata, duels wandering ronin, and fights off ambient waves, through winter, cherry blossom, summer, and autumn.

claude plugin marketplace add theonly1me/claude-code-mods
claude plugin install samurai-dojo@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.
/dojo. The dojo opens in a pane./dojo again to close it./farm) closes the dojo.CLAUDE_CODE_NO_FLICKER=1), the pane docks beside the transcript and shows a log of what the samurai did. Otherwise it sits above the prompt./dojo shows it at any width.| Command | What it does |
|---|---|
/dojo | Open or close the dojo |
/dojo on, /dojo off | Open or close it explicitly |
/dojo stats | Print your rank, kills, and flurries without opening the pane |
The game plays itself. It has no keys to press.
Real work:
Between calls, the samurai picks the next activity:
This ambient action is decoration. Only real work counts toward kills and ranks. When a tool call starts, the samurai drops what it is doing, and a dueling ronin runs off.
The season changes every 5 minutes: winter (snow on the floor and a bare tree), cherry blossom (pink petals), summer (fireflies), and autumn (falling red leaves). The farm in Little Harvest uses the same clock, so both show the same season.
Lifetime kills earn ranks, shown by the color of the samurai's headband and sash: Ronin (red), Hatamoto at 100 (blue), Daimyo at 500 (violet), and Shogun at 2,000 (gold).
See the marketplace README for configuration, privacy, updates, and removal.
hooks/register.tsx 93 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import { configureStage, installStage } from './shared/pixel/stage'
5import { FRAME_MS, MAX_COLUMNS, MIN_COLUMNS, STAGE_ROWS } from './sim/constants'
6import { createDojo } from './sim/dojo'
7import { nextRankOf } from './sim/rank'
8
9const LIFETIME_KEY = 'lifetimeKills'
10
11const tallyAtom = atom({ plugin: 'samurai-dojo', key: 'tally' } as const, {
12 codex: 0,
13 gemini: 0,
14 chatgpt: 0,
15})
16
17export const register: Register = on => {
18 const dojo = createDojo()
19
20 function totalSlain(): number {
21 const { codex, gemini, chatgpt } = dojo.tally()
22 return codex + gemini + chatgpt
23 }
24
25 function report(): string[] {
26 const nextRank = nextRankOf(dojo.lifetimeKills())
27 const progress = nextRank
28 ? `${nextRank.minimumKills - dojo.lifetimeKills()} more to ${nextRank.title}.`
29 : 'The highest rank.'
30 return [
31 dojo.summary(),
32 `Rank: ${dojo.rank().title}. ${progress} Flurries this session: ${dojo.flurries()}.`,
33 `Slain this session: ${totalSlain()}. Lifetime: ${dojo.lifetimeKills()}.`,
34 ]
35 }
36
37 configureStage({
38 game: 'samurai-dojo',
39 title: 'Samurai Dojo',
40 command: {
41 name: 'dojo',
42 description: 'Show or hide the samurai dojo (on, off, stats)',
43 report,
44 },
45 rows: STAGE_ROWS,
46 minColumns: MIN_COLUMNS,
47 maxColumns: MAX_COLUMNS,
48 frameMs: FRAME_MS,
49 scene: {
50 begin: epochMs => dojo.begin(epochMs),
51 resize: columns => dojo.resize(columns),
52 tick: dtMs => dojo.tick({ dtMs }),
53 frame: () => dojo.frame(),
54 summary: () => dojo.summary(),
55 log: () => dojo.log(),
56 },
57 })
58 installStage(on)
59
60 on('session.start', async ($, e, next) => {
61 const saved = await read($, tallyAtom)
62 dojo.restore({
63 tally: { codex: saved.codex, gemini: saved.gemini, chatgpt: saved.chatgpt ?? 0 },
64 lifetimeKills: Number((await $.store.get(LIFETIME_KEY)) ?? 0),
65 })
66 return next(e)
67 })
68
69 on('tool.call', async ($, e, next) => {
70 const id = dojo.spawn({ isElite: e.tool === 'Agent', label: e.tool })
71 try {
72 const ran = await next(e)
73 dojo.defeat({ id, isFailure: ran.deny !== undefined || ran.isError === true })
74 return ran
75 } catch (error) {
76 dojo.defeat({ id, isFailure: true })
77 throw error
78 }
79 })
80
81 on('turn.complete', async ($, e, next) => {
82 dojo.celebrate()
83 await update($, tallyAtom, () => dojo.tally())
84 await $.store.set(LIFETIME_KEY, dojo.lifetimeKills())
85 return next(e)
86 })
87
88 on('session.end', async ($, e, next) => {
89 await $.store.set(LIFETIME_KEY, dojo.lifetimeKills())
90 return next(e)
91 })
92}
93hooks/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/sim/constants.ts 45 lines1export const FRAME_MS = 40
2export const STAGE_ROWS = 8
3export const STAGE_HEIGHT = STAGE_ROWS * 2
4export const FLOOR_TOP = 14
5export const MIN_COLUMNS = 44
6export const MAX_COLUMNS = 72
7export const MIN_BAND_ROWS = 10
8export const STAGE_KEY = 'stage'
9
10export const HOME_X = 2
11export const SAMURAI_TOP = 1
12export const STRIKE_REACH = 20
13export const DASH_SPEED_PIXELS_PER_SECOND = 320
14export const RETURN_SPEED_PIXELS_PER_SECOND = 130
15
16export const SLASH_MS = 420
17export const IMPACT_PROGRESS = 0.46
18export const STRIKE_START = 0.3
19export const STRIKE_END = 0.62
20export const ARC_TRAIL_PROGRESS = 0.3
21export const CHEER_MS = 1500
22export const HURT_MS = 360
23
24export const MONSTER_WIDTH = 12
25export const MONSTER_GAP = 3
26export const MONSTER_FRONT_X = 34
27export const MONSTER_SPEED_PIXELS_PER_SECOND = 15
28export const KNOCKBACK_PIXELS = 4
29export const MAX_ALIVE_MONSTERS = 4
30export const DEATH_MS = 760
31export const FLASH_MS = 120
32export const WALK_FRAME_MS = 280
33export const FLURRY_WINDOW_MS = 1500
34export const FLURRY_KILLS = 3
35
36export const RIVAL_STAND_OFFSET = 25
37export const RIVAL_WALK_PIXELS_PER_SECOND = 36
38export const RIVAL_RETREAT_PIXELS_PER_SECOND = 110
39export const EXCHANGE_MS = 560
40export const DUEL_FINISH_MS = 1400
41export const KI_INTERVAL_MS = 380
42export const WAVE_DOOM_DELAY_MS = 1700
43export const WAVE_STAGGER_MS = 420
44export const LOG_LIMIT = 8
45hooks/sim/dojo.ts 196 lines1import { clearBitmap, createBitmap } from '../shared/pixel/bitmap'
2import type { Bitmap } from '../shared/pixel/bitmap'
3import { nextSeasonIn, seasonAt } from '../shared/pixel/seasons'
4import type { Season } from '../shared/pixel/seasons'
5import { createWeather } from '../shared/pixel/weather'
6import { drawScene, treeCenterOf } from '../draw/scene'
7import { createAmbient } from './ambient'
8import type { AmbientWorld } from './ambient'
9import { FLOOR_TOP, HURT_MS, MAX_ALIVE_MONSTERS, MAX_COLUMNS, STAGE_HEIGHT } from './constants'
10import { advanceEffects } from './effects'
11import { createLedger } from './ledger'
12import { advanceMonsters, applyStrike, createMonster, nearestDoomed } from './monsters'
13import { rankOf } from './rank'
14import type { Rank } from './rank'
15import { advanceSamurai, createSamurai } from './samurai'
16import type { Effect, KillEvent, KillTally, Monster, MonsterKind, MonsterPlan } from './types'
17import { singularOf } from './waves'
18
19const KIND_ORDER: readonly [MonsterKind, ...MonsterKind[]] = ['codex', 'gemini', 'chatgpt']
20
21export function createDojo(options: { seed?: number } = {}) {
22 const seed = options.seed ?? 11
23 const samurai = createSamurai()
24 const ambient = createAmbient({ seed })
25 const weather = createWeather({ seed: seed + 5 })
26 const ledger = createLedger()
27 const overflowPlans = new Map<number, MonsterPlan>()
28 let monsters: Monster[] = []
29 let effects: Effect[] = []
30 let nextId = 1
31 let columns = MAX_COLUMNS
32 let bitmap = createBitmap({ width: columns, height: STAGE_HEIGHT })
33 let epochMs = 0
34 let season: Season = seasonAt(0)
35
36 function recordKill(plan: MonsterPlan): void {
37 if (ledger.recordKill({ plan, clockMs: samurai.clockMs }).isFlurry) {
38 effects.push({ kind: 'flurry', x: samurai.x + 14, y: FLOOR_TOP - 6, ageMs: 0 })
39 }
40 }
41
42 function resolveImpact(target: Monster): void {
43 const outcome = applyStrike(target)
44 const impact = { x: target.x + 1, y: FLOOR_TOP - 6, ageMs: 0 }
45 if (outcome === 'parried') {
46 effects.push({ ...impact, kind: 'clang' })
47 samurai.hurtMs = HURT_MS
48 return
49 }
50 effects.push({ ...impact, kind: 'hit' })
51 if (!target.isAmbient) {
52 recordKill(target)
53 }
54 }
55
56 function takeId(): number {
57 const id = nextId
58 nextId += 1
59 return id
60 }
61
62 const world: AmbientWorld = {
63 samurai,
64 monsters,
65 effects,
66 columns,
67 season,
68 spawn: plan => {
69 const id = takeId()
70 monsters.push(createMonster({ ...plan, id, isAmbient: true, label: 'Ambient' }))
71 return id
72 },
73 doom: id => {
74 const monster = monsters.find(candidate => candidate.id === id)
75 if (monster && !monster.isDoomed) {
76 monster.isDoomed = true
77 monster.hitsRemaining = monster.isElite ? 2 : 1
78 }
79 },
80 note: ledger.note,
81 }
82
83 return {
84 begin(startMs: number): void {
85 epochMs = startMs
86 season = seasonAt(epochMs)
87 ambient.reseed(seed + Math.floor(startMs / 1000))
88 },
89
90 resize(nextColumns: number): void {
91 if (nextColumns !== columns) {
92 columns = nextColumns
93 bitmap = createBitmap({ width: columns, height: STAGE_HEIGHT })
94 }
95 },
96
97 restore: ledger.restore,
98 tally: (): KillTally => ledger.tally(),
99 lifetimeKills: (): number => ledger.lifetimeKills(),
100 flurries: (): number => ledger.flurries(),
101 rank: (): Rank => rankOf(ledger.lifetimeKills()),
102 season: (): Season => season,
103 log: (): readonly string[] => ledger.lines(),
104
105 summary(): string {
106 const upcoming = nextSeasonIn(epochMs + samurai.clockMs)
107 const minutes = Math.max(1, Math.ceil(upcoming.remainingMs / 60000))
108 const lifetime = ledger.lifetimeKills()
109 return `${rankOf(lifetime).title} ${ledger.slain()} slain ${lifetime} lifetime ${season.label}, ${upcoming.label} in ${minutes}m`
110 },
111
112 spawn(plan: { isElite: boolean; label: string }): number {
113 const id = takeId()
114 const kind = KIND_ORDER[(id - 1) % KIND_ORDER.length] ?? 'codex'
115 const aliveCount = monsters.filter(monster => monster.phase === 'alive').length
116 if (aliveCount >= MAX_ALIVE_MONSTERS) {
117 overflowPlans.set(id, { kind, isElite: plan.isElite, label: plan.label })
118 return id
119 }
120 monsters.push(createMonster({ id, kind, isElite: plan.isElite, isAmbient: false, label: plan.label, x: columns }))
121 return id
122 },
123
124 defeat(outcome: { id: number; isFailure: boolean }): void {
125 const monster = monsters.find(candidate => candidate.id === outcome.id)
126 if (monster) {
127 monster.isDoomed = true
128 monster.hitsRemaining = (monster.isElite ? 2 : 1) + (outcome.isFailure ? 1 : 0)
129 if (outcome.isFailure) {
130 ledger.note(`${monster.label} failed: the ${singularOf(monster.kind)} parries once`)
131 }
132 return
133 }
134 const plan = overflowPlans.get(outcome.id)
135 if (plan) {
136 overflowPlans.delete(outcome.id)
137 recordKill(plan)
138 }
139 },
140
141 celebrate(): void {
142 samurai.isCheerRequested = true
143 ledger.note('Turn complete: the samurai salutes')
144 },
145
146 tick(step: { dtMs: number }): KillEvent[] {
147 const { dtMs } = step
148 const nextSeason = seasonAt(epochMs + samurai.clockMs + dtMs)
149 if (nextSeason.name !== season.name) {
150 ledger.note(`${nextSeason.label} begins`)
151 }
152 season = nextSeason
153 const hasWork =
154 monsters.some(monster => !monster.isAmbient && monster.phase === 'alive') ||
155 overflowPlans.size > 0 ||
156 samurai.isCheerRequested
157 Object.assign(world, { monsters, effects, columns, season })
158 if (hasWork) {
159 ambient.interrupt(world)
160 }
161 ambient.advance({ world, dtMs, hasWork })
162 const target = nearestDoomed(monsters)
163 const samuraiStep = advanceSamurai({ samurai, dtMs, target })
164 if (samuraiStep.didStartDash) {
165 effects.push({ kind: 'dust', x: samurai.x + 2, y: FLOOR_TOP - 1, ageMs: 0 })
166 }
167 if (samuraiStep.didImpact && target) {
168 resolveImpact(target)
169 }
170 monsters = advanceMonsters({ monsters, dtMs })
171 effects = advanceEffects({ effects, dtMs })
172 const crown = { x: treeCenterOf(columns) - 7, y: 1, width: 15, height: 8 }
173 weather.advance({ dtMs, season, width: columns, groundY: FLOOR_TOP, sources: [crown] })
174 return ledger.takeKills()
175 },
176
177 frame(): Bitmap {
178 clearBitmap(bitmap)
179 drawScene({
180 bitmap,
181 monsters,
182 effects,
183 samurai,
184 duel: ambient.duel(),
185 totalKills: ledger.slain(),
186 rank: rankOf(ledger.lifetimeKills()),
187 season,
188 weather,
189 })
190 return bitmap
191 },
192 }
193}
194
195export type Dojo = ReturnType<typeof createDojo>
196hooks/sim/rank.ts 29 lines1import type { Color } from '../shared/pixel/bitmap'
2
3export type Rank = {
4 title: string
5 minimumKills: number
6 bright: Color
7 dark: Color
8}
9
10const RANKS: readonly [Rank, ...Rank[]] = [
11 { title: 'Ronin', minimumKills: 0, bright: 0xd8372e, dark: 0x8a1c1c },
12 { title: 'Hatamoto', minimumKills: 100, bright: 0x3b7dd8, dark: 0x1f4a8a },
13 { title: 'Daimyo', minimumKills: 500, bright: 0x9b5de5, dark: 0x5a2d91 },
14 { title: 'Shogun', minimumKills: 2000, bright: 0xf2c14e, dark: 0xa8801f },
15]
16
17export const BASE_SASH: Color = RANKS[0].bright
18
19export function rankOf(lifetimeKills: number): Rank {
20 return RANKS.reduce<Rank>(
21 (current, rank) => (lifetimeKills >= rank.minimumKills ? rank : current),
22 RANKS[0],
23 )
24}
25
26export function nextRankOf(lifetimeKills: number): Rank | undefined {
27 return RANKS.find(rank => rank.minimumKills > lifetimeKills)
28}
29hooks/shared/noop.ts 4 lines1export function noop(): undefined {
2 return undefined
3}
4hooks/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/shared/pixel/seasons.ts 154 lines1import type { Color } from './bitmap'
2import { mixColors } from './colors'
3
4export type SeasonName = 'winter' | 'blossom' | 'summer' | 'autumn'
5
6export type ParticleKind = 'snow' | 'petal' | 'firefly' | 'leaf'
7
8export type SeasonPalette = {
9 ground: Color
10 groundShade: Color
11 foliageLight: Color
12 foliage: Color
13 foliageDark: Color
14 accent: Color
15 trunk: Color
16 skyTop: Color
17 skyLow: Color
18 moon: Color
19 particles: readonly [Color, ...Color[]]
20}
21
22export type Season = {
23 name: SeasonName
24 label: string
25 progress: number
26 particle: ParticleKind
27 palette: SeasonPalette
28}
29
30export const SEASON_MS = 5 * 60 * 1000
31const BLEND_MS = 20 * 1000
32
33const ORDER: readonly [SeasonName, ...SeasonName[]] = ['winter', 'blossom', 'summer', 'autumn']
34
35const LABELS: Record<SeasonName, string> = {
36 winter: 'Winter',
37 blossom: 'Cherry blossom',
38 summer: 'Summer',
39 autumn: 'Autumn',
40}
41
42const PARTICLES: Record<SeasonName, ParticleKind> = {
43 winter: 'snow',
44 blossom: 'petal',
45 summer: 'firefly',
46 autumn: 'leaf',
47}
48
49export const SEASON_PALETTES: Record<SeasonName, SeasonPalette> = {
50 winter: {
51 ground: 0xeef4fb,
52 groundShade: 0xb9c8da,
53 foliageLight: 0xf4f8ff,
54 foliage: 0xd5e2f0,
55 foliageDark: 0x9fb3c8,
56 accent: 0xffffff,
57 trunk: 0x4a3a32,
58 skyTop: 0x1b2440,
59 skyLow: 0x5d7398,
60 moon: 0xdfe9f5,
61 particles: [0xffffff, 0xe3eefa, 0xc6d8ec],
62 },
63 blossom: {
64 ground: 0x7fbf6a,
65 groundShade: 0x4f8f45,
66 foliageLight: 0xffd6e5,
67 foliage: 0xf5a3c0,
68 foliageDark: 0xd8739a,
69 accent: 0xffffff,
70 trunk: 0x5a3a2c,
71 skyTop: 0x3a3f7a,
72 skyLow: 0xe8a7c2,
73 moon: 0xffe6ef,
74 particles: [0xffc2d8, 0xf59bbd, 0xffe4ee],
75 },
76 summer: {
77 ground: 0x5fae4a,
78 groundShade: 0x3c7f32,
79 foliageLight: 0x8fd16a,
80 foliage: 0x4fa83d,
81 foliageDark: 0x2f7a2b,
82 accent: 0xf2e86b,
83 trunk: 0x5b3b25,
84 skyTop: 0x2d64b3,
85 skyLow: 0x9fd4f2,
86 moon: 0xffe9a3,
87 particles: [0xf8f27a, 0xd9f56b, 0xfff6b8],
88 },
89 autumn: {
90 ground: 0xb9984f,
91 groundShade: 0x7d6233,
92 foliageLight: 0xf7b955,
93 foliage: 0xe0752e,
94 foliageDark: 0xa8401f,
95 accent: 0xf2d04e,
96 trunk: 0x4f3322,
97 skyTop: 0x3b2a4f,
98 skyLow: 0xe59a5a,
99 moon: 0xffb866,
100 particles: [0xe8742c, 0xd14b23, 0xf2b441, 0x9b5a2a],
101 },
102}
103
104function blendPalettes(options: { from: SeasonPalette; to: SeasonPalette; amount: number }): SeasonPalette {
105 const { from, to, amount } = options
106 const mix = (start: Color, end: Color): Color => mixColors({ from: start, to: end, amount })
107 return {
108 ground: mix(from.ground, to.ground),
109 groundShade: mix(from.groundShade, to.groundShade),
110 foliageLight: mix(from.foliageLight, to.foliageLight),
111 foliage: mix(from.foliage, to.foliage),
112 foliageDark: mix(from.foliageDark, to.foliageDark),
113 accent: mix(from.accent, to.accent),
114 trunk: mix(from.trunk, to.trunk),
115 skyTop: mix(from.skyTop, to.skyTop),
116 skyLow: mix(from.skyLow, to.skyLow),
117 moon: mix(from.moon, to.moon),
118 particles: amount < 0.5 ? from.particles : to.particles,
119 }
120}
121
122function seasonName(index: number): SeasonName {
123 return ORDER[((index % ORDER.length) + ORDER.length) % ORDER.length] ?? ORDER[0]
124}
125
126export function seasonAt(epochMs: number): Season {
127 const index = Math.floor(epochMs / SEASON_MS)
128 const intoSeasonMs = epochMs - index * SEASON_MS
129 const name = seasonName(index)
130 const previous = seasonName(index - 1)
131 const target = SEASON_PALETTES[name]
132 const palette =
133 intoSeasonMs < BLEND_MS
134 ? blendPalettes({ from: SEASON_PALETTES[previous], to: target, amount: intoSeasonMs / BLEND_MS })
135 : target
136 return {
137 name,
138 label: LABELS[name],
139 progress: intoSeasonMs / SEASON_MS,
140 particle: PARTICLES[name],
141 palette,
142 }
143}
144
145export function nextSeasonIn(epochMs: number): { name: SeasonName; label: string; remainingMs: number } {
146 const index = Math.floor(epochMs / SEASON_MS)
147 const name = seasonName(index + 1)
148 return { name, label: LABELS[name], remainingMs: (index + 1) * SEASON_MS - epochMs }
149}
150
151export function startOfSeason(options: { name: SeasonName; cycle?: number }): number {
152 return (ORDER.indexOf(options.name) + (options.cycle ?? 0) * ORDER.length) * SEASON_MS
153}
154hooks/shared/pixel/weather.ts 149 lines1import { setPixel } from './bitmap'
2import type { Bitmap, Color } from './bitmap'
3import type { ParticleKind, Season } from './seasons'
4
5export type Area = { x: number; y: number; width: number; height: number }
6
7type Particle = {
8 kind: ParticleKind
9 x: number
10 y: number
11 speedX: number
12 speedY: number
13 phase: number
14 color: Color
15 ageMs: number
16 lifeMs: number
17 restMs: number
18}
19
20type KindStyle = { perSecond: number; fallMin: number; fallMax: number; restMs: number; fromSources: number }
21
22const REFERENCE_WIDTH = 72
23
24const STYLES: Record<ParticleKind, KindStyle> = {
25 snow: { perSecond: 2.6, fallMin: 3, fallMax: 6, restMs: 4500, fromSources: 0 },
26 petal: { perSecond: 1.8, fallMin: 4, fallMax: 7, restMs: 5000, fromSources: 0.6 },
27 firefly: { perSecond: 0.9, fallMin: 0, fallMax: 0, restMs: 0, fromSources: 0 },
28 leaf: { perSecond: 1.4, fallMin: 6, fallMax: 10, restMs: 9000, fromSources: 0.75 },
29}
30
31function seeded(seed: number): () => number {
32 let state = seed >>> 0 || 1
33 return () => {
34 state = (Math.imul(state, 1664525) + 1013904223) >>> 0
35 return state / 4294967296
36 }
37}
38
39export function createWeather(options: { seed: number; density?: number }) {
40 const random = seeded(options.seed)
41 const density = options.density ?? 1
42 let particles: Particle[] = []
43 let spawnDebt = 0
44
45 function pick<T>(list: readonly [T, ...T[]]): T {
46 return list[Math.floor(random() * list.length)] ?? list[0]
47 }
48
49 function spawn(spawnOptions: { season: Season; width: number; groundY: number; sources: readonly Area[] }): void {
50 const { season, width, groundY, sources } = spawnOptions
51 const kind = season.particle
52 const style = STYLES[kind]
53 const source = random() < style.fromSources ? sources[Math.floor(random() * sources.length)] : undefined
54 const base = {
55 kind,
56 phase: random() * Math.PI * 2,
57 color: pick(season.palette.particles),
58 ageMs: 0,
59 restMs: 0,
60 }
61 if (kind === 'firefly') {
62 particles.push({
63 ...base,
64 x: random() * width,
65 y: 2 + random() * Math.max(1, groundY - 4),
66 speedX: (random() - 0.5) * 4,
67 speedY: (random() - 0.5) * 2,
68 lifeMs: 6000 + random() * 5000,
69 })
70 return
71 }
72 particles.push({
73 ...base,
74 x: source ? source.x + random() * source.width : random() * (width + 12),
75 y: source ? source.y + random() * source.height : -1,
76 speedX: kind === 'snow' ? 0 : -1.5 - random() * 2.5,
77 speedY: style.fallMin + random() * (style.fallMax - style.fallMin),
78 lifeMs: Number.POSITIVE_INFINITY,
79 })
80 }
81
82 function move(moveOptions: { particle: Particle; dtMs: number; groundY: number }): void {
83 const { particle, dtMs, groundY } = moveOptions
84 const seconds = dtMs / 1000
85 particle.ageMs += dtMs
86 if (particle.restMs > 0) {
87 particle.restMs -= dtMs
88 return
89 }
90 const sway = Math.sin(particle.ageMs / 420 + particle.phase)
91 if (particle.kind === 'firefly') {
92 particle.speedX += (Math.sin(particle.ageMs / 900 + particle.phase) - particle.speedX * 0.3) * seconds
93 particle.speedY += (Math.cos(particle.ageMs / 700 + particle.phase) - particle.speedY * 0.4) * seconds
94 particle.x += particle.speedX * seconds * 3
95 particle.y = Math.min(groundY - 2, Math.max(1, particle.y + particle.speedY * seconds * 2))
96 return
97 }
98 const flutter = particle.kind === 'snow' ? 1.6 : particle.kind === 'petal' ? 3.5 : 5
99 particle.x += (particle.speedX + sway * flutter) * seconds
100 particle.y += particle.speedY * seconds
101 if (particle.y >= groundY - 1) {
102 particle.y = groundY - 1
103 particle.restMs = STYLES[particle.kind].restMs
104 particle.lifeMs = particle.ageMs + particle.restMs
105 }
106 }
107
108 return {
109 advance(advanceOptions: {
110 dtMs: number
111 season: Season
112 width: number
113 groundY: number
114 sources?: readonly Area[]
115 }): void {
116 const { dtMs, season, width, groundY } = advanceOptions
117 const sources = advanceOptions.sources ?? []
118 spawnDebt += (dtMs / 1000) * STYLES[season.particle].perSecond * density * (width / REFERENCE_WIDTH)
119 while (spawnDebt >= 1) {
120 spawnDebt -= 1
121 spawn({ season, width, groundY, sources })
122 }
123 particles.forEach(particle => move({ particle, dtMs, groundY }))
124 particles = particles.filter(
125 particle => particle.ageMs < particle.lifeMs && particle.x > -4 && particle.x < width + 16,
126 )
127 },
128
129 draw(bitmap: Bitmap): void {
130 particles.forEach(particle => {
131 if (particle.kind === 'firefly' && Math.sin(particle.ageMs / 260 + particle.phase) < 0.1) {
132 return
133 }
134 setPixel({ bitmap, x: particle.x, y: particle.y, color: particle.color })
135 const isWide = particle.kind === 'leaf' || (particle.kind === 'petal' && particle.restMs > 0)
136 if (isWide && Math.sin(particle.ageMs / 300 + particle.phase) > 0) {
137 setPixel({ bitmap, x: particle.x + 1, y: particle.y, color: particle.color })
138 }
139 })
140 },
141
142 count(): number {
143 return particles.length
144 },
145 }
146}
147
148export type Weather = ReturnType<typeof createWeather>
149hooks/draw/scene.ts 48 lines1import type { Bitmap } from '../shared/pixel/bitmap'
2import type { Season } from '../shared/pixel/seasons'
3import { drawSeasonTree } from '../shared/pixel/tree'
4import type { Weather } from '../shared/pixel/weather'
5import { FLOOR_TOP } from '../sim/constants'
6import type { Rank } from '../sim/rank'
7import type { Duel, Effect, Monster, Samurai } from '../sim/types'
8import { drawBackdrop } from './backdrop'
9import { drawEffect } from './effects'
10import { drawKillCount } from './hud'
11import { drawMonster } from './monsters'
12import { drawRival } from './rival'
13import { drawSamurai } from './samurai'
14
15export function treeCenterOf(width: number): number {
16 return Math.max(24, width - 22)
17}
18
19export function drawScene(options: {
20 bitmap: Bitmap
21 monsters: readonly Monster[]
22 effects: readonly Effect[]
23 samurai: Samurai
24 duel: Duel | undefined
25 totalKills: number
26 rank: Rank
27 season: Season
28 weather: Weather
29}): void {
30 const { bitmap, rank, season, samurai, duel } = options
31 drawBackdrop({ bitmap, season })
32 drawSeasonTree({
33 bitmap,
34 centerX: treeCenterOf(bitmap.width),
35 groundY: FLOOR_TOP,
36 season,
37 clockMs: samurai.clockMs,
38 })
39 drawKillCount({ bitmap, total: options.totalKills, iconColor: rank.bright })
40 options.monsters.forEach(monster => drawMonster({ bitmap, monster }))
41 if (duel) {
42 drawRival({ bitmap, duel, clockMs: samurai.clockMs })
43 }
44 drawSamurai({ bitmap, samurai, rank, duel })
45 options.weather.draw(bitmap)
46 options.effects.forEach(effect => drawEffect({ bitmap, effect }))
47}
48hooks/sim/ambient.ts 179 lines1import type { Season } from '../shared/pixel/seasons'
2import { FLOOR_TOP, HOME_X, KI_INTERVAL_MS, SAMURAI_TOP, WAVE_DOOM_DELAY_MS, WAVE_STAGGER_MS } from './constants'
3import { advanceDuel, attackerOf, createDuel, retreat } from './duel'
4import { describeWave, MEDITATION_LINES, pickWave } from './waves'
5import type { Activity, Duel, Effect, Monster, MonsterKind, Samurai } from './types'
6
7type AmbientKind = 'kata' | 'meditate' | 'duel' | 'wave' | 'rest'
8
9export type AmbientWorld = {
10 samurai: Samurai
11 monsters: readonly Monster[]
12 effects: Effect[]
13 columns: number
14 season: Season
15 spawn: (plan: { kind: MonsterKind; isElite: boolean; x: number }) => number
16 doom: (id: number) => void
17 note: (line: string) => void
18}
19
20const WEIGHTS: readonly { kind: AmbientKind; weight: number }[] = [
21 { kind: 'duel', weight: 3 },
22 { kind: 'wave', weight: 3 },
23 { kind: 'meditate', weight: 2 },
24 { kind: 'kata', weight: 2 },
25]
26
27const ACTIVITY: Record<AmbientKind, Activity> = {
28 kata: 'kata',
29 meditate: 'meditate',
30 duel: 'duel',
31 wave: 'guard',
32 rest: 'guard',
33}
34
35const KATA_LINES = ['Practices kata', 'Drills the rising cut', 'Drills the falling cut']
36
37function seeded(seed: number): () => number {
38 let state = Math.imul(seed ^ 0x9e3779b9, 2654435761) >>> 0 || 7
39 return () => {
40 state = (Math.imul(state, 1664525) + 1013904223) >>> 0
41 return state / 4294967296
42 }
43}
44
45export function createAmbient(options: { seed: number }) {
46 let random = seeded(options.seed)
47 let current: { kind: AmbientKind; ms: number; durationMs: number } = { kind: 'rest', ms: 0, durationMs: 600 }
48 let previous: AmbientKind = 'rest'
49 let duel: Duel | undefined
50 let dooms: { id: number; atMs: number }[] = []
51 let sinceKiMs = 0
52
53 function choose(): AmbientKind {
54 const choices = WEIGHTS.filter(entry => entry.kind !== previous)
55 const total = choices.reduce((sum, entry) => sum + entry.weight, 0)
56 let roll = random() * total
57 for (const entry of choices) {
58 roll -= entry.weight
59 if (roll < 0) {
60 return entry.kind
61 }
62 }
63 return 'kata'
64 }
65
66 function begin(world: AmbientWorld): void {
67 const nextKind = current.kind === 'rest' ? choose() : 'rest'
68 if (current.kind !== 'rest') {
69 previous = current.kind
70 }
71 current = { kind: nextKind, ms: 0, durationMs: 0 }
72 world.samurai.activity = ACTIVITY[nextKind]
73 world.samurai.trainingMs = 0
74 if (nextKind === 'rest') {
75 current.durationMs = 500 + random() * 600
76 } else if (nextKind === 'kata') {
77 current.durationMs = 5000 + random() * 2500
78 world.note(KATA_LINES[Math.floor(random() * KATA_LINES.length)] ?? 'Practices kata')
79 } else if (nextKind === 'meditate') {
80 current.durationMs = 7000 + random() * 3000
81 world.note(MEDITATION_LINES[world.season.name])
82 } else if (nextKind === 'duel') {
83 duel = createDuel({ columns: world.columns, isWin: random() < 0.7, exchanges: 3 + Math.floor(random() * 4) })
84 world.note('A wandering ronin challenges the samurai')
85 } else {
86 const wave = pickWave(random)
87 dooms = wave.map((plan, index) => ({
88 id: world.spawn({ ...plan, x: world.columns + 2 + index * 15 }),
89 atMs: WAVE_DOOM_DELAY_MS + index * WAVE_STAGGER_MS,
90 }))
91 world.note(describeWave(wave))
92 }
93 }
94
95 function advanceDuelIn(step: { world: AmbientWorld; dtMs: number }): boolean {
96 const { world, dtMs } = step
97 if (!duel) {
98 return true
99 }
100 const outcome = advanceDuel({ duel, dtMs, columns: world.columns })
101 if (outcome.didClash) {
102 const lean = attackerOf(duel) === 'samurai' ? 1 : -1
103 world.effects.push({ kind: 'clang', x: HOME_X + 20 + lean, y: FLOOR_TOP - 8, ageMs: 0 })
104 }
105 if (outcome.didFinishStrike) {
106 world.effects.push({ kind: 'hit', x: duel.rivalX + 4, y: FLOOR_TOP - 7, ageMs: 0 })
107 world.note('One clean cut ends the duel')
108 }
109 if (outcome.didBow) {
110 world.note('The duel ends in a bow')
111 }
112 if (outcome.isOver) {
113 duel = undefined
114 return true
115 }
116 return false
117 }
118
119 return {
120 reseed(seed: number): void {
121 random = seeded(seed)
122 },
123
124 duel(): Duel | undefined {
125 return duel
126 },
127
128 interrupt(world: AmbientWorld): void {
129 if (duel && !duel.isRetreat && duel.phase !== 'leave' && !duel.hasFinishStruck) {
130 retreat(duel)
131 world.note('The ronin flees: real work arrives')
132 }
133 dooms.forEach(entry => world.doom(entry.id))
134 dooms = []
135 if (current.kind !== 'rest') {
136 previous = current.kind
137 current = { kind: 'rest', ms: 0, durationMs: 400 }
138 world.samurai.activity = 'guard'
139 }
140 },
141
142 advance(step: { world: AmbientWorld; dtMs: number; hasWork: boolean }): void {
143 const { world, dtMs, hasWork } = step
144 const isDuelDone = advanceDuelIn({ world, dtMs })
145 if (hasWork || world.samurai.mode !== 'train') {
146 return
147 }
148 current.ms += dtMs
149 dooms = dooms.filter(entry => {
150 if (current.ms < entry.atMs) {
151 return true
152 }
153 world.doom(entry.id)
154 return false
155 })
156 if (current.kind === 'meditate') {
157 sinceKiMs += dtMs
158 if (sinceKiMs >= KI_INTERVAL_MS) {
159 sinceKiMs = 0
160 world.effects.push({ kind: 'ki', x: world.samurai.x + 3 + random() * 9, y: SAMURAI_TOP + 6, ageMs: 0 })
161 }
162 }
163 const isWaveDone =
164 current.kind === 'wave' && dooms.length === 0 && !world.monsters.some(monster => monster.isAmbient && monster.phase === 'alive')
165 const isDone =
166 current.kind === 'duel'
167 ? isDuelDone
168 : current.kind === 'wave'
169 ? isWaveDone && current.ms > 1000
170 : current.ms >= current.durationMs
171 if (isDone) {
172 begin(world)
173 }
174 },
175 }
176}
177
178export type Ambient = ReturnType<typeof createAmbient>
179