A fox spirit that lives on your work: passing tests feed it, finished turns cheer it, it plays while you are quiet, and it grows up to nine tails.

A fox spirit lives on a small hill beside your work. Your work is the only thing that feeds it, and while you are quiet it plays.

claude plugin marketplace add theonly1me/claude-code-mods
claude plugin install pocket-familiar@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 familiar is hidden until you ask for it.
/pet shows the familiar. Run it again to hide it./pet on and /pet off do the same without toggling./pet stats prints its mood, stat bars, and the XP it needs for the next stage, and leaves the scene as it is.When you show it, it comes back in each new session until you hide it again. Only one game shows at a time: showing the familiar hides any other game from this marketplace, and showing another game hides the familiar.
In the fullscreen layout (CLAUDE_CODE_NO_FLICKER=1 claude) the familiar docks beside the transcript, with its stats and a short log of what it did under the scene. Otherwise it sits in a pane above the prompt. A pane that opens by itself at session start needs a terminal 144 columns wide; on a narrower one it waits until you run /pet. The desktop app shows a one-line summary.
Work feeds it:
It mirrors what Claude is doing: a book while Claude reads, a laptop while Claude edits, a magnifier while tests run, and thought dots while it paces during a turn. Any tool call stops its play, and it walks home to help.
While nothing runs, it never stands still. It chases butterflies, pounces on falling leaves, bats a ball of yarn, chases its own tail until it is dizzy, grooms, digs up shiny pebbles, stretches and yawns, watches shooting stars at night and birds by day, and takes short naps. Play is only for show: it never changes fullness, joy, energy, or XP. After five quiet minutes it falls asleep for real, and any prompt or tool call wakes it.
Every successful tool call gives 1 XP, a passing test gives 5, and a finished turn gives 3. The speckled egg hatches at 20 XP into a round fox kit. At 120 XP it becomes a fox spirit, and it grows one more tail at each milestone, up to a nine-tailed kitsune at 5,500 XP. A toast tells you each time it grows. The sky follows your local time of day, with fireflies at night.
See the marketplace README for configuration, privacy, updates, and removal.
hooks/register.tsx 103 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { isCreatedFile, localHour, toolKindOf } from './activity'
4import { noop } from './shared/noop'
5import { configureStage, installStage } from './shared/pixel/stage'
6import { FRAME_MS, MAX_COLUMNS, MIN_COLUMNS, STAGE_ROWS } from './sim/constants'
7import { createFamiliar } from './sim/familiar'
8import type { Familiar } from './sim/familiar'
9import { savedFrom } from './sim/stats'
10
11const SAVE_KEY = 'familiar'
12const SAVE_EVERY_MS = 60 * 1000
13
14async function save($: EngineInterface, options: { familiar: Familiar }): Promise<void> {
15 await $.store.set(SAVE_KEY, options.familiar.snapshot(await $.clock.now()))
16}
17
18function announce($: EngineInterface, options: { familiar: Familiar }): void {
19 options.familiar.takeEvolutions().forEach(growth => {
20 $.ui.toast(growth.form === 'kit' ? 'The egg hatched into a fox kit.' : `Your familiar grew into a ${growth.title}.`)
21 })
22}
23
24export const register: Register = on => {
25 const familiar = createFamiliar()
26
27 configureStage({
28 game: 'pocket-familiar',
29 title: 'Pocket Familiar',
30 command: {
31 name: 'pet',
32 description: 'Show or hide your fox familiar (on, off, stats)',
33 report: () => familiar.report(),
34 },
35 rows: STAGE_ROWS,
36 minColumns: MIN_COLUMNS,
37 maxColumns: MAX_COLUMNS,
38 frameMs: FRAME_MS,
39 scene: {
40 begin: epochMs => familiar.begin(epochMs),
41 resize: columns => familiar.resize(columns),
42 tick: dtMs => familiar.tick({ dtMs }),
43 frame: () => familiar.frame(),
44 summary: () => familiar.summary(),
45 log: () => familiar.log(),
46 },
47 })
48 installStage(on)
49
50 on('session.start', async ($, e, next) => {
51 const saved = savedFrom(await $.store.get(SAVE_KEY))
52 if (saved) {
53 familiar.restore({ saved, now: await $.clock.now() })
54 }
55 familiar.setHour(localHour())
56 $.clock.every(SAVE_EVERY_MS, () => {
57 familiar.setHour(localHour())
58 save($, { familiar }).catch(noop)
59 })
60 return next(e)
61 })
62
63 on('prompt.submit', ($, e, next) => {
64 familiar.userActive()
65 return next(e)
66 })
67
68 on('turn.start', ($, e, next) => {
69 familiar.turnStarted()
70 return next(e)
71 })
72
73 on('tool.call', async ($, e, next) => {
74 const kind = toolKindOf({ tool: String(e.tool), command: e.tool === 'Bash' ? e.command : '' })
75 familiar.toolStarted(kind)
76 try {
77 const ran = await next(e)
78 const isFailure = ran.deny !== undefined || ran.isError === true
79 const isCreation = e.tool === 'Write' && !isFailure && isCreatedFile(ran.result)
80 familiar.toolFinished({ kind, isFailure, isCreation })
81 announce($, { familiar })
82 return ran
83 } catch (error) {
84 familiar.toolFinished({ kind, isFailure: true, isCreation: false })
85 throw error
86 }
87 })
88
89 on('turn.complete', async ($, e, next) => {
90 if (e.agentId === undefined) {
91 familiar.turnCompleted({ isSuccess: e.reason === 'answer' })
92 announce($, { familiar })
93 await save($, { familiar })
94 }
95 return next(e)
96 })
97
98 on('session.end', async ($, e, next) => {
99 await save($, { familiar })
100 return next(e)
101 })
102}
103hooks/activity.ts 30 lines1import type { ToolKind } from './sim/types'
2
3const READ_TOOLS = new Set(['Read', 'Grep', 'Glob', 'LSP', 'WebFetch', 'WebSearch', 'ListMcpResourcesTool', 'ReadMcpResourceTool'])
4const EDIT_TOOLS = new Set(['Edit', 'Write', 'NotebookEdit'])
5const TEST_COMMAND =
6 /(^|[\s;&|(])(npm|pnpm|yarn|bun)\s+(run\s+)?(test|check|vitest|jest)\b|(^|[\s;&|(])(npx\s+)?(vitest|jest|mocha|ava|playwright\s+test|pytest|tox|rspec|phpunit)\b|(^|[\s;&|(])(go|cargo|deno|swift|dotnet|mix|gradle|mvn)\s+test\b|(^|[\s;&|(])node\s+(--test\b|\S*\.test\.[cm]?[jt]s)|(^|[\s;&|(])claude\s+plugin\s+test\b|(^|[\s;&|(])make\s+(test|check)\b|python3?\s+-m\s+(pytest|unittest)\b/
7
8export function isTestCommand(command: string): boolean {
9 return TEST_COMMAND.test(command)
10}
11
12export function toolKindOf(options: { tool: string; command: string }): ToolKind {
13 if (READ_TOOLS.has(options.tool)) {
14 return 'read'
15 }
16 if (EDIT_TOOLS.has(options.tool)) {
17 return 'edit'
18 }
19 return options.tool === 'Bash' && isTestCommand(options.command) ? 'test' : 'other'
20}
21
22export function isCreatedFile(result: unknown): boolean {
23 return typeof result === 'object' && result !== null && 'type' in result && result.type === 'create'
24}
25
26export function localHour(): number {
27 const now = new Date()
28 return now.getHours() + now.getMinutes() / 60
29}
30hooks/shared/noop.ts 4 lines1export function noop(): undefined {
2 return undefined
3}
4hooks/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 20 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
6
7export const MINUTE_MS = 60 * 1000
8export const HOUR_MS = 60 * MINUTE_MS
9export const IDLE_SLEEP_MS = 5 * MINUTE_MS
10export const FAILURES_FOR_DUCK = 3
11
12export const BLINK_EVERY_MS = 3400
13export const BLINK_MS = 160
14export const HOP_MS = 640
15export const WORRY_MS = 2600
16export const BUBBLE_MS = 1800
17
18export const FOX_X = 10
19export const GROUND_Y = 13
20hooks/sim/familiar.ts 113 lines1import { drawScene } from '../draw/scene'
2import { clearBitmap, createBitmap } from '../shared/pixel/bitmap'
3import type { Bitmap } from '../shared/pixel/bitmap'
4import { advanceAmbient, startAmbient } from './ambient'
5import { advance, createState, noteToolEnd, noteToolStart, noteTurnEnd, noteTurnStart, noteUserActive } from './care'
6import { MAX_COLUMNS, STAGE_HEIGHT } from './constants'
7import { growthOf, nextGrowthOf } from './growth'
8import { logLines } from './log'
9import { reportOf, summaryOf } from './report'
10import { afterAway, moodOf } from './stats'
11import type { AmbientKind, Growth, Mood, SavedFamiliar, ToolKind } from './types'
12
13export function createFamiliar() {
14 const state = createState()
15 let bitmap = createBitmap({ width: MAX_COLUMNS, height: STAGE_HEIGHT })
16
17 return {
18 begin(epochMs: number): void {
19 state.ambient.seed = Math.floor(epochMs % 2147483647) || 1
20 },
21
22 restore(options: { saved: SavedFamiliar; now: number }): void {
23 state.stats = afterAway(options)
24 state.lifetimeXp = options.saved.lifetimeXp
25 },
26
27 snapshot(now: number): SavedFamiliar {
28 return { ...state.stats, lifetimeXp: state.lifetimeXp, lastSeenAt: now }
29 },
30
31 setHour(hour: number): void {
32 state.hour = hour
33 },
34
35 toolStarted(kind: ToolKind): void {
36 noteToolStart({ state, kind })
37 },
38
39 toolFinished(options: { kind: ToolKind; isFailure: boolean; isCreation: boolean }): void {
40 noteToolEnd({ state, ...options })
41 },
42
43 turnStarted(): void {
44 noteTurnStart(state)
45 },
46
47 turnCompleted(options: { isSuccess: boolean }): void {
48 noteTurnEnd({ state, ...options })
49 },
50
51 userActive(): void {
52 noteUserActive(state)
53 },
54
55 play(kind: AmbientKind): void {
56 startAmbient({ state, kind })
57 },
58
59 tick(options: { dtMs: number }): void {
60 advance({ state, dtMs: options.dtMs })
61 advanceAmbient({ state, dtMs: options.dtMs })
62 },
63
64 takeEvolutions(): Growth[] {
65 return state.evolutions.splice(0, state.evolutions.length)
66 },
67
68 view() {
69 return state
70 },
71
72 mood(): Mood {
73 return moodOf(state)
74 },
75
76 growth(): Growth {
77 return growthOf(state.lifetimeXp)
78 },
79
80 nextGrowth(): Growth | undefined {
81 return nextGrowthOf(state.lifetimeXp)
82 },
83
84 summary(): string {
85 return summaryOf(state)
86 },
87
88 report(): string[] {
89 return reportOf(state)
90 },
91
92 log(): string[] {
93 return logLines(state.log)
94 },
95
96 resize(columns: number): void {
97 if (columns === state.width) {
98 return
99 }
100 state.width = columns
101 bitmap = createBitmap({ width: columns, height: STAGE_HEIGHT })
102 },
103
104 frame(): Bitmap {
105 clearBitmap(bitmap)
106 drawScene({ bitmap, state })
107 return bitmap
108 },
109 }
110}
111
112export type Familiar = ReturnType<typeof createFamiliar>
113hooks/sim/stats.ts 94 lines1import { HOUR_MS, MINUTE_MS } from './constants'
2import type { FamiliarState, Mood, SavedFamiliar, Stats } from './types'
3
4export const STARTING_STATS: Stats = { fullness: 70, joy: 70, energy: 80 }
5
6const AWAY_PER_HOUR = { fullness: -4, joy: -2, energy: 10 }
7const ACTIVE_PER_MINUTE = { fullness: -0.1, joy: -1 / 15, energy: -0.2 }
8const SLEEP_ENERGY_PER_MINUTE = 2
9
10export function clampStat(value: number): number {
11 return Math.min(100, Math.max(0, value))
12}
13
14export function clampStats(stats: Stats): Stats {
15 return { fullness: clampStat(stats.fullness), joy: clampStat(stats.joy), energy: clampStat(stats.energy) }
16}
17
18export function addStats(options: { stats: Stats; change: Partial<Stats> }): Stats {
19 const { stats, change } = options
20 return clampStats({
21 fullness: stats.fullness + (change.fullness ?? 0),
22 joy: stats.joy + (change.joy ?? 0),
23 energy: stats.energy + (change.energy ?? 0),
24 })
25}
26
27export function afterAway(options: { saved: SavedFamiliar; now: number }): Stats {
28 const hours = Math.max(0, options.now - options.saved.lastSeenAt) / HOUR_MS
29 return addStats({
30 stats: options.saved,
31 change: {
32 fullness: AWAY_PER_HOUR.fullness * hours,
33 joy: AWAY_PER_HOUR.joy * hours,
34 energy: AWAY_PER_HOUR.energy * hours,
35 },
36 })
37}
38
39export function drift(options: { stats: Stats; dtMs: number; isSleeping: boolean }): Stats {
40 const minutes = options.dtMs / MINUTE_MS
41 if (options.isSleeping) {
42 return addStats({ stats: options.stats, change: { energy: SLEEP_ENERGY_PER_MINUTE * minutes } })
43 }
44 return addStats({
45 stats: options.stats,
46 change: {
47 fullness: ACTIVE_PER_MINUTE.fullness * minutes,
48 joy: ACTIVE_PER_MINUTE.joy * minutes,
49 energy: ACTIVE_PER_MINUTE.energy * minutes,
50 },
51 })
52}
53
54export function moodOf(state: FamiliarState): Mood {
55 if (state.isSleeping) {
56 return 'asleep'
57 }
58 if (state.hasDuck) {
59 return 'needs a break'
60 }
61 if (state.worriedMs > 0) {
62 return 'worried'
63 }
64 if (state.stats.fullness < 25) {
65 return 'hungry'
66 }
67 if (state.stats.energy < 20) {
68 return 'tired'
69 }
70 return state.stats.joy >= 70 ? 'happy' : 'content'
71}
72
73function isNumber(value: unknown): value is number {
74 return typeof value === 'number' && Number.isFinite(value)
75}
76
77export function savedFrom(value: unknown): SavedFamiliar | undefined {
78 if (typeof value !== 'object' || value === null) {
79 return undefined
80 }
81 const fields = ['fullness', 'joy', 'energy', 'lifetimeXp', 'lastSeenAt'] as const
82 const record = Object.fromEntries(Object.entries(value))
83 if (!fields.every(field => isNumber(record[field]))) {
84 return undefined
85 }
86 return {
87 fullness: Number(record.fullness),
88 joy: Number(record.joy),
89 energy: Number(record.energy),
90 lifetimeXp: Number(record.lifetimeXp),
91 lastSeenAt: Number(record.lastSeenAt),
92 }
93}
94hooks/sim/types.ts 59 lines1export type Stats = { fullness: number; joy: number; energy: number }
2
3export type Activity = 'idle' | 'read' | 'edit' | 'test' | 'thinking'
4
5export type ToolKind = 'read' | 'edit' | 'test' | 'other'
6
7export type BubbleGlyph = 'alert' | 'question' | 'heart' | 'note' | 'sleep'
8
9export type Bubble = { glyph: BubbleGlyph; ageMs: number }
10
11export type GrowthForm = 'egg' | 'kit' | 'fox'
12
13export type Growth = { form: GrowthForm; tails: number; title: string; minimumXp: number }
14
15export type Mood = 'asleep' | 'needs a break' | 'worried' | 'hungry' | 'tired' | 'happy' | 'content'
16
17export type EyeState = 'open' | 'shut' | 'happy'
18
19export type AmbientKind = 'butterfly' | 'leaf' | 'yarn' | 'tail' | 'groom' | 'dig' | 'stretch' | 'stars' | 'nap'
20
21export type AmbientPhase = AmbientKind | 'look'
22
23export type AmbientState = {
24 kind: AmbientPhase
25 ms: number
26 durationMs: number
27 foxX: number
28 fromX: number
29 targetX: number
30 seed: number
31 last: AmbientKind | undefined
32 count: number
33}
34
35export type LogEntry = { text: string; count: number }
36
37export type FamiliarState = {
38 stats: Stats
39 lifetimeXp: number
40 activity: Activity
41 runningTools: number
42 isTurnRunning: boolean
43 idleMs: number
44 isSleeping: boolean
45 failureStreak: number
46 hasDuck: boolean
47 worriedMs: number
48 hopMs: number
49 bubble: Bubble | undefined
50 clockMs: number
51 hour: number
52 width: number
53 ambient: AmbientState
54 log: LogEntry[]
55 evolutions: Growth[]
56}
57
58export type SavedFamiliar = Stats & { lifetimeXp: number; lastSeenAt: number }
59hooks/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 50 lines1import type { Bitmap } from '../shared/pixel/bitmap'
2import { isAmbientAllowed } from '../sim/ambient'
3import { growthOf } from '../sim/growth'
4import { foxPoseOf } from '../sim/pose'
5import type { FamiliarState } from '../sim/types'
6import { drawAmbient, drawButterfly, drawMouth } from './ambient'
7import { drawBackdrop } from './backdrop'
8import { drawDetails } from './details'
9import { drawFamiliar } from './fox'
10import type { Placement } from './fox'
11import { drawHud } from './hud'
12import { drawFireflies } from './sky'
13
14const EGG_BUTTERFLY = 0xffd43b
15
16function drawEggCompanion(options: { bitmap: Bitmap; state: FamiliarState; placement: Placement }): void {
17 const { bitmap, state, placement } = options
18 if (state.isSleeping) {
19 return
20 }
21 drawButterfly({
22 bitmap,
23 at: {
24 x: placement.x + 3 + Math.cos(state.clockMs / 700) * 8,
25 y: placement.top + 1 + Math.sin(state.clockMs / 450) * 2,
26 },
27 ms: state.clockMs,
28 color: EGG_BUTTERFLY,
29 })
30}
31
32export function drawScene(options: { bitmap: Bitmap; state: FamiliarState }): void {
33 const { bitmap, state } = options
34 const daylight = drawBackdrop({ bitmap, hour: state.hour, clockMs: state.clockMs })
35 drawFireflies({ bitmap, daylight, clockMs: state.clockMs })
36 drawHud({ bitmap, stats: state.stats })
37 const pose = foxPoseOf(state)
38 const growth = growthOf(state.lifetimeXp)
39 const placement = drawFamiliar({ bitmap, state, pose })
40 if (growth.form === 'egg') {
41 drawEggCompanion({ bitmap, state, placement })
42 } else {
43 drawMouth({ bitmap, placement, pose, isKit: growth.form === 'kit' })
44 }
45 if (isAmbientAllowed(state)) {
46 drawAmbient({ bitmap, state, placement, daylight })
47 }
48 drawDetails({ bitmap, state, placement, isNapping: pose.isNapping })
49}
50hooks/sim/ambient.ts 163 lines1import { butterflyAt, progressOf, yarnAt } from './ambientPaths'
2import { FOX_X } from './constants'
3import { growthOf } from './growth'
4import { pushLog } from './log'
5import type { AmbientKind, AmbientState, FamiliarState } from './types'
6
7export const AMBIENT_KINDS: readonly [AmbientKind, ...AmbientKind[]] = [
8 'butterfly',
9 'leaf',
10 'yarn',
11 'tail',
12 'groom',
13 'dig',
14 'stretch',
15 'stars',
16 'nap',
17]
18
19const DURATION_MS: Record<AmbientKind, number> = {
20 butterfly: 6400,
21 leaf: 4200,
22 yarn: 6000,
23 tail: 3600,
24 groom: 3200,
25 dig: 4800,
26 stretch: 2800,
27 stars: 4600,
28 nap: 5200,
29}
30
31export const AMBIENT_LOG: Record<AmbientKind, string> = {
32 butterfly: 'Chased a butterfly',
33 leaf: 'Pounced on a falling leaf',
34 yarn: 'Batted a ball of yarn',
35 tail: 'Chased its own tail',
36 groom: 'Groomed its fur',
37 dig: 'Dug up a shiny pebble',
38 stretch: 'Stretched and yawned',
39 stars: 'Watched the sky',
40 nap: 'Took a short nap',
41}
42
43export const AMBIENT_NOW: Record<AmbientKind, string> = {
44 butterfly: 'chasing a butterfly',
45 leaf: 'pouncing on a leaf',
46 yarn: 'batting yarn',
47 tail: 'chasing its tail',
48 groom: 'grooming',
49 dig: 'digging',
50 stretch: 'stretching',
51 stars: 'watching the sky',
52 nap: 'napping',
53}
54
55const LOOK_MS = 700
56const HOME_PER_SECOND = 36
57const SPOT_DISTANCE = 14
58
59export function createAmbient(seed: number): AmbientState {
60 return { kind: 'look', ms: 0, durationMs: LOOK_MS, foxX: FOX_X, fromX: FOX_X, targetX: FOX_X, seed, last: undefined, count: 0 }
61}
62
63function nextRandom(ambient: AmbientState): number {
64 ambient.seed = (Math.imul(ambient.seed, 1664525) + 1013904223) >>> 0
65 return ambient.seed / 4294967296
66}
67
68export function foxRange(width: number): { min: number; max: number } {
69 const min = 3
70 return { min, max: Math.max(min, width - 32) }
71}
72
73export function isAmbientAllowed(state: FamiliarState): boolean {
74 return (
75 !state.isSleeping &&
76 !state.isTurnRunning &&
77 state.runningTools === 0 &&
78 state.hopMs <= 0 &&
79 state.worriedMs <= 0 &&
80 state.bubble === undefined &&
81 growthOf(state.lifetimeXp).form !== 'egg'
82 )
83}
84
85function moveToward(options: { ambient: AmbientState; target: number; perSecond: number; dtMs: number }): void {
86 const { ambient, target } = options
87 const step = (options.perSecond * options.dtMs) / 1000
88 const distance = target - ambient.foxX
89 ambient.foxX = Math.abs(distance) <= step ? target : ambient.foxX + Math.sign(distance) * step
90}
91
92function spotAwayFrom(options: { ambient: AmbientState; min: number; max: number }): number {
93 const { ambient, min, max } = options
94 const spot = min + nextRandom(ambient) * (max - min)
95 if (Math.abs(spot - ambient.foxX) >= SPOT_DISTANCE) {
96 return spot
97 }
98 return ambient.foxX + SPOT_DISTANCE <= max ? ambient.foxX + SPOT_DISTANCE : Math.max(min, ambient.foxX - SPOT_DISTANCE)
99}
100
101export function startAmbient(options: { state: FamiliarState; kind: AmbientKind }): void {
102 const { state, kind } = options
103 const ambient = state.ambient
104 const { min, max } = foxRange(state.width)
105 ambient.kind = kind
106 ambient.ms = 0
107 ambient.durationMs = DURATION_MS[kind]
108 ambient.fromX = ambient.foxX
109 ambient.last = kind
110 ambient.count += 1
111 ambient.targetX = kind === 'leaf' || kind === 'dig' ? spotAwayFrom({ ambient, min, max }) : ambient.foxX
112 pushLog({ log: state.log, text: AMBIENT_LOG[kind] })
113}
114
115function chooseNext(state: FamiliarState): AmbientKind {
116 const ambient = state.ambient
117 const choices = AMBIENT_KINDS.filter(kind => kind !== ambient.last)
118 return choices[Math.floor(nextRandom(ambient) * choices.length)] ?? 'groom'
119}
120
121function steer(options: { state: FamiliarState; dtMs: number }): void {
122 const { state, dtMs } = options
123 const ambient = state.ambient
124 const progress = progressOf(ambient)
125 if (ambient.kind === 'butterfly') {
126 moveToward({ ambient, target: butterflyAt({ ambient, width: state.width }).x - 14, perSecond: 22, dtMs })
127 } else if (ambient.kind === 'yarn') {
128 moveToward({ ambient, target: yarnAt(ambient).x - 12, perSecond: 26, dtMs })
129 } else if (ambient.kind === 'dig' && progress < 0.3) {
130 moveToward({ ambient, target: ambient.targetX, perSecond: 26, dtMs })
131 } else if (ambient.kind === 'leaf' && progress >= 0.45 && progress < 0.65) {
132 const amount = (progress - 0.45) / 0.2
133 ambient.foxX = ambient.fromX + (ambient.targetX - ambient.fromX) * amount
134 } else if (ambient.kind === 'leaf' && progress >= 0.65) {
135 ambient.foxX = ambient.targetX
136 }
137 const { min, max } = foxRange(state.width)
138 ambient.foxX = Math.min(max, Math.max(min, ambient.foxX))
139}
140
141export function advanceAmbient(options: { state: FamiliarState; dtMs: number }): void {
142 const { state, dtMs } = options
143 const ambient = state.ambient
144 if (!isAmbientAllowed(state)) {
145 ambient.kind = 'look'
146 ambient.ms = 0
147 ambient.durationMs = LOOK_MS
148 moveToward({ ambient, target: FOX_X, perSecond: HOME_PER_SECOND, dtMs })
149 return
150 }
151 ambient.ms += dtMs
152 if (ambient.ms >= ambient.durationMs) {
153 if (ambient.kind === 'look') {
154 startAmbient({ state, kind: chooseNext(state) })
155 } else {
156 ambient.kind = 'look'
157 ambient.ms = 0
158 ambient.durationMs = LOOK_MS
159 }
160 }
161 steer({ state, dtMs })
162}
163