A vampire feeds on every tool call Claude finishes while the moon tracks how full the context window is. Between meals it stalks, swoops, and lies in wait…

A vampire hunts a small village beside your transcript, and the night sky is your context window.

claude plugin marketplace add theonly1me/claude-code-mods
claude plugin install night-feast@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 game is hidden until you turn it on.
/feast shows the night. Run it again to hide it./feast on and /feast off set it directly./feast stats prints the night, the context fill, the blood level, your feeds, and garlic hits, and leaves the pane as it is.When you turn the night on, it comes back in each new session until you turn it off. Only one game mod shows at a time: if you turn on another game (for example /dojo or /farm), the night closes, and the other game is the one that comes back next session.
In fullscreen mode (CLAUDE_CODE_NO_FLICKER=1) on a terminal 110 columns or wider, the night docks in a column beside the transcript, with a log of what the vampire did under the scene. Otherwise it sits in a pane above the prompt. A pane that opens by itself at session start needs a wide terminal (144 columns); /feast shows it at any width. Closing the pane with its close mark also turns the night off. The desktop app shows a one-line summary.
There is nothing to control. The game plays by itself.
Your work drives the score:
/compact before the sun comes up.Between meals the village never stands still. The vampire stalks down the lane, roosts on the castle tower to watch the street, swoops low over the rooftops, hides in the shadows with only its eyes showing, and lies in wait for a lantern bearer who runs for the nearest door. Clouds drift across the moon, an owl on a dead tree turns its head, bats circle the moon, and a black cat prowls the rooftops. This ambient action is only for show: blood, feeds, garlic, and nights change only from real work and the context window.
See the marketplace README for configuration, privacy, updates, and removal.
hooks/register.tsx 122 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, 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 { createNight } from './sim/night'
7
8const LIFETIME_KEY = 'lifetimeFeeds'
9
10const sessionAtom = atom({ plugin: 'night-feast', key: 'session' } as const, { feeds: 0, garlic: 0, night: 1 })
11const night = createNight()
12
13function report(): string[] {
14 const stats = night.stats()
15 const dawn = stats.percent !== null && stats.percent >= 80 ? ' Dawn is close: consider /compact.' : ''
16 return [night.summary(), `Lifetime feeds: ${stats.lifetimeFeeds}. Garlic this session: ${stats.garlic}.${dawn}`]
17}
18
19function applyMeasure($: EngineInterface, options: { percent: number | null }): void {
20 const outcome = night.measure(options.percent)
21 if (outcome.toast !== undefined) {
22 $.ui.toast(outcome.toast)
23 }
24 if (outcome.isNewNight) {
25 $.ui.toast(`Night ${night.stats().night} begins. The context was compacted.`)
26 }
27}
28
29async function saveProgress($: EngineInterface): Promise<void> {
30 const stats = night.stats()
31 await update($, sessionAtom, () => ({ feeds: stats.feeds, garlic: stats.garlic, night: stats.night }))
32 await $.store.set(LIFETIME_KEY, stats.lifetimeFeeds)
33}
34
35export const register: Register = on => {
36 configureStage({
37 game: 'night-feast',
38 title: 'Night Feast',
39 command: {
40 name: 'feast',
41 description: 'Show or hide the night feast (on, off, stats)',
42 report,
43 },
44 rows: STAGE_ROWS,
45 minColumns: MIN_COLUMNS,
46 maxColumns: MAX_COLUMNS,
47 frameMs: FRAME_MS,
48 scene: {
49 begin: () => undefined,
50 resize: columns => night.resize(columns),
51 tick: dtMs => {
52 night.tick({ dtMs })
53 },
54 frame: () => night.frame(),
55 summary: () => night.summary(),
56 log: () => night.log(),
57 },
58 })
59 installStage(on)
60
61 on('session.start', async ($, e, next) => {
62 const saved = await read($, sessionAtom)
63 night.restore({ ...saved, lifetimeFeeds: Number((await $.store.get(LIFETIME_KEY)) ?? 0) })
64 const usage = await $.session.usage().catch(() => undefined)
65 applyMeasure($, { percent: usage?.context.percent ?? null })
66 return next(e)
67 })
68
69 on('session.measure', ($, e, next) => {
70 if (e.changed.includes('context')) {
71 applyMeasure($, { percent: e.context.percent ?? null })
72 }
73 return next(e)
74 })
75
76 on('classic.SessionStart', ($, e, next) => {
77 if (e.source === 'compact') {
78 night.compacted()
79 }
80 return next(e)
81 })
82
83 on('prompt.submit', ($, e, next) => {
84 if (e.origin.kind === 'composer' && e.turnId === undefined) {
85 night.arrive()
86 }
87 return next(e)
88 })
89
90 on('tool.call', async ($, e, next) => {
91 try {
92 const ran = await next(e)
93 if (ran.deny !== undefined || ran.isError === true) {
94 night.garlic(e.tool)
95 } else {
96 night.feed(e.tool)
97 }
98 return ran
99 } catch (error) {
100 night.garlic(e.tool)
101 throw error
102 }
103 })
104
105 on('turn.complete', async ($, e, next) => {
106 if (e.agentId === undefined) {
107 night.celebrate()
108 const usage = await $.session.usage().catch(() => undefined)
109 if (usage) {
110 applyMeasure($, { percent: usage.context.percent ?? null })
111 }
112 await saveProgress($)
113 }
114 return next(e)
115 })
116
117 on('session.end', async ($, e, next) => {
118 await $.store.set(LIFETIME_KEY, night.stats().lifetimeFeeds)
119 return next(e)
120 })
121}
122hooks/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 49 lines1export const FRAME_MS = 40
2export const STAGE_ROWS = 8
3export const STAGE_HEIGHT = STAGE_ROWS * 2
4export const MIN_COLUMNS = 44
5export const MAX_COLUMNS = 72
6export const FLOOR_TOP = 13
7export const FEET_Y = 12
8
9export const HOME_X = 17
10export const VILLAGE_LEFT = 22
11export const CASTLE_WIDTH = 18
12export const MOON_LEFT = 22
13export const MOON_RIGHT_MARGIN = 26
14
15export const BAT_SPEED_PIXELS_PER_SECOND = 70
16export const VILLAGER_SPEED_PIXELS_PER_SECOND = 6
17export const DIZZY_SPEED_PIXELS_PER_SECOND = 9
18export const POOF_MS = 260
19export const FEED_MS = 700
20export const RECOIL_MS = 650
21export const PERCH_MS = 2400
22export const RECOIL_PIXELS = 4
23export const WALK_FRAME_MS = 260
24
25export const FEED_BLOOD = 14
26export const GARLIC_BLOOD = 18
27export const HUNGER_MS = 15000
28export const MAX_QUEUED_FEEDS = 6
29export const MIN_VILLAGERS = 2
30export const MAX_VILLAGERS = 4
31
32export const WARM_PERCENT = 80
33export const DAWN_PERCENT = 90
34export const NEW_NIGHT_DROP = 25
35export const VIAL_DROP_X = 2
36export const VIAL_BOTTOM_Y = 9
37export const FLEE_SPEED_PIXELS_PER_SECOND = 14
38export const STALK_SPEED_PIXELS_PER_SECOND = 9
39export const CRUISE_SPEED_PIXELS_PER_SECOND = 34
40export const IDLE_BETWEEN_MS = 900
41export const LOG_LIMIT = 8
42export const OWL_TREE_X = 10
43
44export const HOUSES: readonly { offset: number; width: number; door: number }[] = [
45 { offset: 1, width: 7, door: 3 },
46 { offset: 10, width: 9, door: 4 },
47 { offset: 21, width: 7, door: 3 },
48]
49hooks/sim/night.ts 198 lines1import { drawScene } from '../draw/scene'
2import { clearBitmap, createBitmap } from '../shared/pixel/bitmap'
3import type { Bitmap } from '../shared/pixel/bitmap'
4import { createDirector } from './ambient'
5import { CASTLE_WIDTH, FEED_BLOOD, FEET_Y, GARLIC_BLOOD, HUNGER_MS, MAX_COLUMNS, MAX_QUEUED_FEEDS, STAGE_HEIGHT, VIAL_BOTTOM_Y, VIAL_DROP_X } from './constants'
6import { createEffects } from './effects'
7import { createJournal } from './journal'
8import { ACTIVITY_LINES, describeDoing } from './moves'
9import { createPopulation } from './population'
10import { createScenery, VIGNETTE_LINES } from './scenery'
11import { createSky } from './sky'
12import type { MeasureOutcome, NightStats } from './types'
13import { advanceVampire, createVampire, isAmbient, recoil } from './vampire'
14
15const PERCH_OFFSET = 12
16
17export function createNight(options: { seed?: number } = {}) {
18 const seed = options.seed ?? 7
19 const sky = createSky()
20 const vampire = createVampire()
21 const director = createDirector(seed)
22 const scenery = createScenery(seed + 11)
23 const journal = createJournal()
24 const population = createPopulation()
25 const effects = createEffects()
26 let queuedLabels: string[] = []
27 let isPerchRequested = false
28 let blood = 50
29 let feeds = 0
30 let garlicHits = 0
31 let lifetimeFeeds = 0
32 let finishedFeeds = 0
33 let hungerMs = 0
34 let columns = MAX_COLUMNS
35 let bitmap = createBitmap({ width: columns, height: STAGE_HEIGHT })
36
37 function completeFeed(label: string | undefined): void {
38 feeds += 1
39 lifetimeFeeds += 1
40 finishedFeeds += 1
41 blood = Math.min(100, blood + FEED_BLOOD)
42 journal.push(label ? `Fed on a villager after ${label}` : 'Fed on a villager')
43 }
44
45 function noteMeasure(outcome: MeasureOutcome): MeasureOutcome {
46 if (outcome.isNewNight) {
47 journal.push(`Night ${sky.state().night} begins after a compaction`)
48 }
49 return outcome
50 }
51
52 function runAmbient(dtMs: number): void {
53 const isBusy = queuedLabels.length > 0 || isPerchRequested || !(vampire.mode === 'idle' || isAmbient(vampire))
54 if (isBusy) {
55 director.cancel(vampire)
56 return
57 }
58 const step = director.advance({ vampire, dtMs, villagers: population.villagers(), width: columns })
59 if (step.started) {
60 journal.push(ACTIVITY_LINES[step.started])
61 }
62 if (step.didTransform) {
63 effects.add({ kind: 'poof', x: Math.round(vampire.x) + 6, y: FEET_Y - 5 - Math.round(vampire.lift) })
64 }
65 if (step.wantsPrey) {
66 const prey = population.prey({ width: columns, nearX: vampire.x })
67 if (prey !== undefined) {
68 director.setPrey(prey)
69 }
70 }
71 const scared = step.scareId === undefined ? undefined : population.scare({ id: step.scareId, width: columns, fromX: vampire.x + 6 })
72 if (scared) {
73 effects.add({ kind: 'alarm', x: Math.round(scared.x) + 2, y: FEET_Y - 10 })
74 journal.push('The vampire pounces and the lantern bearer runs')
75 }
76 }
77
78 return {
79 resize(nextColumns: number): void {
80 if (nextColumns !== columns) {
81 columns = nextColumns
82 bitmap = createBitmap({ width: columns, height: STAGE_HEIGHT })
83 }
84 },
85
86 restore(saved: { lifetimeFeeds: number; feeds: number; garlic: number; night: number }): void {
87 lifetimeFeeds = saved.lifetimeFeeds
88 feeds = saved.feeds
89 garlicHits = saved.garlic
90 sky.restoreNight(saved.night)
91 },
92
93 measure(percent: number | null): MeasureOutcome {
94 return noteMeasure(sky.measure(percent))
95 },
96
97 compacted(): void {
98 const before = sky.state().night
99 sky.compacted()
100 if (sky.state().night !== before) {
101 journal.push(`Night ${sky.state().night} begins after a compaction`)
102 }
103 },
104
105 feed(label?: string): void {
106 if (queuedLabels.length >= MAX_QUEUED_FEEDS) {
107 completeFeed(label)
108 return
109 }
110 queuedLabels = [...queuedLabels, label ?? '']
111 },
112
113 garlic(label?: string): void {
114 garlicHits += 1
115 blood = Math.max(0, blood - GARLIC_BLOOD)
116 director.cancel(vampire)
117 effects.add({ kind: 'fume', x: Math.round(vampire.x) + (vampire.facing === 1 ? 11 : -3), y: FEET_Y - 3 })
118 effects.add({ kind: 'drop', x: VIAL_DROP_X, y: VIAL_BOTTOM_Y })
119 recoil(vampire)
120 journal.push(label ? `Garlic: ${label} failed` : 'Garlic: a tool call failed')
121 },
122
123 arrive(): void {
124 population.spawn({ width: columns })
125 journal.push('A villager arrives with your prompt')
126 },
127
128 celebrate(): void {
129 isPerchRequested = true
130 journal.push('Turn done: the vampire spreads its cape at the castle')
131 },
132
133 stats(): NightStats {
134 const { percent, night } = sky.state()
135 return { blood, feeds, garlic: garlicHits, lifetimeFeeds, night, percent }
136 },
137
138 summary(): string {
139 const { percent, night } = sky.state()
140 const skyText = percent === null ? 'dusk' : `context ${percent}%`
141 return `Night ${night} · ${skyText} · blood ${blood}% · ${feeds} feeds · ${describeDoing({ activity: director.current(), vampire })}`
142 },
143
144 log(): readonly string[] {
145 return journal.lines()
146 },
147
148 tick(tickOptions: { dtMs: number }): number {
149 const { dtMs } = tickOptions
150 hungerMs += dtMs
151 if (hungerMs >= HUNGER_MS) {
152 hungerMs = 0
153 blood = Math.max(0, blood - 1)
154 }
155 population.keepAround({ dtMs, width: columns, wanted: queuedLabels.length })
156 const target = queuedLabels.length > 0 ? population.nearestWalking(vampire.x) : undefined
157 const perchX = isPerchRequested && queuedLabels.length === 0 ? columns - CASTLE_WIDTH - PERCH_OFFSET : undefined
158 const step = advanceVampire({ vampire, dtMs, plan: { target, perchX }, target: population.find(vampire.targetId) })
159 if (vampire.landsPerched) {
160 isPerchRequested = false
161 }
162 if (step.didTransform) {
163 effects.add({ kind: 'poof', x: Math.round(vampire.x) + 6, y: FEET_Y - 5 - Math.round(vampire.lift) })
164 }
165 const victim = step.bittenId === undefined ? undefined : population.bite({ id: step.bittenId, fromX: vampire.x })
166 if (victim) {
167 effects.add({ kind: 'spark', x: Math.round(victim.x) + 2, y: FEET_Y - 4 })
168 }
169 if (step.didFinishFeed) {
170 const [label, ...rest] = queuedLabels
171 queuedLabels = rest
172 completeFeed(label === '' ? undefined : label)
173 }
174 runAmbient(dtMs)
175 const vignette = scenery.advance({ dtMs, width: columns })
176 if (vignette) {
177 journal.push(VIGNETTE_LINES[vignette])
178 }
179 population.advance({ dtMs, width: columns }).forEach(door => {
180 effects.add({ kind: 'light', x: door, y: FEET_Y - 3 })
181 journal.push('The lantern bearer slams a door just in time')
182 })
183 effects.advance(dtMs)
184 const finished = finishedFeeds
185 finishedFeeds = 0
186 return finished
187 },
188
189 frame(): Bitmap {
190 clearBitmap(bitmap)
191 drawScene({ bitmap, percent: sky.state().percent, blood, vampire, villagers: population.villagers(), effects: effects.list(), scenery: scenery.state() })
192 return bitmap
193 },
194 }
195}
196
197export type Night = ReturnType<typeof createNight>
198hooks/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/draw/scene.ts 33 lines1import type { Bitmap } from '../shared/pixel/bitmap'
2import { WARM_PERCENT } from '../sim/constants'
3import { dawnAmount } from '../sim/sky'
4import type { Effect, Scenery, Vampire, Villager } from '../sim/types'
5import { drawCat, drawOwlTree, drawSkyLife } from './ambient'
6import { drawHeavens, drawTown } from './backdrop'
7import { drawEffect } from './effects'
8import { drawHud } from './hud'
9import { drawVampire } from './vampire'
10import { drawVillager } from './villager'
11
12export function drawScene(options: {
13 bitmap: Bitmap
14 percent: number | null
15 blood: number
16 vampire: Vampire
17 villagers: readonly Villager[]
18 effects: readonly Effect[]
19 scenery: Scenery
20}): void {
21 const { bitmap, percent, vampire, scenery } = options
22 const clockMs = scenery.clockMs
23 drawHeavens({ bitmap, percent, dawn: dawnAmount(percent), clockMs })
24 drawSkyLife({ bitmap, scenery, percent })
25 drawTown({ bitmap, clockMs })
26 drawOwlTree({ bitmap, scenery })
27 drawCat({ bitmap, scenery })
28 options.villagers.forEach(villager => drawVillager({ bitmap, villager }))
29 drawVampire({ bitmap, vampire, isNervous: (percent ?? 0) >= WARM_PERCENT })
30 options.effects.forEach(effect => drawEffect({ bitmap, effect }))
31 drawHud({ bitmap, blood: options.blood, percent, clockMs })
32}
33hooks/sim/ambient.ts 94 lines1import { stepActivity } from './activities'
2import { CASTLE_WIDTH, HOME_X, IDLE_BETWEEN_MS, VILLAGE_LEFT } from './constants'
3import { quietStep } from './moves'
4import type { AmbientStep } from './moves'
5import { seededRandom, shuffled } from './random'
6import type { Activity, ActivityKind, Vampire, Villager } from './types'
7import { enter, isAmbient, isBat, returnTo } from './vampire'
8
9const KINDS: readonly ActivityKind[] = ['stalk', 'perch', 'swoop', 'shadow', 'chase']
10
11export function createDirector(seed: number) {
12 const random = seededRandom(seed)
13 let queue: ActivityKind[] = []
14 let activity: Activity | undefined
15 let last: ActivityKind | undefined
16 let idleMs = 0
17
18 function nextKind(): ActivityKind {
19 if (queue.length === 0) {
20 queue = shuffled({ items: KINDS, random, avoidFirst: last })
21 }
22 const [kind = 'stalk', ...rest] = queue
23 queue = rest
24 last = kind
25 return kind
26 }
27
28 function goalFor(options: { kind: ActivityKind; width: number }): number {
29 const streetEnd = options.width - CASTLE_WIDTH - 16
30 if (options.kind === 'stalk') {
31 return Math.max(HOME_X + 4, Math.min(streetEnd, HOME_X + 12 + Math.floor(random() * 8)))
32 }
33 if (options.kind === 'shadow') {
34 return Math.max(HOME_X + 4, Math.min(streetEnd, VILLAGE_LEFT + 6))
35 }
36 return options.kind === 'perch' ? options.width - CASTLE_WIDTH - 3 : HOME_X
37 }
38
39 function run(options: { vampire: Vampire; current: Activity; dtMs: number; villagers: readonly Villager[]; width: number }): AmbientStep {
40 const result = stepActivity({ ...options, activity: options.current })
41 if (result.isDone) {
42 activity = undefined
43 idleMs = 0
44 }
45 return result.step
46 }
47
48 return {
49 current(): Activity | undefined {
50 return activity
51 },
52
53 setPrey(id: number): void {
54 if (activity) {
55 activity.preyId = id
56 }
57 },
58
59 cancel(vampire: Vampire): void {
60 if (!activity) {
61 return
62 }
63 activity = undefined
64 idleMs = 0
65 if (!isAmbient(vampire)) {
66 return
67 }
68 if (isBat(vampire)) {
69 returnTo({ vampire, x: HOME_X, landsPerched: false })
70 } else {
71 enter({ vampire, mode: 'idle' })
72 }
73 },
74
75 advance(options: { vampire: Vampire; dtMs: number; villagers: readonly Villager[]; width: number }): AmbientStep {
76 const { vampire, dtMs, width } = options
77 if (activity) {
78 activity.stageMs += dtMs
79 return run({ ...options, current: activity })
80 }
81 idleMs += dtMs
82 if (vampire.mode !== 'idle' || idleMs < IDLE_BETWEEN_MS) {
83 return quietStep()
84 }
85 const kind = nextKind()
86 const current: Activity = { kind, stage: 0, stageMs: 0, originX: vampire.x, goalX: goalFor({ kind, width }), preyId: undefined }
87 activity = current
88 return { ...run({ ...options, current }), started: kind }
89 },
90 }
91}
92
93export type Director = ReturnType<typeof createDirector>
94hooks/sim/effects.ts 24 lines1import type { Effect, EffectKind } from './types'
2
3export const EFFECT_MS: Record<EffectKind, number> = { poof: 300, spark: 650, fume: 1100, drop: 600, alarm: 900, light: 1000 }
4
5export function createEffects() {
6 let effects: Effect[] = []
7 let count = 0
8 return {
9 add(effect: { kind: EffectKind; x: number; y: number }): void {
10 count += 1
11 effects.push({ ...effect, ageMs: 0, seed: (count * 1.37) % 6.28 })
12 },
13 advance(dtMs: number): void {
14 effects.forEach(effect => {
15 effect.ageMs += dtMs
16 })
17 effects = effects.filter(effect => effect.ageMs < EFFECT_MS[effect.kind])
18 },
19 list(): readonly Effect[] {
20 return effects
21 },
22 }
23}
24hooks/sim/journal.ts 16 lines1import { LOG_LIMIT } from './constants'
2
3export function createJournal() {
4 let lines: string[] = []
5 return {
6 push(line: string): void {
7 lines = [line, ...lines].slice(0, LOG_LIMIT)
8 },
9 lines(): readonly string[] {
10 return lines
11 },
12 }
13}
14
15export type Journal = ReturnType<typeof createJournal>
16hooks/sim/moves.ts 88 lines1import { CRUISE_SPEED_PIXELS_PER_SECOND, STALK_SPEED_PIXELS_PER_SECOND } from './constants'
2import type { Activity, ActivityKind, Vampire } from './types'
3import { enter } from './vampire'
4
5export type AmbientStep = {
6 didTransform: boolean
7 started: ActivityKind | undefined
8 wantsPrey: boolean
9 scareId: number | undefined
10}
11
12export type StepResult = { step: AmbientStep; isDone: boolean }
13
14export const ACTIVITY_LINES: Record<ActivityKind, string> = {
15 stalk: 'The vampire stalks down the lane',
16 perch: 'Perched on the castle tower, watching the village',
17 swoop: 'Swooping low over the rooftops',
18 shadow: 'Hiding in the shadows by the cottages',
19 chase: 'Lying in wait for a lantern bearer',
20}
21
22export const ACTIVITY_LABELS: Record<ActivityKind, string> = {
23 stalk: 'stalking the lane',
24 perch: 'on the tower',
25 swoop: 'swooping',
26 shadow: 'in the shadows',
27 chase: 'lying in wait',
28}
29
30export function describeDoing(options: { activity: Activity | undefined; vampire: Vampire }): string {
31 if (options.activity) {
32 return ACTIVITY_LABELS[options.activity.kind]
33 }
34 const { mode } = options.vampire
35 if (mode === 'flying' || mode === 'feeding') {
36 return 'feeding'
37 }
38 return mode === 'perched' ? 'at the castle' : 'watching the street'
39}
40
41export function quietStep(): AmbientStep {
42 return { didTransform: false, started: undefined, wantsPrey: false, scareId: undefined }
43}
44
45export function walkToward(options: { vampire: Vampire; x: number; dtMs: number }): boolean {
46 const { vampire } = options
47 const distance = options.x - vampire.x
48 const travel = (STALK_SPEED_PIXELS_PER_SECOND * options.dtMs) / 1000
49 if (distance !== 0) {
50 vampire.facing = distance > 0 ? 1 : -1
51 }
52 vampire.x += Math.sign(distance) * Math.min(Math.abs(distance), travel)
53 return Math.abs(options.x - vampire.x) < 0.5
54}
55
56export function glide(options: { vampire: Vampire; activity: Activity; dtMs: number; fromLift: number; toLift: number }): boolean {
57 const { vampire, activity } = options
58 const distance = activity.goalX - vampire.x
59 const travel = (CRUISE_SPEED_PIXELS_PER_SECOND * options.dtMs) / 1000
60 if (distance !== 0) {
61 vampire.facing = distance > 0 ? 1 : -1
62 }
63 vampire.x += Math.sign(distance) * Math.min(Math.abs(distance), travel)
64 const total = Math.max(1, Math.abs(activity.goalX - activity.originX))
65 const progress = 1 - Math.abs(activity.goalX - vampire.x) / total
66 const bob = Math.sin(vampire.clockMs / 120) * 0.6
67 vampire.lift = options.fromLift + (options.toLift - options.fromLift) * progress + bob
68 return Math.abs(activity.goalX - vampire.x) < 0.5
69}
70
71export function moveStage(options: { activity: Activity; stage: number; vampire: Vampire; goalX?: number }): void {
72 options.activity.stage = options.stage
73 options.activity.stageMs = 0
74 options.activity.originX = options.vampire.x
75 options.activity.goalX = options.goalX ?? options.activity.goalX
76}
77
78export function takeOff(options: { activity: Activity; stage: number; vampire: Vampire; goalX?: number }): void {
79 enter({ vampire: options.vampire, mode: 'cruising' })
80 moveStage(options)
81}
82
83export function land(vampire: Vampire): void {
84 vampire.lift = 0
85 vampire.facing = 1
86 enter({ vampire, mode: 'idle' })
87}
88