| name | tinyworld-runtime-state |
| description | Use when adding or changing persisted user state — settings defaults, audio, camera/orbit, panel positions, feature flags, and the in-app "Save Defaults" pipeline that snapshots localStorage into tinyworld-defaults.json. Also covers the inline-script regex gotcha that has burned us twice. |
Tiny World Runtime State
All persisted user state lives in localStorage under the tinyworld:* prefix.
Read/write convention: stringified primitives or JSON.stringify for objects.
Never store credentials, world saves, or per-viewport pixel positions in the
shipped defaults file — see exclusion list below.
Defaults pipeline (dev → all users)
There is a "Save Defaults" button in Settings → Workspace (visible only on
localhost / 127.0.0.1 / file:). When clicked:
- The browser snapshots every
tinyworld:* localStorage key (minus the
exclusion list).
- POSTs
{ settings: { key: value, ... } } to /api/save-defaults.
tools/dev-server.js writes the result to tinyworld-defaults.json at the
repo root.
publish.sh copies that file into dist/ so it ships with the site.
- On every page load, the first inline
<script id="tinyworld-defaults-bootstrap">
does a synchronous XMLHttpRequest for tinyworld-defaults.json. For
each key the user does NOT already have in localStorage, it seeds the
default. Existing user prefs win — defaults never overwrite.
The bootstrap script MUST have an attribute (e.g. id="tinyworld-defaults-bootstrap")
so the tools/check.js regex doesn't grab it. See the inline-script gotcha
below.
Exclusion list (must stay in sync, two copies)
Mirror these regexes in both tools/dev-server.js (server filter) and the
inline setupDevSaveDefaults() IIFE (client filter):
/^tinyworld:v\d+$/ — serialised home world
/^tinyworld:worlds\.v\d+/ — multi-world saves
/^tinyworld:ai:key:/ — API credentials (SECURITY)
/^tinyworld:ai:prompt$/ — user prompt text
/^tinyworld:vehicle-demo:/ — session demo state
/^tinyworld:audio:music-track$/ — per-user manual music choice
/^tinyworld:audio:music-mode$/ — random vs manual music mode
/^tinyworld:welcome:dismissedId$/ — per-user welcome dismissal
/:backup$/ — any explicit backup
/\.pos$/, /-pos$/, /:pos$/ — panel/widget positions (viewport-specific)
If you persist a new value that should NOT ship as a default, add a matching
pattern to both lists in the same change.
Panel/widget positions — RELATIVE, not pixels
Draggable panels (minimap, crowd panel, agent panel, future panels) MUST save
their position as percentage of viewport, not absolute pixels. Absolute pixels
saved on a wide monitor land off-screen for users on smaller displays.
Format:
localStorage.setItem(KEY, JSON.stringify({
topPct: +(r.top / window.innerHeight).toFixed(4),
leftPct: +(r.left / window.innerWidth).toFixed(4),
}));
Read with backward compatibility for legacy absolute values:
let top, left;
if (Number.isFinite(p.topPct) && Number.isFinite(p.leftPct)) {
top = p.topPct * window.innerHeight;
left = p.leftPct * window.innerWidth;
} else if (Number.isFinite(p.top) && Number.isFinite(p.left)) {
top = p.top; left = p.left;
}
Always re-apply on window.addEventListener('resize') and clamp to
[8, innerWidth - w - 8] / [8, innerHeight - h - 8].
The existing minimap implementation (applyStoredMinimapPos /
endMinimapDrag) is the reference pattern.
Audio system
Two layers:
- HTMLAudioElement for music (looped) and one-shot SFX (cloned per play).
- Web Audio (PannerNode/StereoPannerNode) for positional sources
(engines, water) — distance attenuation + L/R pan based on
(sourceWorldPos - camera.position) projected onto camera-right.
State keys (AUDIO_LS):
tinyworld:audio:music / music-muted / music-track / music-mode
tinyworld:audio:sfx / sfx-muted
tinyworld:audio:ambient / ambient-muted
tinyworld:audio:engines / engines-muted
Music tracks: MUSIC_TRACKS array (currently 6 horizon + 1 rising). Random
playback must use only MUSIC_RANDOM_TRACKS / the music-horizon-* files;
music-rising-1.mp3 stays selectable manually but should not ship as a default
or be picked by automatic random playback. Avoid
prop engine files (large-prop-engine-*, foley-propellers-*) — the planes
have jet engines, use foley-rocket-engines-1..4. Water variants:
foley-water-1..4. Loop seams are hidden by overlaying two variants at
different start offsets and per-source gains.
UI: single #sound-icon button lives inside the toolbar (appended in
buildToolbar() near the audio panel reference). Click toggles the floating
#sound-panel with track list + 4 volume rows (Music, Effects, Ambient,
Engines). currentMusicTrack() resolves the persisted choice or random.
Camera / view persistence
Single key tinyworld:view.camera holds:
{ "mode": "perspective", "azimuth": 1.2, "polar": 0.9, "viewSize": 8.2,
"target": { "x": 0, "y": 0, "z": 0 } }
updateCamera() schedules a throttled save (250ms debounce) every frame the
camera changes. On boot, the let declarations read this key and apply with
clamping (clampViewSize, MIN_ORBIT_POLAR/MAX_ORBIT_POLAR). Ships in
defaults — sets the welcome shot for new users.
Feature flags
tinyworld:features:cluso — legacy Cluso flag. The in-page Cluso embed has
been removed, and no app runtime path reads this key.
tinyworld:features:ai — AI panel.
tinyworld:features:model-stamp-api — stamp-defaults dev endpoint.
Inline <script> gotcha (read this!)
tools/check.js uses this regex to extract the main app script:
html.match(/<script>([\s\S]*?)<\/script>\s*<\/body>/);
It matches the first plain <script> through to the last </script></body>.
If you add an extra inline <script> block (e.g. a bootstrap loader), it MUST
have an attribute so the regex skips it:
<script id="my-bootstrap">...</script>
<script>...</script>
Symptom when wrong: npm test fails with
inline app script syntax error: Unexpected token '<' because the regex
grabbed your bootstrap + the </script><script> separator + the main app.
Validation
After any persistence change:
node tools/check.js — inline JS syntax + schema parity.
node tools/smoke-static.js — no-browser smoke.
- Browser at
http://localhost:3000/tiny-world-builder with clean
localStorage in a fresh tab — confirm defaults seed correctly and the
app doesn't error.
- Then with existing localStorage — confirm user prefs are NOT overwritten.
Common pitfalls
- Saving panel positions as absolute pixels (do RELATIVE %).
- Persisting an API key, prompt text, or world save into defaults (add to
exclusion list in both server + client).
- Adding a new inline
<script> without an attribute (breaks npm test).
- Forgetting to restart
npm run dev after editing tools/dev-server.js —
the running process won't have the new route, returns 405.
- Letting a hard-coded camera default drift from
DEFAULT_AZIMUTH/
DEFAULT_POLAR/DEFAULT_TARGET — keep restored state clamped to those
ranges.