SLOPSHOPPER

night-feast

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…

newpaneguardcommandtoastprompt
★ 1v0.4.0MITupdated 2026-10-04theonly1me/claude-code-mods/plugins/night-feast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · night-feast
│ ┃ Night Feast ✕ › fix the failing auth test and add an audit log call │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⏺ Read(src/auth.ts) │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⎿ Read 6 lines │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⏺ Update(src/auth.ts) │ ┃ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ │ ┃ Night 1 · context 49% · blood 60% · 2 feeds… ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ Turn done: the vampire spreads its cape at … ✻ Worked for 42s · done 4:20 PM │ ┃ Fed on a villager after Bash │ ┃ Fed on a villager after Bash › /feast │ ┃ Garlic: Bash failed ⎿ night-feast: Night Feast is on. It comes back each session until │ ┃ A villager arrives with your prompt ⎿ night-feast: Night 1 · context 49% · blood 60% · 2 feeds · watch │ ⎿ night-feast: Lifetime feeds: 2. Garlic this session: 1. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Night Feast
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ Night 1 · context 49% · blood 60% · 2 feeds · watching the … Turn done: the vampire spreads its cape at the castle Fed on a villager after Bash Fed on a villager after Bash Garlic: Bash failed A villager arrives with your prompt
README

Night Feast

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

Night Feast preview

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.

How to use

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.

How it plays

Your work drives the score:

  • The moon moves across the sky as the context fills. At 80% the horizon starts to warm, and at 90% dawn is near. A toast tells you to run /compact before the sun comes up.
  • Each tool call Claude finishes is a meal: the vampire turns into a bat, swoops down on a villager, and fills its blood vial. The villager wanders off dizzy.
  • A failed or denied tool call is garlic. The vampire recoils and loses blood.
  • Each prompt you send brings a new villager into the street.
  • When Claude finishes a turn, the vampire spreads its cape in front of the castle while bats circle.
  • A compaction ends the night. The sky resets to dusk and a new night begins.

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.

