SLOPSHOPPER

pocket-familiar

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.

newpaneguardcommandtoastprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · pocket-familiar
│ ┃ Pocket Familiar ✕ › 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 │ ┃ │ ┃ speckled egg, worried fullness 70 joy 74 … ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ │ ┃ Cheered: Claude finished a turn ✻ Worked for 42s · done 4:20 PM │ ┃ Got worried: a tool call failed │ › /pet │ ⎿ pocket-familiar: Pocket Familiar is on. It comes back each sessi │ ⎿ pocket-familiar: Your familiar is a speckled egg, and it is worr │ ⎿ pocket-familiar: fullness ███████████▎ 70 │ ⎿ pocket-familiar: joy ███████████▉ 74 │ ⎿ pocket-familiar: energy ████████████▊ 80 │ ⎿ pocket-familiar: XP 11. 9 more to become a fox kit. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Pocket Familiar
▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀ speckled egg, worried fullness 70 joy 74 energy 80 wait… Cheered: Claude finished a turn Got worried: a tool call failed
README

Pocket Familiar

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.

Pocket Familiar preview

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.

How to use

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.

How it plays

Work feeds it:

  • Fullness goes up when a test command passes. It drops slowly while you work and about 4 points an hour while you are away.
  • Joy goes up when a turn finishes and when Claude creates a new file. It fades about 2 points an hour while you are away.
  • Energy drains during long sessions and comes back while it sleeps or while you are away.
  • An error makes it sweat. Three failures in a row and it brings you a rubber duck, which stays until something works.

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.

How it grows

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.

Source 29 files
hooks/register.tsx 103 lines
1import 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}
103
hooks/activity.ts 30 lines
1import 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}
30
hooks/shared/noop.ts 4 lines
1export function noop(): undefined {
2  return undefined
3}
4
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 20 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
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
20
hooks/sim/familiar.ts 113 lines
1import { 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>
113
hooks/sim/stats.ts 94 lines
1import { 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}
94
hooks/sim/types.ts 59 lines
1export 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 }
59
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 50 lines
1import 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}
50
hooks/sim/ambient.ts 163 lines
1import { 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