a tiny pet with a GPT-2 brain, living in your browser
tokenling docs pet-simulation.md
18 kB
Markdown
at main

Pet simulation, storage and UI #

The typed controllers live in web/app/; body math is in body.ts, the clock and logs in simulation.ts, and care/turns/diary/game in their own modules. Preact components in web/components/ render the UI. web/scene.ts and web/sprites.ts draw the LCD. Times are real wall-clock time; the pet keeps aging while the tab is closed.

The split is strict: the brain decides, the app runs the body. Stats, poop, sickness, collapsing from exhaustion and waking up rested are simulation. Speaking, walking, reacting, accepting or refusing care, wanting things and choosing to nap come from the model's tags (prompt-format.md). Without the brain loaded the pet dozes: the LCD shows it asleep, the care buttons and chat are off, and its body keeps ticking.

Life cycle #

Stage Reached at Look
egg start speckled egg; "get an egg" starts the brain download, the cracks grow with download progress and it wiggles once the brain is ready; name the pet to hatch
baby hatch wears its eggshell skirt; babbles in training data
kid 12 h old leaf sprout
teen 2 days ears
adult 5 days flower, wiser voice

Before the egg, the setup note gives the first download's size (the model plus about 27 MB of runtime), suggests Wi-Fi, and says so more firmly when navigator.connection reports cellular or Save-Data (Chromium browsers). The hatch form offers one suggested name per visit. Clearing or replacing it is left alone by later renders and download progress; an empty name cannot hatch the egg.

Growing up pushes * NAME grew into a STAGE! * into the chat and asks the brain for a reaction. The body color and two personality traits are random at hatch. A pet whose download was interrupted resumes it on the next visit; a pet whose brain is already in Cache Storage loads it automatically (the newest one it has; an older one offers the upgrade, see export-and-runtime.md).

Stats and time #

Four stats, 0–100: tummy, fun, energy, clean. Change per hour:

tummy fun energy clean
awake −8 −6 −5 −3
asleep −2 0 +22 −0.5

While sick, fun falls twice as fast. The tick runs once a second. After the tab was hidden or closed, catchUp() replays up to seven days in 10-minute steps and writes what happened into the day log ("was very hungry", "was stinky", "pooped", "napped", " was away for a while"). Away for 30 minutes or more adds * OWNER came back after … * and a reaction. Catch-up also advances the live tick's clock, so the next animation frame does not apply the same elapsed time again; gaps of two minutes or less are still handled once by the live tick.

  • Poop: every 2.5–5 awake hours, up to four. Each costs 15 cleanliness.
  • Sickness: three or more poops, or tummy or clean below 5, for an hour makes the pet sick. This timer pauses while asleep and resumes on waking; resolving the grime condition clears it. Medicine cures sickness. Pets never die.
  • Sleep: the pet chooses to nap with [sleep] (typically when sleepy, at night or after the lights go off), lights on or off. Its body makes it collapse at energy < 6, but not within 5 minutes of being woken. It wakes on its own at energy ≥ 99, or at energy > 70 between 07:00 and 11:00 with the lights on.
  • Being disturbed: messages, pats and lights on roll a wake chance: min(0.9, base + 0.007 × energy + 0.12 × (n − 1)), with base 0.03 for talk, 0.10 for touch, 0.20 for light. n counts this disturbance. The counter resets on waking, a new sleep, reload, or five quiet minutes. A successful message/pat roll writes woke up before the action; lights on writes it after the lights event. Offers do not roll: [yes] wakes the pet after its reply and applies the care, while [no] leaves it dreaming. Cleaning and lights off do not wake it. Natural waking remains as above.
  • Asleep, it dreams: [dream:X] shows a thought bubble for 25 seconds; the line may contain a tiny mumble. Other effects are ignored except answering an offer. A sleeping pat produces a small stretch and still adds fun. Queued awake animations are dropped. A sleeping pet wears a floppy nightcap (its sprout, ears or flower are tucked under it) and breathes under floating z's; awake, it breathes with a soft squish every few seconds and kicks up dust puffs when it toddles.

Care actions #

Feeding, baths, medicine and catch are offers: the app writes the offer event line, shows the item bobbing beside the pet, and asks the brain. The effect happens only if the pet's answer contains [yes]; [no] (or no answer) logs "said no to …" in the day log. One offer can be open at a time, and an offer left open when the page closed is dropped.

Care choices stay available regardless of needs. For example, a healthy pet can still be offered medicine; training teaches it to decline, and medicine has no health effect in that state. The UI does not choose its answer for it.

Action Event line Effect on [yes]
feed (6 foods) offered … a strawberry tummy +28 (+22 for pudding and cookie), fun +6 for pudding, cookie and strawberry, eating animation
bath offered … a bath clean = 100, fun −2, bubbles
medicine offered … medicine cures sickness (no effect if healthy)
play asked … to play catch starts the catch minigame
pat patted fun +4 and a squish, every tap; the line is not repeated while the brain has not yet answered the last pat
clean cleaned up the poop removes all poop, clean +10
lights turned on/off the lights toggles; turning them on rolls a wake chance

