Gitgotchi maps observable repo signals to a creature's state, and does it in a way that keeps the game logic trivially testable. The whole design follows from one rule: all I/O lives at the edges; the game is a pure function in the middle.
collectors (async, I/O) → RepoSignals
RepoSignals + prev state → engine + decay (pure) → PetState
PetState → store.save() + Ink render
One check-in (gitgotchi, one tick of watch, or --json) walks this exactly
once, in src/core/session.ts → collectAndAdvance:
loadthe previousPetState(or a newborn).collectSignalsruns every collector concurrently.tickadvances the pet:computeVitals → applyDecay → computeMood, updates lifetime + stage, appends a history snapshot.savewrites the new state atomically.
One collector per signal source. The iron rule: collectors never throw. Any
failure (missing file, weird git state, no network, rate limit) degrades to
null or a safe default and logs only under GITGOTCHI_DEBUG.
| File | Signal |
|---|---|
git.ts |
commit recency, dirty count, branch count, stale branches, ahead/behind |
todos.ts |
TODO/FIXME/HACK counts (honors .gitignore via git ls-files, skips binaries) |
coverage.ts |
coverage-summary.json (preferred) or lcov.info |
ci.ts |
GitHub Actions status; token via GITGOTCHI_GITHUB_TOKEN → GITHUB_TOKEN → gh auth token; octokit is lazy-imported only when a token + remote exist |
index.ts |
collectSignals — settles all four so one rejection can't poison the rest |
No I/O, no Date.now(), no unseeded Math.random(). The clock and seed are
parameters, so running the engine twice on identical input is byte-identical
(asserted by a determinism test). Held to 100% line coverage by a Vitest
threshold.
| File | Responsibility |
|---|---|
health.ts |
computeVitals(signals) → the four vitals |
mood.ts |
computeMood(vitals, seed) → mood bucket + flavor line |
flavor.ts |
mood → dry, non-preachy flavor lines (data) |
decay.ts |
applyDecay(vitals, hours) — hunger/social erode while away |
evolution.ts |
computeStage(lifetime, born, clock) + pickSpecies(key) |
src/core/tick.ts composes these into the one pure tick() reducer.
types.ts/schemas.ts— thePetStatecontract, mirrored by zod schemas with a compile-time assertion that the two stay in lockstep.store.ts—loadvalidates and migrates; on corrupt/invalid input it copies the broken file tostate.json.bakand returns a newborn.savewritesstate.json.tmpthenrenames over the real file (atomic — a crash can never leave partial JSON). State lives outside the repo, so observing a repo never writes to it:GITGOTCHI_STATE_DIR, else$XDG_STATE_HOME/gitgotchi/, else~/.local/state/gitgotchi/(%LOCALAPPDATA%\gitgotchi\on Windows). Each repo gets a<basename>-<sha256(abs path)[0..12]>slot, so two checkouts namedapinever collide. A pre-0.2.gitgotchi/state.jsonis read once for migration and never written back.name.ts—safeName/stripControlscrub control bytes out of anything a user typed, at the persistence boundary and again at the SVG boundary. A pet name is rendered into a terminal and into a shared card; neither should ever see an escape sequence.history.ts— a 50-entry snapshot ring buffer.
Ink (React for terminals). Sprites are plain data (sprites/byte.ts), never
inline strings, and are snapshot- and invariant-tested (every stage × mood is
exactly two frames, ≤ 12×6, equal-width). App.tsx composes the sprite, HUD, and
flavor line; launch.tsx drives one-shot and watch; plain.ts is the non-TTY
fallback.
- hunger: 100 at ≤ 2h since last commit, linear to 0 at 7 days.
- hygiene:
100 − 2·TODO − 5·FIXME − 8·HACK, floor 0. - social:
100 − 15·stale branches − (unpushed ? 10 : 0), floor 0. - health: with coverage →
70 + 30·coverage − 25·min(failStreak,3) − (failing ? 15 : 0); no CI/coverage → mean of the other three, so zero-config repos still work. - mood: lowest vital picks the bucket; all ≥ 70 → content; all ≥ 85 → thriving; any ≤ 15 → critical. Ties break health > hunger > hygiene > social.
Full fixture tables live in docs/milestones/M2-engine.md and the engine tests.