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.
Both run the same rules: the shared Rust crate 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.
<base>/ (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/<hash>/ main.js + brain.js + tokenling_core_bg.wasm, hashed together
├─ models/<id>/ tokenizer + config + onnx/model_q4.onnx
└─ runtime/<name>/ 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. |
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.
- 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.
- 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;fitdrops the oldest lines). - 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]). - On a lookup stop the turn appends
{result}and asks again with the longer prefix: up to three lookups per line. A line that failsNOT_CUTEis generated again, three tries in all. - 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. - 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
<base>/models/<id>/
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 (
fitincore/src/world.rs, counted by each brain's tokenizer). Long-term knowledge lives in the header'sremembers:andtoday: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/, 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 and
serde-wasm-bindgen.
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.