Every one of these, and every message the owner sends, asks the brain for a "react" turn; sleep transitions follow the rules above. Tapping the pet on the LCD pats it.

Catch minigame #

Starts after the pet says [yes] to catch. 20 seconds, arrow keys or A/D, the on-screen pad, or click/drag on the LCD. Hold a direction to move at 70 LCD pixels/second; releasing stops immediately. The pad captures the pointer so a finger may slide off the button while held, and release/cancel still stops it. The newest held direction wins if keys or fingers overlap; releasing it resumes any other held direction. Losing focus, hiding or leaving the page clears held inputs. Focused pad buttons also accept held Space/Enter; typing in a field and navigating tabs do not move the pet. Stars score +1, ghosts −1 (about 18% of spawns). At the end fun rises by 8 + 3 × score, energy drops 8, * OWNER played catch with NAME (N stars) * is logged and the pet reacts.

The day log and diary #

S.today.log holds up to 14 short entries: foods eaten, baths, pats, games, medicine, naps, offers it turned down, things the pet learned or was told, gifts it gave, dreams and notes it made with [diary:…]. At most two dream entries are retained; an identical dream already there is not logged again. It is both the today: line and the diary's source material. Chat headers filter out OWNER said: … before taking the last six entries; diaries keep those quotes.

The pet writes its diary itself; ordinary visitors have no button to ask for a page. The debug panel has a write/rewrite control for testing (see testing). A page is written:

  • when the pet falls asleep at night (21:00–04:59) with at least two log entries and no page yet for that date (onSleep()), or
  • for a missed day: at local midnight rollDay() moves a day with at least two entries and no page into S.history (days with fewer are dropped; only the newest seven are kept), and maybeDiary() has the pet write them one at a time, oldest first, when the brain is free, the tab is visible and the owner has not interacted for 30 seconds. A toast names the date.

The "today so far" line on the diary tab updates whenever the day log changes.

Diary generation needs the brain. Pages store the date, day number, weather, stage, text and up to three doodle names.

Weather is deterministic per date and half-day (weatherAt), so the window, the now: line and diary headers agree.

Turns: react and idle #

wantTurn() queues a brain turn and pump() runs one at a time.

  • React turns follow anything that happened: an owner message, an offer, a pat, cleaning, the lights, poop, waking, growing up, coming back, the end of a game. A react request replaces a waiting idle one.
  • Idle turns let the pet live: when the brain is ready, nothing is queued, no game runs and the tab is visible, the pet gets a turn every 8–20 s ("lively", the default) or every 2–4 minutes with the setting off, and every 3–6 minutes while asleep. Idle turns use a 512-token context. Their lines are usually silent: walking, an emote, a want, sometimes a mutter or a lookup.
  • If a new line arrives while a turn is generating, the result is thrown away and the brain thinks again with the new context (at most twice in a row). A sleep-state change always discards the stale result, including a body collapse that wrote no chat event.
  • While a react turn generates, the LCD shows a "…" thought bubble and the chat shows the pet's bubble with three bouncing dots until its first words stream in.

Storage #

localStorage key tokenling.v1, JSON. Saves are debounced by 200 ms and failures (private mode, quota) are ignored.

v: 1                      save version (VERSION in web/app/save-format.ts)
created, lastTick, lastActive, grimeSince, nextPoop   ms timestamps
owner                     name the pet calls them
egg: { laid } | null
pet: { name, traits[2], color, toy, hatched, stage } | null
stats: { tummy, fun, energy, clean }
sick, asleep, lightsOff, poop
memories[]                strings, newest last (max 60)
shelf[]                   gift names (max 24)
chat[]                    the raw prompt lines (max 240); lines use the exact
                          "Name: text", "Owner: text" and "* event *" formats
today: { date, log[], day?, weather? }
offer: { kind, item } | null   the open offer (feed/bath/medicine/play)
wokeAt                    ms timestamp of the last time the owner woke the pet
history: { "YYYY-MM-DD": { log[], weather, day } }   days awaiting a diary page
diary[]                   { date, day, weather, stage, text, doodles[] }
settings: { chatter, alerts, alertsAsked, installAsked }
                          chatter = "lively" idle turns; alerts on (off by default);
                          whether the one-time alerts and install suggestions were answered
counts: { fed, pats, games, baths }

Because chat[] stores exactly what the model sees, an export is a faithful transcript. New optional fields whose absence means "off" or "empty" need no version bump (load() fills missing top-level keys from fresh(); a missing setting reads as false). Changing or renaming a stored shape means bumping VERSION and adding a step to MIGRATIONS in web/app/save-format.ts.

Save versions and the bad egg #

