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
tummyorcleanbelow 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 atenergy < 6, but not within 5 minutes of being woken. It wakes on its own atenergy ≥ 99, or atenergy > 70between 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.ncounts this disturbance. The counter resets on waking, a new sleep, reload, or five quiet minutes. A successful message/pat roll writeswoke upbefore 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 intoS.history(days with fewer are dropped; only the newest seven are kept), andmaybeDiary()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.