# Pet simulation, storage and UI The rules on this page (body, care, offers, tags, the day log, diary and turn scheduling) live once in the shared engine in `core/` (see [its guide](../core/README.md)). In local mode the page runs it as WebAssembly; `web/app/` drives its clock and turns, and plays its effects. 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. In remote mode the Rust host runs the same engine natively and also generates thoughts while the page is closed; the browser renders synchronized state. The host uses the save's time zone; a local pet uses its device's clock. 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. ## Choosing and changing its home Setup asks local or remote, defaulting to local. Local downloads the browser brain and works offline; remote connects to a chosen, trusted Rust host and shares the save, conversations and memories with it. Remote eggs hatch only when the server’s native brain is ready, without a browser model download. Settings always exposes both choices. Local-to-remote prepares inactive server state and persists transfer intent before activation. Remote-to-local parks the host and receives the latest full save before starting the browser brain; the browser clock is reset to avoid replaying remote time. Names, traits, birth time, memories, shelf and diary are preserved. The selected brain is pinned; a host with another release is rejected without upgrading. Mode changes wait for an active catch game to finish. Foreground remote sync polls once per second and keeps a browser checkpoint. Late or older responses cannot replace a newer snapshot or local branch. Owners can reconnect, or explicitly recover locally from the last checkpoint during an outage. Server-only changes may be missing, and the old executor may continue until its pending stop is delivered. The browser retains pending stop keys separately in `subtokenling.remote-stops.v1`, including after moving to another host, and retries every 30 seconds while its owning tab is open, on reload and on reconnect. The stop receipt never overwrites newer local progress. See [the ownership protocol](../server/README.md). ## 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. Any gap over two minutes (a hidden or closed tab, a host restart) is a quiet catch-up: up to seven days in 10-minute steps, writing what happened into the day log ("was very hungry", "was stinky", "pooped", "napped"). When the owner reopens a local page after 30 minutes or more, the pet also gets `* OWNER came back after … *`, " was away for a while" and a reaction. Catch-up advances the body clock (`lastTick`), so the next tick does not apply the same elapsed time again. An offer left open while nobody ran the pet is dropped during catch-up. - **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. A second offer while one is open is refused ("still deciding"), as is cleaning with no poop. 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 `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](testing.md#debug-panel)). A page is written: - while the pet sleeps at night (21:00–04:59) with at least two log entries and no page yet for that date, or - for a missed day: at local midnight the engine moves a day with at least two entries and no page into `history` (days with fewer are dropped; only the newest seven are kept), and the pet writes them one at a time, oldest first, when the brain is free, the tab is visible (locally) and the owner has not interacted for 30 seconds. A toast names the date. The engine's `choose_task` decides this in both modes. A page whose text fails the cute filter is dropped whole; its day stays pending and is tried again. 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 (`weather_at`), so the window, the `now:` line and diary headers agree. ## Turns: react and idle The engine keeps the requested turn and its idle timer; `pump()` (browser) and the host's dispatcher run one task at a time when the brain is free. - **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 (locally) 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 the context changes while a turn is generating (a new line, an owner command or the pet falling asleep, including a body collapse that wrote no chat event), the result is thrown away; the change itself asked for the next turn, which sees the new context. - 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 `subtokenling.v1`, JSON. Saves are debounced by 200 ms and failures (private mode, quota) are ignored for ordinary local ticks. Transfers use a confirmed immediate write before relinquishing local execution. Remote mode stores a synchronized checkpoint here, while its authoritative state lives in the host’s private SQLite database. Access keys, connection IDs and transfer intent use the separate `subtokenling.remote.v1` browser key and are excluded from exported local recovery saves. ``` v: 2 save version (VERSION in core/src/save.rs) 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 } hosting: { mode: "local" | "remote", brainId: string | null, timeZone: string } ``` 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 (reading fills missing top-level keys from a new save; a missing setting reads as false). Reading also validates the save as the host does: names, closed lists (stage, traits, toy, offer kind), need ranges, timestamps and history limits. Unknown fields are kept. Changing or renaming a stored shape means bumping `VERSION` and adding a step to `MIGRATIONS` in `core/src/save.rs`. ### Same-origin rebrand compatibility Before parsing, the bridge copies legacy `tokenling.v1` and `tokenling.bad` byte-for-byte to their new keys only when each target is absent. This includes broken or newer-version JSON: moving a namespace is not a stored-shape migration or version bump. A source is removed only after its copy succeeds. If both keys exist, the current key wins and the legacy bytes are retained; even an unreadable or newer current save shows the bad egg rather than falling back to a readable old pet. If copying into a missing save key fails, `load()` can read the old key; the first successful subsequent current save retires that old key. Unreadable data still disables saving and shows the bad egg. This bridge applies on the existing origin/path only, not across origins or by moving an installed app to another path. The namespace copy happens before parsing. The independent stored-shape migration then upgrades readable v1 saves to local v2 saves; unreadable or newer bytes are never rewritten. ### Save versions and the bad egg `parse_save` in `core/src/save.rs` (`parseSave()` in the page) 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 `subtokenling.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. The historical Preact rewrite kept v1. Save v2 adds hosting metadata; v1 migrates to local mode with the original pet data intact. Invalid hosting metadata/time zones and newer saves are rejected. Exports are local recovery saves; an import starts locally and does not copy a server access key. Importing over a remote pet first parks it and saves its latest local copy. A failed import write therefore retains the previous pet rather than deleting its only authoritative save. The parked host record remains private. ### One tab at a time Two tabs writing the same key would undo each other. At boot the page asks for the compatibility Web Lock `tokenling.v1` (`ifAvailable`), deliberately unchanged so old still-open tabs cannot run alongside the new app. 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 both live save keys (`subtokenling.v1` and legacy `tokenling.v1`) 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. In remote mode, start over also deletes the authenticated host record before clearing the browser save; a failed host deletion keeps the current 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 (`subtokenling-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), as is the mutable web manifest. Both requests revalidate HTTP caches; only navigation can fall back to the home page, never a missing manifest. Other assets are 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. During same-origin service-worker activation, the compatibility bridge copies all entries from legacy `tokenling-app` and `tokenling-alerts` caches into `subtokenling-app` and `subtokenling-alerts`, preserving destination entries. Any alert-plan URL ending in `/tokenling-alerts.json` is mapped to `/subtokenling-alerts.json`; the copied response retains its `sent` flags so migration does not repeat delivered alerts. Each old cache is retired only after all its copies succeed. An existing authorized periodic-sync subscription is transferred by registering `subtokenling-alerts` before unregistering the legacy `tokenling-alerts` tag, then the worker claims clients. No permission is granted by the migration, and model files remain in unchanged `transformers-cache`. This is a namespace bridge, not cross-origin storage migration. **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 push service, including in remote mode. 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 `subtokenling-alerts.json` in the `subtokenling-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 (`subtokenling-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`.