Source 34 files
hooks/register.tsx 122 lines
1import { 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}
122
hooks/shared/pixel/stage.tsx 199 lines
1import type { EngineInterface, On } from 'claude-code'
2
3import { noop } from '../noop'
4import type { Bitmap } from './bitmap'
5import { encodeCells } from './cells'
6
7export type StageScene = {
8  begin: (epochMs: number) => void
9  resize: (columns: number) => void
10  tick: (dtMs: number) => void
11  frame: () => Bitmap
12  summary: () => string
13  log: () => readonly string[]
14}
15
16export type StageOptions = {
17  game: string
18  title: string
19  command: { name: string; description: string; report: () => readonly string[] }
20  rows: number
21  minColumns: number
22  maxColumns: number
23  frameMs: number
24  scene: StageScene
25}
26
27const POLL_MS = 1000
28const PANE_COLUMNS = 62
29const ACTIVE_FILE = '.claude/mods/active-game'
30
31let stage: StageOptions | undefined
32let activePath = ''
33let isClockRunning = false
34let isPaneOpen = false
35let isRasterMounted = false
36let lastCells = ''
37let lastSummary = ''
38let sincePollMs = 0
39
40export function configureStage(options: StageOptions): void {
41  stage = options
42}
43
44export function isStageShown(): boolean {
45  return isPaneOpen
46}
47
48function wantsShow(options: { args: string; isActive: boolean }): boolean {
49  if (options.args === 'on') {
50    return true
51  }
52  return options.args === 'off' ? false : !options.isActive
53}
54
55async function readActive($: EngineInterface): Promise<string> {
56  if (activePath === '') {
57    return ''
58  }
59  return (await $.fs.read(activePath).catch(() => '')).trim()
60}
61
62export function installStage(on: On): void {
63  on('session.start', { isInteractive: true }, async ($, e, next) => {
64    const options = stage
65    if (!options) {
66      return next(e)
67    }
68    await $.command.register({ name: options.command.name, description: options.command.description, immediate: true })
69    activePath = `${(await $.env.get('HOME')) ?? '.'}/${ACTIVE_FILE}`
70    options.scene.begin(await $.clock.now())
71    isPaneOpen = (await $.ui.panes()).some(pane => pane.id === options.game)
72    const active = await readActive($)
73    if (active === options.game && !isPaneOpen) {
74      const opened = await $.ui.open({ id: options.game, title: options.title, rows: options.rows + 1, columns: PANE_COLUMNS })
75      isPaneOpen = true
76      if (!opened.isPlaced) {
77        $.ui.toast(`${options.title} waits for a wider terminal. Run /${options.command.name} to show it now.`)
78      }
79    }
80    if (isClockRunning) {
81      return next(e)
82    }
83    isClockRunning = true
84    $.clock.every(options.frameMs, () => {
85      options.scene.tick(options.frameMs)
86      sincePollMs += options.frameMs
87      if (sincePollMs >= POLL_MS) {
88        sincePollMs = 0
89        if (isPaneOpen && options.scene.summary() !== lastSummary) {
90          $.ui.invalidate('ui.render')
91        }
92        readActive($)
93          .then(current => (isPaneOpen && current !== options.game ? $.ui.close({ id: options.game }) : undefined))
94          .catch(noop)
95      }
96      if (!isPaneOpen || !isRasterMounted) {
97        return
98      }
99      const cells = encodeCells(options.scene.frame())
100      if (cells === lastCells) {
101        return
102      }
103      lastCells = cells
104      $.ui
105        .blit({ requestId: options.game, key: `${options.game}:stage`, cells })
106        .then(result => {
107          if (result.deny !== undefined) {
108            isRasterMounted = false
109          }
110        })
111        .catch(noop)
112    })
113    return next(e)
114  })
115
116  on('command.run', { command: stage?.command.name ?? '' }, async ($, e, next) => {
117    const options = stage
118    if (!options || e.command !== options.command.name) {
119      return next(e)
120    }
121    const args = e.args.trim().toLowerCase()
122    if (args === 'stats') {
123      return { text: options.command.report().join('\n') }
124    }
125    if (args !== '' && args !== 'on' && args !== 'off') {
126      return { text: [`Usage: /${options.command.name} [on|off|stats]`, ...options.command.report()].join('\n') }
127    }
128    const pane = (await $.ui.panes()).find(candidate => candidate.id === options.game)
129    const isActive = (await readActive($)) === options.game && pane?.isPlaced === true
130    if (wantsShow({ args, isActive })) {
131      await $.fs.write(activePath, options.game)
132      await $.ui.open({ id: options.game, title: options.title, rows: options.rows + 1, columns: PANE_COLUMNS })
133      isPaneOpen = true
134      return {
135        text: [`${options.title} is on. It comes back each session until you run /${options.command.name} again.`, ...options.command.report()].join('\n'),
136      }
137    }
138    await $.fs.write(activePath, '')
139    await $.ui.close({ id: options.game })
140    isPaneOpen = false
141    return { text: `${options.title} is off. Run /${options.command.name} to bring it back.` }
142  })
143
144  on('ui.close', async ($, e, next) => {
145    const options = stage
146    if (!options || e.id !== options.game) {
147      return next(e)
148    }
149    const closed = await next(e)
150    isPaneOpen = false
151    isRasterMounted = false
152    const active = await readActive($)
153    if (e.origin.kind === 'person' && active === options.game) {
154      await $.fs.write(activePath, '')
155    }
156    return closed
157  })
158
159  on('ui.render', { component: 'Pane' }, async ($, e, next) => {
160    const options = stage
161    if (!options || e.requestId !== options.game) {
162      return next(e)
163    }
164    lastSummary = options.scene.summary()
165    const logRows = e.props.placement === 'dock' ? Math.max(0, e.props.scroll.bodyRows - options.rows - 3) : 0
166    const lines = options.scene.log().slice(0, logRows)
167    if (e.surface !== 'terminal' || e.props.bodyColumns < options.minColumns) {
168      const { Box, Text } = $.ui.resolve(e)
169      isRasterMounted = false
170      return (
171        <Box flexDirection="column">
172          <Text>{lastSummary}</Text>
173          {e.surface === 'terminal' ? <Text dimColor>Widen this pane to see the scene.</Text> : null}
174        </Box>
175      )
176    }
177    const { Box, Raster, Text } = $.ui.resolve(e)
178    const columns = Math.min(e.props.bodyColumns, options.maxColumns)
179    options.scene.resize(columns)
180    isRasterMounted = true
181    lastCells = encodeCells(options.scene.frame())
182    return (
183      <Box flexDirection="column">
184        <Raster key={`${options.game}:stage`} columns={columns} rows={options.rows} cells={lastCells} />
185        <Text dimColor wrap="truncate">{lastSummary}</Text>
186        {lines.length > 0 ? (
187          <Box flexDirection="column" marginTop={1}>
188            {lines.map((line, index) => (
189              <Text key={`log-${index}`} dimColor={index > 0} wrap="truncate">
190                {line}
191              </Text>
192            ))}
193          </Box>
194        ) : null}
195      </Box>
196    )
197  })
198}
199
hooks/sim/constants.ts 49 lines
1export 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]
49
hooks/sim/night.ts 198 lines
1import { 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>
198
hooks/shared/noop.ts 4 lines
1export function noop(): undefined {
2  return undefined
3}
4
hooks/shared/pixel/bitmap.ts 83 lines
1export type Color = number
2
3export type Bitmap = {
4  width: number
5  height: number
6  pixels: (Color | null)[]
7}
8
9export type Tint = (color: Color) => Color
10
11export function createBitmap(options: { width: number; height: number }): Bitmap {
12  const { width, height } = options
13  return { width, height, pixels: Array.from({ length: width * height }, () => null) }
14}
15
16export function setPixel(options: {
17  bitmap: Bitmap
18  x: number
19  y: number
20  color: Color
21}): void {
22  const { bitmap, color } = options
23  const x = Math.round(options.x)
24  const y = Math.round(options.y)
25  if (x < 0 || y < 0 || x >= bitmap.width || y >= bitmap.height) {
26    return
27  }
28  bitmap.pixels[y * bitmap.width + x] = color
29}
30
31export function getPixel(options: { bitmap: Bitmap; x: number; y: number }): Color | null {
32  const { bitmap, x, y } = options
33  if (x < 0 || y < 0 || x >= bitmap.width || y >= bitmap.height) {
34    return null
35  }
36  return bitmap.pixels[y * bitmap.width + x] ?? null
37}
38
39export function fillRect(options: {
40  bitmap: Bitmap
41  x: number
42  y: number
43  width: number
44  height: number
45  color: Color
46}): void {
47  const { bitmap, color } = options
48  for (let row = 0; row < options.height; row += 1) {
49    for (let column = 0; column < options.width; column += 1) {
50      setPixel({ bitmap, x: options.x + column, y: options.y + row, color })
51    }
52  }
53}
54
55export function stamp(options: {
56  target: Bitmap
57  source: Bitmap
58  x: number
59  y: number
60  isFlipped?: boolean
61  tint?: Tint
62}): void {
63  const { target, source, tint } = options
64  source.pixels.forEach((color, index) => {
65    if (color === null) {
66      return
67    }
68    const column = index % source.width
69    const row = Math.floor(index / source.width)
70    const offset = options.isFlipped ? source.width - 1 - column : column
71    setPixel({
72      bitmap: target,
73      x: options.x + offset,
74      y: options.y + row,
75      color: tint ? tint(color) : color,
76    })
77  })
78}
79
80export function clearBitmap(bitmap: Bitmap): void {
81  bitmap.pixels.fill(null)
82}
83
hooks/shared/pixel/cells.ts 44 lines
1import { getPixel } from './bitmap'
2import type { Bitmap } from './bitmap'
3
4const TERMINAL_DEFAULT = 0x01000000
5const SPACE = 0x20
6const UPPER_HALF_BLOCK = 0x2580
7const LOWER_HALF_BLOCK = 0x2584
8
9export function cellRowsOf(bitmap: Bitmap): number {
10  return Math.floor(bitmap.height / 2)
11}
12
13export function toCellWords(bitmap: Bitmap): Uint32Array {
14  const rows = cellRowsOf(bitmap)
15  const words = new Uint32Array(bitmap.width * rows * 3)
16  for (let row = 0; row < rows; row += 1) {
17    for (let column = 0; column < bitmap.width; column += 1) {
18      const upper = getPixel({ bitmap, x: column, y: row * 2 })
19      const lower = getPixel({ bitmap, x: column, y: row * 2 + 1 })
20      const offset = (row * bitmap.width + column) * 3
21      if (upper === null && lower === null) {
22        words.set([SPACE, TERMINAL_DEFAULT, TERMINAL_DEFAULT], offset)
23      } else if (upper === null && lower !== null) {
24        words.set([LOWER_HALF_BLOCK, lower, TERMINAL_DEFAULT], offset)
25      } else if (upper !== null) {
26        words.set([UPPER_HALF_BLOCK, upper, lower ?? TERMINAL_DEFAULT], offset)
27      }
28    }
29  }
30  return words
31}
32
33function toBinaryString(bytes: Uint8Array): string {
34  let binary = ''
35  bytes.forEach(byte => {
36    binary += String.fromCharCode(byte)
37  })
38  return binary
39}
40
41export function encodeCells(bitmap: Bitmap): string {
42  return btoa(toBinaryString(new Uint8Array(toCellWords(bitmap).buffer)))
43}
44
hooks/draw/scene.ts 33 lines
1import 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}
33
hooks/sim/ambient.ts 94 lines
1import { 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>
94
hooks/sim/effects.ts 24 lines
1import 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}
24
hooks/sim/journal.ts 16 lines
1import { 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>
16
hooks/sim/moves.ts 88 lines
1import { 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