parseSave() reads a save: it migrates an older version one step at a time (MIGRATIONS[n] turns version n+1 into n+2), and refuses a save that is not JSON, has no integer v, lacks chat, diary or stats, has no migration step, or comes from a newer version than the page. A refused save is never overwritten: the page shows a "bad egg" (a grey glitching egg with a "?") with the reason and three choices: download the raw save, try again (offered for a newer save, since the page may be out of date), or start anew, which moves the raw save to tokenling.bad (replacing an older one there) and starts a fresh egg. While the bad egg is shown nothing is saved. Imports go through the same parseSave(), so an imported older save is migrated and a newer one is refused with the reason. An accepted import writes the validated save and reloads under the Web Lock, cancelling the previous pet's worker, timers and queued turns before resuming. Save version 1 is unchanged by the Preact rewrite.

One tab at a time #

Two tabs writing the same key would undo each other. At boot the page asks for a Web Lock named after the save key (ifAvailable). A tab that does not get it shows "Your pet is open in another tab" and never loads, ticks or saves the pet; "use it here" reloads that tab and steals the lock. The tab that lost the lock stops at once (no more saves, brain stopped) and shows the same notice. Page exit explicitly releases the lock; a new document retries an unavailable lock twice at 100 ms intervals to allow the previous document to finish releasing it, without stealing. A page restored from the back/forward cache reloads to reclaim ownership before resuming. Browsers without Web Locks run every tab as before. Blocked sessionStorage does not stop the app from booting: it still checks the lock normally, but cannot retain the "use it here" takeover marker across reload.

Export, import and start over are on the "settings" tab. Start over is a hard reset: it removes the tokenling.v1 key synchronously, blocks further saves, stops the brain and reloads (writing a fresh state through the debounced save() lost the race with the reload). The downloaded brain stays in Cache Storage, so a new egg hatches without downloading it again. Deleting the brain clears only Cache Storage, never the pet.

Installing, offline use and alerts #

The page is an installable web app (manifest and icons in site/assets/). After hatching, once the brain is ready, a one-time suggestion in the chat offers to install it (with the browser's prompt where there is one, or "tap Share, then Add to Home Screen" on iPhone and iPad); settings keep an "install app" button. Installed apps open standalone, work offline, and browsers are less inclined to clear their storage (Safari, for one, exempts Home Screen apps from clearing the data of sites not visited for seven days). The settings storage line says whether the browser has granted persistent storage.

sw.js keeps the page, the app modules, the font, the manifest and icon and the runtime files the brain loaded in its own cache (tokenling-app), and drops older app and runtime versions once a brain has loaded. The page is fetched network first (falling back to the copy after 6 s or offline); the rest is served from the copy. The brain itself stays in transformers-cache. ONNX downloads bypass the service worker's fetch handler so a long transfer does not depend on the service worker staying alive. After a brain load error, the egg shows a retry button that preserves the egg and entered name; settings also keep the download button. Storage persistence permission is optional and does not block downloading. Tested offline with the server stopped: the pet loaded, cross-origin isolated, and talked.

Alerts are off by default. The settings checkbox asks for notification permission; once the app runs installed, a one-time suggestion offers them. There is no server and so no push. Instead the page plans: whenever it hides or shows (and on pagehide), it simulates a copy of the pet's body forward in 10-minute steps for up to a day and writes the first time it will get hungry (tummy below 20, if it is not already), poop, get sick (if it is not already) or reach a new stage, as tokenling-alerts.json in the tokenling-alerts cache. The worker shows each due alert once, never while a pet page is visible: when the hidden page pings it every minute, and on a periodic background sync (tokenling-alerts, registered with a one-hour minimum where the browser offers it, which in practice means installed apps in Chromium browsers; the browser decides how often). Tapping an alert focuses or opens the app. Turning alerts off deletes the plan. Alerts are status lines written by the app ("Mochi is getting hungry"), not the pet's words. Tested in headless Chromium: the plan, the worker showing a due alert once and skipping a later one; not tested on a phone, and periodic sync was not observed firing.

Chat rendering #

ChatLine in web/components/Chat.tsx shows each pet line as plain text with the tags removed. Emotes and walking become an italic *giggles, toddles left*; tool calls become small chips under the line ("looked around …", "saved a memory …", "gave you a gift …", "said yes") using the tool's arguments and results. A silent line renders as a small centred chat-act item ("Mochi toddles left, bounces"). The chat is a polite live region, tabs follow the ARIA tab pattern, meters expose role="meter", and the LCD canvas has an aria-label describing the pet's state. With ?debug=1 in the URL a debug tab shows the prompts and sets the pet's age and stats (docs/testing.md).

LCD #

The canvas is 96×64 logical pixels, scaled with image-rendering: pixelated. drawScene() paints a window (weather and day or night), the gift shelf, poop, the pet or egg, an offered item, held food, floating effects and the want or "thinking" bubble, or a dream bubble while asleep. The pet only moves toward a target set by [walk:…] (or the minigame). drawPet() varies size, decorations and face per stage and animation (idle, happy, dance, spin, eat, pat, sleepy and so on). Icons are SPRITES in sprites.ts: rows of characters, one per pixel, using the palette in KEY.