# Architecture
tokenling is a static site plus a model, with an optional Rust pet host. In
local mode the browser owns state, simulation and inference. Remote mode
explicitly transfers ownership to the chosen host; the browser becomes a
renderer/controller with a recovery checkpoint. See [the Rust host](../server/README.md).
Both run the same rules: the [shared Rust crate](../core/README.md) in
`core/`, linked natively by the host and compiled to WebAssembly for the page.
A deployment may enable a separate Cloudflare Web Analytics beacon for page
traffic and performance. The builder adds it and its disclosure only when a
site token is configured; pet/owner data never enter analytics. Local state
and generation remain in the browser; remote data go only to the chosen host.
See `deployment.md` for configuration and privacy checks.
```
/ (static files from any web server)
├─ index.html site/index.html with its placeholders filled (site/build.sh)
├─ bench.html test/bench.html with the release's model/runtime, unlinked
├─ sw.js site/sw.js: offline copies and alerts
├─ assets/ site/assets/* (font, manifest, icons)
├─ app// main.js + brain.js + tokenling_core_bg.wasm, hashed together
├─ models// tokenizer + config + onnx/model_q4.onnx
└─ runtime// transformers.min.js + ONNX Runtime Web .wasm/.mjs
browser
├─ main thread main.js Preact components + typed app controllers; the
│ shared engine (WebAssembly) runs the pet's rules
├─ worker brain.js loads the model, streams tokens; the shared stop and
│ context-fit rules from the same module
├─ service worker sw.js serves the offline copies, shows alerts
├─ localStorage "tokenling.v1" the whole pet (JSON, see pet-simulation.md)
├─ Web Lock "tokenling.v1" held by the one tab that runs the pet
└─ Cache Storage "transformers-cache" the downloaded model files
"tokenling-app" page, app and runtime for offline use
"tokenling-alerts" the alert plan
```
## Modules
| File | Role |
|---|---|
| `core/src/`, `core/wasm/` | The shared rules (prompt contract, body, care, tag effects, turns, diary, saves) and their WebAssembly bindings. See [core](../core/README.md). |
| `web/core.ts` | Typed wrapper of the WebAssembly exports: lists, prompt/lookup/text helpers, stop/fit, saves and the `Engine` class. Loaded before boot in the page and in the worker. |
| `web/app/engine.ts` | The browser's engine instance: replaces/edits the save, runs local commands and refreshes the read-only `store.state` snapshot (body fields only on a plain tick). Changes hand their effects to `playEffect()`. |
| `web/main.tsx` | Loads the rules module, claims the tab, loads the save, mounts Preact and resumes the cached brain. |
| `web/components/` | `App.tsx` owns tabs; `Device.tsx`/`Setup.tsx` own the LCD, care and setup; `Chat.tsx`, `Diary.tsx`, `Shelf.tsx`, `Settings.tsx`, `Brain.tsx` and `Debug.tsx` render the panels and dialogs. `shared.tsx` supplies the revision hook and sprite component. |
| `web/types.ts`, `web/runtime-types.ts` | State (mirroring the core's serde types), scene, worker request/response types and the small API surface used from the separately vendored transformers.js. |
| `web/app/store.ts` | The state snapshot, brain/view state and transient UI state. `emit()` increments a revision; Preact subscribes. Streaming text is data, never detached DOM nodes. Animation frames do not rerender the UI. |
| `web/app/save-format.ts`, `web/app/storage.ts` | Browser wrappers of the core's save reading (device time zone) and guarded debounced persistence of the engine's JSON. |
| `web/app/simulation.ts` | Drives the engine's body clock: one-second ticks, quiet catch-up with the owner's return, clock resets after a transfer, and cached weather. |
| `web/app/conversation.ts`, `web/app/text.ts` | Runs the task the engine chooses (react/idle line, diary) while the brain is free, feeding each reply into the shared turn; chat chip descriptions. |
| `web/app/hosting.ts`, `web/components/Hosting.tsx` | Authenticated sync, staged local/remote transfers, stale-response rejection, recovery and mode controls. |
| `server/src/` | Rust private API, SQLite ownership/receipts, background scheduler and native ONNX inference around the shared engine. |
| `server/src/routing.rs` | Numeric worker cost estimates, two-lane queue planning and age-based priority. |
| `server/tests/`, `server/src/bin/` | ownership/API/body tests, Python and native/WebAssembly parity, native inference probe. |
| `web/app/care.ts`, `web/app/diary.ts` | Egg progress and care actions as engine commands; writing the diary page the engine asks for. |
| `web/app/animation.ts`, `web/app/canvas.ts`, `web/app/game.ts` | Animation state and `playEffect()` for local and host effects, canvas attachment/cleanup, and catch physics/input. |
| `web/app/brain.ts` | Model cache, download, upgrade, worker protocol and one-thread fallback. |
| `web/app/pwa.ts`, `web/app/lifecycle.ts` | Offline resources, install prompts/alerts, Web Lock ownership and page lifecycle. |
| `web/app/commands.ts`, `web/app/debug.ts` | UI actions for setup/chat/settings/save management (`petCommand()` runs locally or on the host), and debug controls/prompt history. |
| `web/app/config.ts`, `web/app/utils.ts` | Host configuration, feature gates, constants and small shared helpers. |
| `web/brain.ts` | Module worker: imports the vendored runtime, loads the model on WebAssembly and streams generation, fitting and stopping with the shared rules. Bundled as `brain.js`. |
| `web/scene.ts`, `web/sprites.ts` | Existing 96×64 canvas renderer and pixel art, now typed. |
| `site/bundle.mjs`, `package.json`, `package-lock.json`, `tsconfig.json` | Pinned Preact/TypeScript/esbuild build, strict checking and Prettier formatting. |
| `test/body.test.mjs`, `test/storage.test.mjs`, `test/fixtures/save-v1.json` | Body, engine and save rules through the WebAssembly build, and compatibility with a real previous-app save. |
| `core/tests/rules.rs` | Native checks of saves, stop/fit, the line loop, offers, scheduling and catch-up. |
| `test/browser-flow.js` | Repeatable playwright-cli flow for a local debug preview with a real brain. |
| `test/hosting-flow.js` | Real remote creation, lost activation acknowledgement, closed-page execution, transfers and checkpoint recovery. |
| `server/tests/native.rs`, `test/host-startup.py` | Opt-in real CPU/GPU/private-HTTP qualification and startup fallback. |
| `train/migrate_v6.py` | Rewrites old chat headers and sleep events, rebuilds loss spans, removes old teen rows and validates the combined dataset. |
| `test/data_rules.py` | Regression cases for sleep, offers, validation, loss masks and migration; includes a stand-in sleep teacher. |
| `test/site_tools.py` | Stdlib-only regressions that exercise the real build, publish and preview scripts with disposable model/runtime stand-ins. |
| `site/sw.js` | Service worker beside the page: keeps what the page lists for offline use (cache first; the page itself network first), shows due alerts, opens the app from one. |
| `site/build.sh` | Builds the rules module, fills `site/index.html` and `test/bench.html` from `site/release.json`, copies the app under its hash, the assets and the worker, and writes `_headers`. |
`site/build.sh` runs `core/wasm/build.sh`, the TypeScript checker and
`site/bundle.mjs`, then hashes the emitted app/worker/rules bytes together.
The host page loads `main.js` and supplies
only `#pet-root`, `#pet-config`, styles and static explanatory content. Preact
owns all interactive markup. There is no hydration or server rendering.
The body remains independent of Preact: controllers call the engine, which
changes the save, then notify subscribers at ticks, actions and token updates. Components own form
inputs, tab selection and refs. The canvas effect cancels its animation frame
and removes input listeners when it unmounts (including tab takeover).
The host contract is in `host-page.md`.
## Execution ownership
Setup asks local or remote, defaulting to local. Existing v1 saves migrate to
local v2 saves without changing pet data. Settings permits both directions.
`hosting` pins mode, selected brain and time zone; a separate browser record
holds remote access keys and transfer intent. Exports omit keys and restore
as local recovery saves.
Local-to-remote prepares an inactive record before activation and records
remote intent before it gives up local execution. Remote-to-local parks the
host atomically, fetches the latest full state and resets the browser body
clock. Epoch/context checks fence late replies; operation IDs deduplicate
retries. An explicit outage recovery uses the last checkpoint and asks the
old host to stop when reachable, without overwriting the new local branch.
Remote mode stops browser body/brain scheduling. The host's copy of the same
engine owns simulation, lookups, tags, memories and diary; returned effects
only animate the LCD, exactly as local effects do. The browser's engine then
only holds the latest checkpoint.
The host thinks while the page is closed. Its native CPU worker is resident;
a separately configured CUDA worker can handle reactive turns. Full serving
and GPU performance qualification are separate from prior engine probes.
## The pet's turn
The same engine schedules turns in both modes; the browser's `pump()` and the
host's dispatcher only run what it chooses while a brain is free.
1. Something asks for a turn. **React** turns follow an owner message, an
offer, a pat, cleaning, the lights, poop, waking, growing up, coming back
or the end of a game. **Idle** turns come every 8–20 s (minutes while
asleep or with "lively" off), so the pet lives on its own. Today's diary
page is written at night once the pet sleeps; missed days follow after 30
quiet seconds. React beats idle; a hidden local page answers reactions only.
2. The engine's chat turn renders the state header and asks the brain to
continue `Name:` after the chat lines that fit (1024 tokens for react
turns, 512 for idle ones; `fit` drops the oldest lines).
3. The brain streams tokens. The shared stop rule ends a line at a newline,
or right after a complete lookup tag (`[clock]`, `[look]`, `[recall:…]`,
`[yesterday]`).
4. On a lookup stop the turn appends `{result}` and asks again with the longer
prefix: up to three lookups per line. A line that fails `NOT_CUTE` is
generated again, three tries in all.
5. If the context changed while it was generating (the owner spoke, an offer,
a pat, the pet fell asleep), the line is discarded; whatever changed asked
for the next turn. Otherwise the engine commits it: the line joins the
chat (keeping at most two consecutive silent lines) and its tags take
effect: answer an open offer (`[yes]` eats, bathes, takes medicine or
starts the game), walk, animate, show a want bubble, save a memory, put a
gift on the shelf, note something for the diary, fall asleep or show a
dream. Sleeping offers wake on acceptance; other sleeping lines only show
dreams. An offer the pet saw but did not answer counts as no.
6. The chat view renders the line as plain text plus small "tool" chips, or a
silent line as a short action ("Mochi toddles left, bounces"). Effects
play on the LCD.
The diary uses the same machinery with a `## tokenling diary` header, the day's
log as context and the prefix `dear diary,`; it runs to a new header or a blank
gap, and the engine keeps up to three known `[doodle:…]` stickers and the
paragraphs. A page with unsafe text is dropped and its day stays pending.
## Where the model comes from
```
teacher data ───────► train/gen_data.py ──► new.jsonl ─┐
older dataset ─────────────────────────────────────────┴► train/mix_data.py ► data.jsonl
│ train/train.py (full fine-tune)
GPT-2 (124M) ─────────────────────────────────►ckpt (safetensors)
│ train/export.sh
optimum ONNX ► quantize.py ► model_q4.onnx + SHA256SUMS
│ train/publish.sh
/models//
```
See `training.md` and `export-and-runtime.md`.
## Constraints that shaped the design
- **The brain decides.** Every choice the pet makes comes from a tag the model
emits; the app runs the body and carries out decisions. That means many
short generations (an idle turn every few seconds), so speed and prompt
length matter more than eloquence.
- **Context is 1024 tokens.** The header is about 150 tokens, so the chat view
sent to the model keeps only the newest lines that fit (`fit` in
`core/src/world.rs`, counted by each brain's tokenizer). Long-term knowledge lives in the header's `remembers:` and
`today:` lines and in lookups, not in raw history.
- **GPT-2 small is small, on purpose.** Behavior is taught by example; it
cannot follow instructions, and its occasional silliness is part of the pet.
Everything it should "know" is in the prompt format.
- **Phones in local mode.** A 97 MB download, WebAssembly only, about 0.8 GB of page memory
once loaded (measured on desktop Chromium; phones not measured).
- **Storage.** localStorage is about 5 MB, enough for state; the model goes in
Cache Storage, and the page asks for persistent storage so it is not evicted.
- **Speed.** About 35 tokens/s on a 4-thread desktop WebAssembly session (see
`export-and-runtime.md`); most turns are a few tokens.
## Shared rules
One Rust crate, [`core/`](../core/README.md), holds the prompt rendering,
closed lists and lookups, stop and fit rules, body math, care and tag effects,
turn scheduling, the diary and save reading. The host links it natively; the
page and its worker load the same code as WebAssembly through
[wasm-bindgen](https://wasm-bindgen.github.io/wasm-bindgen/) and
[serde-wasm-bindgen](https://wasm-bindgen.github.io/wasm-bindgen/reference/arbitrary-data-with-serde.html).
Preact/canvas, browser storage/lifecycle and browser inference stay in
TypeScript; HTTP, SQLite, queue routing and native inference stay in the host.
Python keeps independent training-format parity.
Time, calendar and random draws are inputs, so both builds replay the same
scenario identically (`server/tests/parity.mjs`). The browser keeps the engine
inside the module and reads a snapshot after changes; a plain one-second tick
reads only the body fields, so the transcript and diary do not cross the
boundary every second, and canvas frames never do. The module is part of the
content-hashed app directory and the offline cache, and loads before the save
is read.