Skip to content

Voice co-pilot, LLM agents, genie world-authoring, and a procedural score - #3

Open
dusterbloom wants to merge 53 commits into
mainfrom
feat/browser-llm-pilot-voice
Open

Voice co-pilot, LLM agents, genie world-authoring, and a procedural score#3
dusterbloom wants to merge 53 commits into
mainfrom
feat/browser-llm-pilot-voice

Conversation

@dusterbloom

@dusterbloom dusterbloom commented Sep 4, 2026

Copy link
Copy Markdown
Owner

Adds three agent systems, a creative world-authoring surface, and a rewritten soundtrack. Everything runs in the page — no server of ours in the loop, and no paid API anywhere in the UI.

Talk to the carpet

A co-pilot you speak or type at. Each turn returns a spoken reply plus an intent it acts on, grounded in agentAPI.observe() so it talks about the world you're actually in: take me to the crystal formation, raise a mountain ahead, make it sunset, let me fly the flamingo.

Three seams, each a factory returning the same shape, so a fourth backend never touches the caller:

  • llmProviders.js — any OpenAI-compatible endpoint, or WebLLM on WebGPU
  • voiceProviders.js — the OS voice (default), Kokoro, or Supertonic-3
  • listenModes.js — push-to-talk, or hands-free with semantic endpointing

localEndpoints.js probes the usual loopback ports, so anyone already running a local server is offered it before being asked for anything.

Every brain is free

The Brain picker offers Groq, OpenRouter (filtered to :free models), a local server, on-device WebLLM, and Apple FM. Anthropic is gone from the UI; its provider code, modelRouting.js and llmBudget.js stay on disk so an existing saved config keeps working.

Model ids are fetched live from each provider's /models rather than hardcoded, because hardcoded ids rot. probeEndpoint() already did that fetch for the local scan and now serves both callers.

Keys are scoped per brain, not per provider. Groq and OpenRouter both resolve to the openai provider at runtime, so the previous provider-keyed map would have sent a Groq key to OpenRouter — exactly what that map's comment says it exists to prevent.

The picker also learns. Some free models are gated to "agentic harnesses" and 403 from an app like this, and nothing in the /models metadata says so — pricing is zero, per_request_limits is null. So a 403 naming the model in use blocklists it for that brain, advances to the next candidate, and says so once. Requiring the model id in the error text is what keeps a bad API key — also a 403 — from quietly emptying the dropdown.

Apple FM

fm serve (macOS 26+) puts an OpenAI-compatible server on port 1976 — no key, no download, already there. It's probed second, behind only this project's own server.

Measured on a real Mac against the game's actual system prompt, 24 turns each:

model parseable correct median where it runs
pcc 24/24 24/24 1650ms Apple's Private Cloud Compute
system 24/24 13–17/24 (unstable) 3400ms your Mac

pcc is the default because the data says so, and the README says plainly that it is a network call, not on-device — only system is. Two fixes made it work: stream: false (fm serve streams SSE otherwise, which res.json() can't parse), and preferModels, because Apple lists system first so models[0] would have defaulted to the weaker model by accident of array order.

Conversation surface

CopilotConsole is a persistent chat panel. Before it, every turn — your words, the reply, the world edits — rendered as a toast and vanished, and there was no text input anywhere in the UI: a browser without SpeechRecognition had no path in but DevTools. Voice and typing are now two inputs to one conversation, and Firefox works.

UI

Tabs reorder to Agent / Flight / Race with Agent first. The Agent tab is one click and talk — Talk, Voice Off, and planet save/load, with settings collapsed once a brain is configured. SimpleBot and the LLM pilot lose their buttons but keep their code and their window.agentAPI entry points. A music mute sits in the HUD, one tap, no menu.

Genie world-authoring

window.worldAPI, deliberately separate from the fairness-bound agentAPI so creative edits can't taint a verified race. Conjure primitives and animated rigs, import from curated CORS-friendly repos, fly what you import, roam the ground as an animal, reshape terrain and sky, save and restore a planet. GenieCatalog persists it to IndexedDB.

Score

ProceduralMusicSystem rewritten around an actual composition: a theme derived from the planet seed, real chord progressions, a nine-phrase song form, and Karplus-Strong strings through a generated impulse-response reverb. Reroll the world and the music changes with it.

Found by playing it

Five bugs the automated tests could not have caught, all surfaced by driving the co-pilot against a real model:

  • World ops were started in order, not applied in order. _applyWorld looped a synchronous _applyWorldOp that fired async verbs without returning their promises. The exact sequence the system prompt documents — [{import Fox},{vehicle Fox}] — kicked off the import's fetch then immediately ran the vehicle lookup against an empty catalogue. Every "let me fly the X" left the model in the world and the player on the carpet.
  • A resolved-but-falsy result was reported as success. vehicle() returns false on a miss and spawn() returns [], but the bridge treated resolution as success — so the console said "🪄 now flying the Fox" while nothing had happened. That is why the failure was silent, and fixing it is what made every later diagnosis possible.
  • Catalogue names were looked up exactly, but stored slugified. uniqueName() writes "Flamingo" as flamingo; get() is a case-sensitive Map lookup. Every capitalised name missed, and the model cannot know the slug because it is generated after the model has written its ops. resolve() now slugifies with the same shared rule, matches exact-family → startsWith → includes without ever falling to a looser tier, and prefers the newest of a collision family.
  • Truncated JSON was read aloud. A cut-off reply failed extractJSON, and the fallback put the raw object into the chat bubble and the TTS. It is now repaired where recoverable — the reported string recovers its reply and its complete import op — and never spoken raw.
  • The prompt named only one import repo. threejs (the animated birds) and cesium existed in code but were invisible to the model, so a flamingo was unreachable by voice.

Testing

Build, build:pages and smoke pass; the game boots headless with no page errors. Voice turns driven end-to-end against a stub OpenAI-compatible endpoint. Mic behaviour driven through a faked SpeechRecognition. Apple FM discovery and model preference driven against a stub shaped like fm serve. Layout measured at 390×844 and 1280×800. The pure functions — endpointing, voice ranking, model preference, catalogue resolution, JSON repair — were each checked against case tables, and the ordering and false-success bugs were demonstrated before and after against the same script.

Not verified anywhere: real audio output, a real microphone, and a real fm serve process. The stubs cover the wiring, not the hardware.

🤖 Generated with Claude Code

https://claude.ai/code/session_01YYuB2vmEkWjXUWe6HzF8T1

mraxai and others added 30 commits June 14, 2026 16:15
On-device feedback: the carpet felt frame-rate fast and turned like a
supertanker after passing a gate.

- PlayerPhysics: velocityAlignRate 2.5 -> 5.0 (velocity follows the nose faster,
  less drift after a turn); absoluteMaxSpeed 420 -> 280.
- PlayerInputSystem: turnRate 1.6 -> 2.0 (sharper yaw); S brake 1.5 -> 1.2.
- UISystem FLIGHT_DEFAULTS: cruise 210 -> 140, rush 420 -> 280, punch 400 -> 300
  (live-tunable via the Speed tab; existing players hit Reset to default).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Make the for-humans-and-machines agent path zero-infra by running the agent
entirely in the page -- no server you host.

- llmProviders.js: one complete() interface over three serverless backends --
  cloud (direct browser fetch to the Claude API), openai (any OpenAI-compatible
  /chat/completions: LM Studio, Jan, Ollama, vLLM), and local (WebLLM on WebGPU,
  loaded from a CDN only when chosen -- no npm dep). The Anthropic path splits
  system out of messages so one shape works across all three.
- LLMPilot.js: hybrid racer -- SimpleBot flies the 10Hz floor (never dies) while
  an LLM advisor sets high-level line/speed/climb biases every ~2s. Drives
  through agentAPI.act(), so fairness + verified-replay recording are untouched.
- VoiceCopilot.js: talk to the carpet, it talks back. Browser SpeechRecognition
  for input, the same providers for the brain (grounded via agentAPI.observe()),
  browser speechSynthesis now / on-device Kokoro (CDN, opt-in) for output.
- RaceSystem: LLM Pilot and Talk buttons in the Race pane; one-time
  prompt + localStorage config.
- docs/AGENT_QUICKSTART: In-Browser LLM Pilot section.

No npm dependencies added; reuses agentAPI, SimpleBot, the Race panel, and the
browser own prompt/localStorage/speech APIs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The agent was racing-shaped; free it to roam the open world. observe() already
exposes landmarks, mana, terrain and position, and steering toward any of them
is the same bearing->turn, elevation->climb math.

- Companion.js: one goal-driven controller. Goals: manual (hands off), roam
  (explore toward the farthest landmark, else cruise + avoid terrain), goto
  (a landmark type), collect (nearest mana), race (gate course), hover (hold
  position). Reuses observe()/act(); terrain + ceiling safety on every action.
- VoiceCopilot: the brain now returns reply + intent + target, sets the
  Companion goal, and flies it while speaking - so you can ask it to go to a
  landmark, collect mana, wander, race, or hand control back, and it does it.
  Grounded with nearby landmarks/mana each turn.

Racing is now one goal among many. A literal second carpet (fly side-by-side)
is left as a follow-up - it needs a separate autonomous entity.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The voice co-pilot can now reshape the procedurally generated world in
realtime, all client-side. A single height oracle (getTerrainHeight) is
wrapped as base procedural terrain plus a list of smooth additive brushes,
so one edit changes the mesh, the physics collision, and the agent vision
at once. Affected loaded chunks are re-meshed live and the physics height
cache is flushed to match.

WorldSystem: window.worldAPI exposes raiseTerrain, carveTerrain,
clearTerrain, reroll, spawnMana, setTimeOfDay and terrainHeight. Edits are
sparse smoothstep brushes summed over getTerrainHeight; new chunks pick
them up automatically, only loaded chunks re-mesh. This is godmode and is
deliberately separate from the fair-play agentAPI, which never writes the
world.

VoiceCopilot: the model can return a world op alongside chat/intent. Point
ops resolve to a spot ahead of the carpet or right here from live state;
global ops (time of day, reroll) need no position. When there is no carpet
yet it says so instead of silently dropping the edit.

Verified in-browser: raise and carve move getTerrainHeight by the exact
amount, clear restores the base, reroll changes the world and clears edits,
spawnMana grows the node list, setTimeOfDay drives the atmosphere, and the
voice chain reaches worldAPI with correct coordinates, all with no console
or build errors across a 289-chunk re-mesh.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The voice co-pilot can now speak with Supertonic-3 — a lightning-fast,
multilingual, on-device neural TTS — alongside the browser voice and Kokoro,
selectable via config.tts. Still fully serverless: onnxruntime-web and the
model weights load from a CDN/Hugging Face only when this backend is chosen,
so there is no npm dependency and no bundle cost otherwise.

supertonicTTS.js wraps Supertone's flow-matching pipeline (duration
predictor, text encoder, vector estimator denoise loop, vocoder) behind a
single generate(text) -> { audio, sampleRate }. The pure text, mask and
latent helpers are lifted verbatim from Supertone's own static Space so the
maths is theirs and tested; we add only the orchestration and a clean
surface. Voice (F1..F5 / M1..M5) and denoise-step count are configurable.

VoiceCopilot: select with config.tts 'supertonic'; PCM playback is factored
out so Kokoro and Supertonic share it. A neural voice that fails to load
falls back to the browser voice and never blocks the co-pilot.

Verified in-browser: all four ONNX sessions load and a sentence synthesizes
to 3.86s of clean 44.1kHz audio (zero NaN, peak 0.97), and the VoiceCopilot
dispatch routes a reply through generate() into shared playback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Supertonic-3 is now the default TTS when config.tts is unset (was the
browser voice), and English is pinned across the board: a single this.lang
(config.lang or en-US) drives speech recognition input, the browser
speechSynthesis utterance, and the Supertonic language tag (en). Explicit
config.tts / config.lang overrides still win.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SimpleBot, the LLM pilot (which already embeds its own SimpleBot floor) and
the voice companion all push actions through window.agentAPI.act() every
frame. Running two at once made them overwrite each other each frame, so the
carpet thrashed and looped and the LLM pilot looked interrupted by the
simple bot. There was no arbitration; every launcher just started another
driver on top of whatever was already flying.

Now starting any autonomous driver stops the others first
(RaceSystem._stopOtherDrivers): launching SimpleBot or the LLM pilot stops
the other and parks the voice companion in manual, and when the voice
companion takes the controls (a non-manual goal) it stops the race bots.
VoiceCopilot gains an onGoal host hook so the host can arbitrate.

Verified: with all three stubbed as running, starting the LLM pilot stops
SimpleBot and sets the companion to manual while the pilot keeps flying.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ible

Kokoro and Supertonic render PCM into a WebAudio context, but it was created
lazily inside _playPCM, which runs after the async LLM call, long after the
click that started the co-pilot. Browsers block audio not tied to a user
gesture, so that context stayed suspended and both neural voices played
silently (the browser speechSynthesis voice was unaffected, which is why only
it was ever heard).

Now _ensureAudio() creates and resumes the context synchronously inside the
user gesture, in start() and on every listen() press, and _playPCM reuses it.

Verified: the shared context reaches the running state and _playPCM resolves
cleanly with a test tone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds the screenshot-to-commit history page generator (scripts/, public/history.html,
docs/SKYBLOOM_HISTOGRAPHY.md) and wires it into the build/build:pages npm scripts,
with the rebuilt dist output (now including the supertonic TTS bundle). Ignores the
local .playwright-mcp/ tool artifact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The sun and moon discs were placed at fixed world positions on an arc around
the origin, while the sky dome and stars follow the camera. So flying far
enough let you reach and pass the sun (most obvious at sunrise) while the sky
stayed put. The sky is a direction, not a place.

Now both discs are positioned as a camera-relative OFFSET (camera.position +
arc offset), so they ride at infinity and can never be reached. The arc math
and the camera-relative offset vectors are unchanged, so getSunDirection() and
the day/night look are identical. The directional lights are left origin-
relative on purpose: a directional light only needs its direction, which is
constant wherever the camera flies; shadow-frustum centering is a separate
concern.

Verified in-browser: moving the camera 150k units moves each disc exactly with
it (offset drift 0-1 units), so the sun/moon hold the same position relative to
the viewer near and far.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The fallback sky gradient was purely vertical (horizon->zenith), so it had no
idea where the sun was — the sky looked the same in every compass direction.
Now each sky vertex also blends toward a warm glow colour based on how closely
it points at the real sun direction, giving a halo that blooms around the sun:
a dramatic warm band low on the horizon at sunrise/sunset, a soft bright halo
around the disc at noon, and nothing at night (the glow gates off as the sun
sinks below the horizon). The glow tint is taken from the sun disc colour, so
it warms to orange at the horizon and pales to near-white when high.

Per-vertex directions are precomputed once; the per-frame paint adds one dot
product per vertex over a few hundred vertices, so it is effectively free.

Verified in-browser: warm halo concentrated toward the sun at sunrise, subtle
halo around the disc at noon, gradient preserved away from the sun, no glow at
night.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a live, switchable world shape on top of the flat infinite generator: a
camera-relative vertex bend that drops distant terrain by distance squared over
2R, the parabolic approximation of a planet. Distant ground curves away below a
domed horizon like a globe; near the viewer the drop is ~0, so what you fly over
still matches the flat collision heightfield, and generation/physics are
untouched. Flat mode is strength 0, a pixel-identical no-op.

The bend is injected into the two terrain MeshStandardMaterials via
onBeforeCompile (chunk meshes are translation-only, so local Y is world Y). A
shared uniform is re-centered on the camera each frame, and strength eases
toward its target so flipping flat<->round morphs smoothly instead of popping.

Exposed for humans and agents on window.worldAPI: setWorldShape('flat'|'round'),
setCurveRadius(R) (smaller = stronger curve), worldShape(). Default R=30000.

Verified in-browser: round visibly bows the horizon and sinks distant peaks
below it, flat is unchanged, both render without shader errors; fog hides the
far water (still flat) so there is no visible disc. Water/vegetation curvature
is a noted follow-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extends the flat/round world shape to the sea so it no longer stays flat
behind curved land. WorldSystem now owns a single curvature value and centre
and exposes getCurveUniforms(); it eases and re-centres every registered set
on the viewer each frame. The terrain materials register their own set;
WaterSystem pulls one and injects it into the Water example shader (which has
no begin_vertex include) by recomputing each vertex view position from a world
position dropped by distance squared over 2R. The whole world now shares one
curvature, so sea and land bow about the same point.

Verified in-browser: looking across open sea, round mode domes the water and
dips the far shoreline below a curved horizon while flat is unchanged; the
water shader is patched and shares WorldSystem's uniform; no shader errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extends the flat/round world shape to trees so they settle onto the curved
ground instead of floating above it in round mode. Trees are rigid, so each
whole tree is dropped by distance squared over 2R evaluated at its base (the
mesh origin) rather than bending each vertex — it stays upright and just sinks
onto the slope. Because a tree base uses the SAME parabola and centre the
terrain uses at that exact point, the base stays welded to the ground by
construction.

The active tree system is SimpleTreeSystem (not the unused VegetationSystem);
spawned trees are clones that share their template materials, so patching the
6 loaded models patches every tree. Keyed off WorldSystem's shared curvature
uniforms, alongside terrain and water.

Verified in-browser: a close line of trees stands upright and grounded on the
curved terrain in round mode, mountains bow away behind them, no shearing or
floating, no shader errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…uest visits

Completes the flat/round world shape across landmarks and fixes three landmark
problems.

Curvature: landmarks are rigid groups at fixed world points, so instead of
shader-patching their many materials, WorldSystem exposes curveDropAt(x,z) (the
CPU twin of the shader bend, using the same eased value and centre) and each
landmark subtracts the drop at its base from its flat terrain height every
frame. In round mode they settle onto the slope instead of floating.

Beacons (problem 3): the marker was a fixed 300-unit open cylinder that ended in
a hard edge like a stopped laser. It is now a tall pillar with a vertical fade
baked into vertex colours (bright base, zero at top) under additive blending, so
it dissolves into the sky.

Fly-through (problem 2): removed the landmark collision in PlayerPhysics. It
bounced the carpet out of a 30-200 unit zone (radius = size/2) for no gameplay
value; landmarks are fly-through waypoints now.

Quest visits (problem 1): checkLandmarkVisits used a 50-unit 3D test that never
fired because the carpet cruises far above ground-level landmarks, so the Visit
Landmarks quest never progressed. Now a visit registers by flying OVER one — a
horizontal radius scaled to size with a generous altitude band.

Verified in-browser: landmark drop equals the parabola, flying over a landmark
advances the quest objective 0->1, the beacon fades into the sky, no errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds setWorldShape to the co-pilot world ops and system prompt, so the LLM
companion can switch the whole world between flat (endless plane) and round (a
planet whose horizon curves away) when the player asks to make the world flat
or round/spherical/a planet. It is a global op (no position needed) routed
through window.worldAPI.setWorldShape.

Verified in-browser: a stubbed turn asking to make the world round flips the
world shape from flat to round through the full chain.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nk orbs

Three playtest complaints:

- Too many landmarks: magical_circle and crystal_formation spawned at 40% and
  30% per attempt with NO global cap, so they accumulated endlessly. Added a
  hard cap of 8 live landmarks, cut both frequencies to 0.0001, and widened
  their minimum spacing so they read as rare discoveries again.

- Very few mana: raised the live mana target from 30 to 80, pulled the spawn
  ring in (250-1200 from 300-1500) and tightened spacing, and refill now kicks
  in at 60% of target instead of near-empty, so the field stays dense.

- Pointless pink orbs: the ambient butterflies were pink/purple gradient
  sprites that just looked like floating pink spheres doing nothing. Disabled
  them (count 0, early return; flip the count to bring them back). Birds stay.

Verified in-browser: 0 butterflies, 80 mana nodes, landmark cap 8 in effect, no
errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…uds, sea level)

New creative ops on window.worldAPI, wired into the voice companion, each taking
a friendly level where 1 = normal and more/less scales around it:

- setTrees(level): live forest density via SimpleTreeSystem.setDensity — scales
  clusters and the per-chunk cap, then clears the forest so nearby chunks respawn
  at the new density (0 = barren, 3 = jungle).
- setLandmarks(level): LandmarkSystem.setMaxLandmarks — raises/lowers the cap and
  culls the farthest landmarks immediately when lowered.
- setMana(level): scales the live mana target (1 = 80), trimming or topping up.
- setClouds(on): CloudSystem.setVisible toggles the cloud sprites.
- setSeaLevel(level): WaterSystem.setSeaLevel raises/lowers the sea live and keeps
  WorldSystem.waterLevel in sync — level 1 floods the lowlands, 2 is a water
  planet, negative drains it.

The companion system prompt gains these ops, so the player can say things like
make it a jungle, drain the sea, clouds off, or turn this into a water planet.

Verified in-browser: every op moves its target via worldAPI (trees 0<->3, mana
160<->20, landmarks cap 24<->0 with culling, clouds hide/show, sea level to a
water planet), and the voice chain drives setSeaLevel, setClouds and setTrees.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…sons

Two more companion powers.

setCurveRadius by voice: a friendly level maps to the curve radius (round mode
+ radius = 30000 / level), so the player can say make it a tiny planet (level 3,
a tight dome) or a subtle curve (level 0.5, a gentle Earth). The op rounds the
world first so it always takes effect.

setSeason: spring / summer / autumn / winter recolour the land AND the forest
live. A shared fragment-shader tint re-colours each lit fragment toward the
season hue (luminance re-tinted, blended by mix), driven by one shared uniform
set so setSeason just mutates two values — no per-frame work, no respawn. It is
folded into the existing terrain curvature patch and the tree material patch via
a static WorldSystem.applySeasonShader, so terrain and trees season together.

Both are exposed on window.worldAPI (setSeason; setCurveRadius already was) and
taught to the voice companion.

Verified in-browser: autumn turns the land warm amber, winter washes it pale and
cold, and a voice turn make it a tiny planet rounds the world to radius 10000
with a strongly bowed horizon; both reach the world through the voice chain, no
shader errors.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two visibility problems with the companion editing the world.

Placement: ahead edits were dropped only 340 units forward, but the default
brush radius is 250, so a carved crater started ~90 units ahead — right under
the carpet, out of view. _worldPoint now pushes area edits to 450 + radius, so
the WHOLE brush lands clearly in front of and below the carpet. Verified: a
radius-250 carve now starts 450 units ahead of the carpet instead of 90.

Legibility: the on-screen note was a terse (world: carveTerrain). It now reads
in plain language via _worldLabel — carved a canyon ahead, made the world a
planet, turned it to autumn, flooded it into a water planet, cleared the
clouds, etc. — so the player can see what the copilot just did (alongside its
spoken reply).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…TS fallback

Playtest hit two dead-ends: Kokoro produced no audio, and after transcription
there was sometimes no response at all.

- Kokoro on WebGPU was loaded with q8 weights, which the WebGPU backend cannot
  run, so generate() failed. It now uses fp32 on WebGPU (q8 only on the wasm
  fallback) — the usual cause of Kokoro staying silent.
- A neural-voice failure was swallowed; now _speak surfaces it once
  (Kokoro/Supertonic voice failed — using the browser voice ...) and falls back,
  so there is always an audible reply.
- The brain call had no timeout, so a hung or unreachable provider produced dead
  silence. say() now aborts after 45s and reports No answer — the brain timed
  out... instead of nothing; other errors still show Comms trouble — <reason>.

Verified in-browser: an AbortError yields the timeout message, a thrown Kokoro
generate shows the fallback note once and the browser voice speaks, and the
WebGPU dtype resolves to fp32.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three playtest fixes for the agentic side.

Settings menu: replaced the 8-step prompt() chain (which queued blocking
dialogs and was uneditable once set) with a real form in MENU -> Race -> Agent
/ LLM. Brain (cloud/openai/local), Base URL, API key, Model and Voice are
editable any time and persisted to localStorage; Save restarts a running voice
co-pilot. runVoiceChat/runLLMPilot now read this config with no prompts, and an
empty cloud key points the player to the panel instead of failing silently.

No auto-race: starting the LLM pilot used to immediately start a race, which was
jarring. LLMPilot gains autoRace (default OFF) — it now arms and waits, and
flies a race only once one is started. Verified: 20 idle ticks start 0 races.

Kokoro: in addition to using fp32 on WebGPU (q8 is wasm-only), init now retries
on wasm/q8 if the WebGPU path errors before giving up to the browser voice.

Verified in-browser: the Agent form renders and persists config, no prompt() is
called when launching voice/pilot, and the LLM pilot no longer auto-races.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two playtest fixes.

Form typing: the game captured every keystroke (flight/menu keys) and
preventDefaulted them, so typing into the settings inputs did nothing, letters
triggered game actions, and copy/paste was swallowed. The key handlers now skip
when the focused target is a form field — InputManager.onKeyDown/onKeyUp bail
on INPUT/TEXTAREA/SELECT/contenteditable (covering the player input and Engine
KeyM), and the UISystem quest (Q) and spell (1-3) listeners do the same.
Verified: typing claude-opus-4-8-mq1 into Model lands in the field, m/q/1 no
longer toggle menu/quest/spells, and paste works.

Save the planet: WorldSystem.savePlanet/loadPlanet capture and restore the whole
creative world — seed, terrain edits, world shape + curve, season, sea level,
tree/landmark/mana density, clouds and time of day — to localStorage. Exposed on
window.worldAPI, given Save Planet / Load Planet buttons in MENU -> Race, and
taught to the voice companion (save the planet / load my planet). Verified:
after wiping terrain, season and shape, loadPlanet restores the exact terrain
height, season and shape.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
On a round/tiny planet, race gates and mana nodes kept flat world height while
the ground curved away, so they flew off the surface in a line — most obvious
at the smallest planet radius. They now subtract WorldSystem.curveDropAt at
their own x,z from the visual height every frame, exactly like terrain, water,
trees and landmarks already do.

Race gates: only the visual ring/beacon meshes drop; the logical gate.position
used for collision stays flat, so as the player nears a gate the drop falls to
zero and the ring rises to its true spot — the run still plays correctly.

Mana nodes: bob around a stored spawn height, then drop onto the curve.

Both are no-ops when the world is flat. Verified in-browser at radius 4000: the
ring sits in the hillside and the orbs hug the ground instead of floating.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…o top

UX pass on the existing menu structure (no rewrite).

Settings: the Race tab was a wall of stats + 14 buttons + the LLM form, brutal
on a phone. Added an Agent tab to the existing tab bar (Race / Agent / Flight /
Time) and split the panel: the Race tab now holds only race + ghost + planet +
export controls, while the Agent tab holds the bot/pilot/voice controls and the
LLM settings form. SimpleBot and the LLM pilot collapse from start/stop pairs
into single toggle buttons whose label flips with state (Run SimpleBot <-> Stop
Bot, LLM Pilot <-> Stop Pilot), so the agent controls drop from 6 buttons to 4.

Mobile: toasts move back to the TOP on phones so they no longer sit over the
thumb controls (joystick / W-S / camera) along the bottom edge.

Verified: desktop + 390px phone viewport show the four tabs, the Race tab with 8
focused buttons, the Agent tab with the toggles + form, and the toggle labels
flip with state.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The Flight tab now holds both the flight-feel sliders and the time-of-day
controls, dropping the tab bar from four tabs to three.

Doing this surfaced a latent bug: registerSettingsPane used replaceChildren and
required the menu to already exist, so a panel registered before the menu was
lost and a second panel on the same tab overwrote the first (the flight sliders
were not actually reaching the menu). It now APPENDS panels and QUEUES any that
register before the menu is built, flushing them once it is — so multiple
panels can share a tab regardless of init order.

Verified: three tabs, the Flight tab shows the 3 flight sliders AND the
time-of-day presets/scale/custom-time, and the Race (8 buttons) and Agent
(toggles + form) tabs are intact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…clipped)

The camera far plane is 5000, but the celestial bodies sat well beyond it: the
moon at 8000 (clipped -> invisible), the stars on a 6000 dome (the far half
clipped -> sparse, weird), and the sun arc out to 15000 (clipped at every angle
but noon). The far-plane cutoff was the invisible thing hiding the moon/sun and
eating the stars.

They are all camera-anchored, so absolute distance is arbitrary — only the
direction matters. Now each is placed at far * 0.72 (inside the far plane and
the sky dome): the moon and stars on a ~3600 dome, and the sun disc along its
direction at a fixed ~3600 instead of the elliptical 3000-15000.

Verified in-browser: the moon is centered and bright, the star field is dense
across the whole sky, and the sun disc is on-screen at morning, noon and sunset.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… far plane

Pulling the sun to a constant ~3600 (to stop far-plane clipping) made it show
its noon size at every time of day, so at sunset it loomed large and close
instead of receding small to the horizon as before.

The disc is now scaled by viewDist / trueArcDistance, i.e. shrunk to the angular
size it WOULD have at its real arc distance. So it looks identical to the
original: ~144px effective radius at noon (true dist ~3000) and ~33px at sunset
(true dist ~15000) — small and distant on the horizon again — while still
sitting inside the far plane so it never clips.

Verified in-browser: sunset sun is a small distant disc, noon is full size.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds a creative-mode "genie" that can reshape the three.js world, separate
from the fairness-bound agentAPI so it never taints verified races.

- GenieCatalog: IndexedDB-backed persistent library; every conjured or
  imported artifact is saved and re-spawnable offline (the genie remembers).
- GenieSystem (window.worldAPI, augmenting WorldSystem's terrain godmode):
  - spawn: primitives + procedurally-animated rigs (falcon flaps, paperplane
    glides), saved to the catalogue.
  - import: fetch glb from curated CORS-friendly repos (Khronos 118-model
    manifest with fuzzy match + tags; three.js animated birds), normalized,
    saved, dropped in front of the player.
  - discover: query the Khronos manifest so models can ask what's importable.
  - vehicle: fly a catalogue model instead of the carpet — wrapped+centered on
    the player anchor, embedded glTF clips played (Fox gallops, Flamingo flaps).
  - placement clamps to sea level (no more drowned imports).
- CarpetTrailSystem: settable emission anchor so the stream starts at a
  swapped vehicle's body, not the carpet's old back edge.
- VoiceCopilot + GenieAgent: spawn/import/discover/vehicle ops in the prompt,
  a GENIE grounding line so "what can you summon?" is answerable, all routed
  through the existing cloud/Qwen/WebLLM providers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Add a `cesium` import repo (plane / drone / car) — CORS-friendly animated
  models the Khronos set lacks, mapped to friendly names.
- selectClips(): play ALL independent part clips (twin propellers, rotors,
  wheels) instead of just the first; characters with rival locomotion gaits
  (Walk/Run/Survey) still play a single clip so they don't blend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
mraxai and others added 14 commits June 16, 2026 23:45
…he worldAPI genie

- GroundRoamController: a swapped vehicle (e.g. a fox) walks/runs ALONG the
  terrain instead of flying. Holding climb=-1 pins it to terrain+5; a
  proportional governor holds a pace (graze/walk/trot/prowl/run). Stays on LAND
  — veers off any sea ahead and turns back if it ever ends up over water. Holding
  SPACE sprints (reads the engine's real key state, key-listener fallback).
- GenieSystem: roam()/stopRoam() verbs drive it; trail() + CarpetTrailSystem
  emit toggle silence the flight contrail while grounded; clip priority now plays
  Run (not idle/Survey) for animals.
- Docs: README "What Works" notes the creative genie; AGENT_API.md gains a full
  "Creative mode: the genie (window.worldAPI)" section — objects/vehicles/roam
  verbs, import repos (khronos/threejs/cesium), world/atmosphere ops, examples —
  so agents can actually discover their powers.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
CI (pages.yml/ci.yml) builds fresh on every push, so the committed
dist/ (32 MB, including a duplicate copy of all screenshots) and the
build-regenerated mapping json/md + public/history.html only churned
the repo. Untrack them and gitignore the lot.

Also harden the history scripts: skip gracefully when screenshots/ or
git history is unavailable (tarball builds), and HTML-escape commit
messages, dates and file paths in the generated gallery.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…NaN guards

Sweep of 45 verified review findings across the June 14-16 work
(b220350..84c79b9), all verified live in the browser after the fix.

Player-visible bugs:
- LLMPilot/VoiceCopilot start() is now cancellable: stop() during model
  load no longer orphans an unstoppable 10 Hz autopilot
- keyup is always processed (guard was eating releases while a settings
  field had focus -> stuck keys); window blur clears all pressed keys
- vehicle() validates the new mount before removing the current one, so
  a failed swap no longer leaves the player invisible
- night-sky glow gates on the unclamped sun altitude (was glowing
  sunset-orange nearly all night)
- moon gets the same apparent-size compensation and origin-relative
  light the sun already had (was 2.2x too big on desktop, 5x smaller on
  mobile, with moonlight direction corrupted far from origin)

Lifecycle and resources:
- AudioContexts closed on stop; WebLLM engines get dispose()/unload;
  ONNX TTS sessions cached module-level; swapped genie vehicles and
  culled landmarks dispose their GPU resources
- RaceSystem destroy() stops all autonomous drivers and removes the
  agent panel (no more stacked panes or ghost pilots on re-init)

Arbitration and input:
- GroundRoamController joins the single-driver arbitration in both
  directions (new RaceSystem.stopAutonomousDrivers <-> genie stopRoam)
- SPACE no longer counts as flight-climb during ground roam, so
  sprinting animals stay pinned to the terrain

LLM-facing hardening:
- worldAPI numeric setters treat missing/NaN args as neutral (1), not
  zero; save/load sanitizes; setTrees(NaN) can no longer bake an empty
  forest into the planet save
- VoiceCopilot world ops accept sequences (array form), report actual
  per-op outcomes instead of unconditional success, honor the brain
  timeout on the local provider, and use one shared balanced-brace
  extractJSON
- per-provider API keys; switching Brain swaps the default model id and
  never sends one provider's key to another's endpoint

Consistency and cleanup:
- curvature GLSL extracted to src/game/shaders/curvature.js, used by
  terrain/trees/water, with a loud assertion if three's Water shader
  ever drifts past the regex patch
- conjured genie objects ride the curved world; reroll/loadPlanet
  resync landmark, mana and genie-object heights to the new terrain
- terrain edits indexed in a spatial hash (height queries were O(all
  edits) on the physics hot path); GLBs parsed once per spawn batch and
  cloned per instance
- deleted dead GenieAgent.js, ~160 dead lines in MobileUI, the dead
  vc.pilot localStorage write (a stray plaintext API-key copy), and the
  dead starDistance parameter; shared editable-target helper replaces
  four divergent copies; celestial 0.72 far-fraction named in one place

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…fe output

The old generator ran one static E-minor drone with sine bleeps and a
metallic delay-loop reverb, forever. Rewritten around an actual score:

- Each planet composes its own theme from the terrain seed (mode, root,
  tempo, an 8-note whistle melody with a peak and a resolution) — reroll
  the world and the music changes with it
- A real chord progression under every layer (i-i-VI-VII by day, a
  plaintive i-iv-VI-v after dark), phrase arranger with deliberate rests
- Whistle is one legato oscillator: portamento between notes, vibrato
  that swells in, a breath-noise layer — a whistler, not a synth
- Guitar and bass are Karplus-Strong plucked strings (noise burst in a
  tuned damped delay loop), arpeggios and a stride bass
- Convolution reverb from a generated stereo impulse response
- Pitched timpani, rimshots, and a dotted gallop that arrives when you
  fly flat out; brass stabs on phrase cadences
- Genie hook: worldAPI.setMusic mood calm/epic/night/off/auto, wired
  into the voice copilot so asking for epic music now works
- Volume/mute persisted in vc.music

Hard-won correctness: WebAudio lowpass Q is measured in dB, so any
positive Q peaks above unity — which made the string loop gain exceed 1
and the output explode exponentially. The damping filter now runs at
-12 dB, the feedback is strictly lossy, a tanh soft-clip caps the final
output at unity, and a watchdog kills the music outright if a voice
ever runs away again. Verified with an AnalyserNode: dry-bus peak 0.41
in the calm arrangement, 0.76 with every layer at full gallop.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…mpet B theme

The score now has somewhere to go. A nine-phrase form cycles
intro / A / A / A-prime / bridge / B / B / A-prime / outro:

- A: the whistle theme; A-prime lifts it an octave with a string
  section doubling the melody
- bridge: the drone drops out, the harmony climbs iv-v-VI-VII, a
  military snare roll and swelling strings build bar by bar, brass
  demands the arrival, and a tubular bell rings in the release
- B: a second melody composed from the same planet seed with the
  inverted contour (starts on the peak, descends home), handed to a
  solo trumpet — filtered saw with a pitch scoop into each note —
  over a wordless formant-filtered choir
- Each section owns its progression; night still bends everything
  plaintive, intensity still decides how many players are on stage

New voices: solo trumpet, string ensemble (chord bed + melody
doubling), two-formant ahh choir, tubular bell, snare roll. The
legato lead engine is shared between whistle and trumpet.

Verified muted with an AnalyserNode across the bridge-into-B
transition (the densest 40 seconds of the form): dry-bus peak 0.64,
RMS 0.19, watchdog silent.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The pilot and the voice co-pilot shared one model and one set of request
settings, which overpaid on the high-frequency workload and underpowered the
one where quality is the product.

- modelRouting.js holds the whole policy: pilot -> haiku-4-5 (4 numbers on a
  timer, SimpleBot flies the floor so a weak answer costs a racing line, not a
  run), voice -> sonnet-5 (open-ended speech plus ~25 world ops, where a wrong
  op is a visible failure).
- Cache the voice system prefix. The breakpoint goes on the system block, not a
  message, because the history window rotates every turn. Routing voice UP is
  what makes caching possible at all: the ~1.4k prefix is under Haiku 4.5's
  4096-token cache minimum but over Sonnet 5's 1024, so the repeated prefix
  bills at ~$0.30/MTok instead of an uncached $1.00.
- Disable thinking on models that default it on. max_tokens caps thinking and
  reply together, so Sonnet 5 would have eaten the budget and truncated the
  spoken line mid-sentence — silently, with no error.
- Stop re-asking the advisor when nothing changed. It ran on a fixed 2s timer
  regardless of whether the race had moved; it now skips on an unchanged
  quantized state signature, with an 8s hold cap and no call at all when there
  is no gate. 21 -> 11 calls on a synthetic cruise-then-approach trace.
- Per-task max_tokens (96 / 400) as a truncation guard, not a budget.
- llmBudget meters tokens, cache hit rate and cost per agent, shown live in
  MENU -> Agent and on window.llmBudget — the claims above are measurable
  rather than asserted.
- Settings gain auto/manual model mode; existing saved configs migrate to auto,
  since the model they held was the shipped default, not a choice.

Rationale and how to read the meter: docs/AGENT_COST_OPTIMIZATION.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uccHnFvACTx8XueXcW8vh
The cheapest token is the one you don't buy. If LM Studio, Jan, Ollama or
llama.cpp is already running on the player's machine, that should be the offer —
not a key prompt.

- localEndpoints.js probes the usual ports in parallel (GET {baseURL}/models,
  the one endpoint every OpenAI-compatible server implements) plus whatever base
  URL is in the form, and reports which models are loaded. ~14ms in Chromium
  with a server up, ~40ms with none; nothing in the UI waits on it.
- Never-configured players get the local server applied, not just offered —
  there is no key to lose and the Brain select reverses it. Players with saved
  settings get a banner and a one-click adopt.
- Discovered model ids become completions on the Model id field, so nobody has
  to guess the exact string.
- Brain options now read "cloud API (paid)" / "local server (free)" / "on-device
  (free)" — cost visible at the point of choosing. A missing-key toast names the
  free server that is already running.
- A probe can fail while the server is up (CORS, mixed content, Chrome's private
  network access). All three are indistinguishable from a closed port in a
  browser, so the UI offers a rescan instead of claiming nothing is there.

Verified in Chromium against a CORS-enabled stand-in server: all six ports
probed, models parsed, and a non-model server on a candidate port correctly
ignored rather than adopted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uccHnFvACTx8XueXcW8vh
Higgs is this project's own inference server, so it leads KNOWN_ENDPOINTS: the
first endpoint to answer is what gets offered and what an unconfigured player
auto-adopts. LM Studio and the rest still follow as fallbacks.

Verified: Higgs alone is found and offered; Higgs alongside LM Studio wins;
Higgs down falls through to LM Studio cleanly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uccHnFvACTx8XueXcW8vh
Playing the built game surfaced a bug no unit or in-browser module test caught:
with a server answering on :9000, the scan still came back empty. The panel is
built while the engine is still starting, and a blocked main thread delays the
fetch resolution and the probe's own abort timer alike — the 1500ms abort won,
so a server that had already replied 200 was discarded as absent.

- Defer the initial scan to requestIdleCallback (timer fallback) so it runs off
  the critical start-up path.
- Raise the probe timeout to 4000ms. It only ever costs time on a port that
  hangs; a closed port refuses instantly.

Verified end to end in the built game against a server on :9000: fresh player
auto-adopts it (provider openai, baseURL :9000/v1, model higgs-audio-v2), the
offer banner correctly retires itself, and the model completions populate.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uccHnFvACTx8XueXcW8vh
The static server never set Content-Length. AssetManager sizes every asset from
that header and swaps in a placeholder below 100 bytes, so with `npm run play`
every texture, model and sound read as 0 bytes, the whole catalogue fell back,
and the game sat on "weaving the sky…" forever. Serving the same dist/ through
a conventional static server worked, which is what isolated it.

- Set Content-Length (and Accept-Ranges) from the file stat.
- Answer HEAD without a body instead of piping one.
- 404 a missing path that has a file extension. The index.html fallback is for
  client routes; answering an asset request with the HTML shell is what let
  this hide in the first place.

Verified: assets report real sizes, the loader reaches 19/19, the overlay
clears, and the carpet renders with its own texture rather than the grey
placeholder.

Unrelated to the agent cost work on this branch — split it out if you'd rather
land it on its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012uccHnFvACTx8XueXcW8vh
Two small hardening fixes found while reviewing the branch for merge.

pages.yml checked out at the default fetch-depth of 1. The build now runs
match-screenshots-to-commits.mjs, which reads `git log --all` — with one commit
in the clone, every card on the deployed history page renders "No nearby commit
found". Verified against a depth-1 clone of this branch: 1 commit. fetch-depth: 0
is the fix.

The Kokoro and WebLLM dynamic imports carried no version. Both execute
third-party code in the page's own origin, next to the API key the player saved
in localStorage, so an unpinned specifier silently adopts whatever the registry
serves that day. Pinned to the versions in use (kokoro-js@1.2.1,
@mlc-ai/web-llm@0.2.84) — onnxruntime-web next door was already pinned, so this
just makes the three consistent. Bump them deliberately from here.

Verified: npm run build, npm run build:pages and npm run smoke all pass; the
built game boots headless with the loader clearing and no page errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YYuB2vmEkWjXUWe6HzF8T1
…o-pilot

The co-pilot's whole conversation used to render as transient toasts: your
words, its reply, the world edits it made, all gone in three seconds. And there
was no text input anywhere in the UI, so a browser without SpeechRecognition —
Firefox — could not use the feature at all. The code's own fallback was to tell
the player to call say() from DevTools.

The console is now the conversation surface, and voice and typing are two inputs
to one conversation rather than two code paths. VoiceCopilot already had the
right seam: onText/onState out, say(text) in. Speech and the keyboard both land
on say(); both render through onText.

- src/game/ui/CopilotConsole.js: docked panel, scrollback capped at 200 lines,
  per-role styling, a status dot mirroring VoiceCopilot's states, and a text
  field. It knows nothing about speech, TTS, LLMs or the engine — its only
  outward dependency is the onSubmit callback. Auto-scrolls only when the reader
  is already near the bottom, so reading history isn't yanked away. textContent
  throughout: turn text comes from a model and from the player.
- Empty state carries six example chips drawn from what the co-pilot can
  actually do. ~20 world ops and 118 importable models previously had no
  affordance beyond a Talk button.
- RaceSystem: onText/onState route into the console, the console's input routes
  to say(). vc.listen() now fires only when SpeechRecognition exists — otherwise
  the console says so and typing carries the feature. _toast keeps the transient
  operational notices.

Keyboard safety came free: a real <input> is already recognized by
InputManager.isEditableTarget, which every window-level game-key listener gates
on, so no competing keydown handling was added — stopping a keyup is exactly the
stuck-key bug this repo has hit before.

Verified in a browser against a stub OpenAI-compatible endpoint on :1234, since
the sandbox has no API key: Talk starts the co-pilot, typing "make it sunset"
renders the user line, the spoken reply, and "shifted the time of day" as the
world op lands — with the budget meter reading 1 call / 120 in / 40 out. Docked
bottom-left on desktop after measuring a ~100px collision with the settings menu
on the Agent tab, which is the tab Talk lives on; now zero overlap against the
settings panel, the minimap and the throttle hint, and on a 390x844 viewport
clear of both the W/S buttons and the joystick. Build, build:pages and smoke all
pass, and the game boots headless with no page errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YYuB2vmEkWjXUWe6HzF8T1
…the default

Two linked changes to how the co-pilot speaks.

The voice now follows the same shape as the brain. llmProviders.js has had
createProvider(config) -> {complete(messages, {signal}), dispose()} for a while;
the TTS side was the one thing in src/agents/ that didn't use it — an if/else
chain in _speak() over this.tts, plus _initKokoro, _initSupertonic, _speakKokoro,
_speakBrowser, _playPCM, _ttsFellBack and four fields of bookkeeping. It is now
createVoice(config) -> {speak(text, {signal}), dispose()}, and adding a fourth
voice no longer means touching VoiceCopilot.

- speak() takes a signal, mirroring complete(). stop() aborts the in-flight
  utterance and each voice silences its own playback, instead of VoiceCopilot
  reaching in to stop a shared _activeSrc and call speechSynthesis.cancel().
- The fallback is one decorator rather than a per-call re-test plus a warn-once
  flag. A neural voice that won't load, or that throws mid-sentence, warns once
  and routes to the OS voice for the rest of the session rather than re-failing
  every turn. An AbortError is rethrown, not swallowed as a voice failure — a
  cancelled turn isn't a broken voice.
- VoiceCopilot keeps owning the AudioContext. It has to: the context is created
  inside the click on Talk because browsers won't unlock audio off a gesture, so
  voiceProviders takes a getAudioContext() accessor and never constructs one.

And the default voice flips from Supertonic to the OS's own. Speaking used to
cost an onnxruntime-web download plus four ONNX graphs plus weights from
HuggingFace before the first word; on macOS speechSynthesis already exposes the
system voices for zero bytes and zero latency. Neural voices are still there as
an explicit "downloads" choice in the settings dropdown, and a player who picked
one keeps it — only the unset default changed.

Two bugs fixed on the way:

- speak() could hang forever. The old browser path resolved only on onend or
  onerror, and an environment with no installed voices fires neither, wedging
  the turn at 'speaking'. It's now bounded by a timeout proportional to the text.
- pickSystemVoice ranked language below everything else, so any premium voice in
  the wrong language outranked a correct one. On a stock Mac — a long list of
  localised premium voices — that read English dialogue in a Russian voice, which
  is worse than the download it replaces. Language is a filter now, not a
  tie-breaker, and the ranking is a pure exported function.

Verified in a browser against a stub OpenAI-compatible endpoint, both paths:
on the new default the turn completes and the status returns to ready (proving
the no-voice timeout, since headless has no installed voices); seeded to
supertonic with the weights unreachable, it reports the fallback and still
completes rather than wedging or throwing. Ranking checked against macOS- and
Windows-shaped voice lists, a list with no match for the language, an empty
list and junk input. Build, smoke and a headless boot all clean.

Audio was not audibly confirmed — headless Chromium has no installed voices.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YYuB2vmEkWjXUWe6HzF8T1
… hotkey

Talking to the co-pilot was a walkie-talkie: listen() opened the mic for exactly
one utterance, with interimResults off and continuous unset, behind a button
three clicks deep in MENU -> Agent. This makes it a conversation.

Listening is now a strategy, the way the brain and the voice already are.
listenModes.js owns every SpeechRecognition detail — VoiceCopilot no longer
mentions the API at all — and offers two modes. 'ptt' is the default and is
exactly the old behaviour, so nothing changes for anyone who doesn't opt in.
'handsfree' keeps the mic open across turns and streams the interim transcript
into the console as a provisional line that the final utterance replaces.

The interesting part is when a turn ends. Silence-only endpointing has to pick
one number and lose either way: short enough to feel responsive and it truncates
"take me to the… uh… crystal formation" into "take me to the"; long enough to
survive that and every "stop" feels laggy. isLikelyComplete() is a pure function
over how the transcript *ends* — trailing conjunction, filler, preposition or
article means the speaker is mid-thought, so wait 1600ms; anything that reads
like a finished clause fires at 700ms. No model, no download, and it gets most
of what real semantic endpointing buys.

The echo guard is v1 and deliberately blunt: an open mic transcribes the co-pilot
talking to itself, so recognition is suspended for exactly the duration of each
utterance. That trades interrupting-by-talking for correctness. V is the escape
hatch — it aborts the utterance mid-sentence and reopens the mic, using the
AbortController seam the voice-provider work put in. V follows the existing
hotkey pattern (KeyR, KeyQ): ignores repeats, ignores editable targets so typing
"voice" in the chat box can't fire it, inert unless a co-pilot is already running,
and removed on destroy.

Two bugs found and fixed during review:

- Stale recognizer callbacks could clobber live state. A barge-in opens a new
  recognizer while the old one's stop() is still winding down, and its onend
  still fires afterwards. Every callback now checks it belongs to the current
  instance.
- The hands-free restart backoff never accumulated. Chrome ends continuous
  recognition on its own so onend has to restart it, but the delay was reset
  whenever start() didn't throw — which only means the call was accepted, not
  that the session ran. Against a mic that starts fine and ends immediately that
  was a flat 300ms loop forever, ~3.3 restarts/second: precisely the battery
  drain the backoff existed to prevent. It now resets only on evidence the
  session did something — a result arrived, or it stayed up long enough.

Verified: the backoff repro that produced 10 flat 300ms restarts in 3s now gives
4, growing 302/542/975 and capping at 5006, while a session that gets a result
still restarts promptly at a flat ~360ms. isLikelyComplete checked against 20
adversarial cases (dangling "then"/"because"/"but"/"I want a" all wait; "go",
"stop", "hover", "land" all fire fast). Mic behaviour driven through a faked
SpeechRecognition since headless has no microphone: interim renders, final
replaces it and runs a turn, the echo guard suspends and resumes, and a
not-allowed denial stops for good instead of looping. The typed path is
unchanged, and a browser with no SpeechRecognition at all still falls back to
typing. Build, build:pages, smoke and a headless boot all clean.

Turn latency was A/B'd against the previous commit to rule out a regression:
identical within noise. Millisecond assertions against this repo's render loop
under swiftshader are unreliable unless measured inside the page.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YYuB2vmEkWjXUWe6HzF8T1
@dusterbloom dusterbloom changed the title Add voice co-pilot, LLM agents, genie world-authoring, and procedural music overhaul Voice co-pilot, LLM agents, genie world-authoring, and a procedural score Sep 4, 2026
… panel

Four owner-requested changes to strip the UI back to what people actually use.

A music kill switch in the HUD. ProceduralMusicSystem already had setMuted() and
already persisted the flag to vc.music, so the button calls that and keeps no
second copy of the state — the icon is correct on first paint because the music
system restores the flag in its constructor, before any system initializes.

SimpleBot and the LLM pilot lose their buttons but keep their code. Both stay
reachable through window.agentAPI and DevTools, and runSimpleBot/runLLMPilot are
untouched — smoke.mjs asserts SimpleBot.js exists, that RaceSystem still contains
runSimpleBot, and that the README mentions it, so deleting any of that would have
turned CI red. The panel is what was cluttered, not the API.

Tabs reorder to Agent / Flight / Race, with Agent opening by default.

The Agent tab becomes one click and talk: Talk (no icon), Voice Off, and planet
save/load moved over from the Race tab. The settings form now sits behind a
Settings disclosure, collapsed once a usable brain is configured and expanded
when it isn't, since that is the only path to configuring one. The collapse
threshold reuses the same predicate _requireBrain already used, factored out as
a pure _brainConfigured() rather than duplicated.

The mobile fix is the interesting part. Putting the button in a wrapper div made
it a direct child of #ui-container, and Engine.js sweeps exactly that shape on
mobile — `#ui-container > div:not(#health-bar):not(#battery-toggle)` — 500ms
after the mobile UI initializes. The row went to display:none and took the button
with it: present in the DOM, zero-sized, unclickable, on the devices where a
one-tap mute matters most. The button is now a direct-child <button> instead,
which that div-only selector cannot match, positioned off the mana pill's live
width via a ResizeObserver so it self-corrects as the digit count grows and
collapses to the safe edge when the sweep hides the pill. On phones a media
query moves it to the empty top-left corner, clear of Power Save and MENU.
Engine.js itself is untouched — the fix stays on the button's side of that
existing contract.

Verified in a real browser at both sizes: the actual music.muted boolean flips on
click and survives a reload, with no collision against MENU, Power Save, the
minimap or the touch controls. Tabs read Agent/Flight/Race with Agent open; the
Agent tab shows Talk, Voice Off, Save Planet, Load Planet with settings collapsed
when configured and expanded when not; Save/Load Planet are gone from Race and
work from their new home. The typed voice turn still lands its reply and world
edit end-to-end. Build, smoke and a headless boot are clean.

Pre-existing and deliberately untouched: the settings panel overflows
horizontally at 390px wide. Measured identically before this change.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YYuB2vmEkWjXUWe6HzF8T1
The co-pilot is the largest thing on this branch and the README didn't mention
it at all. Adds a "Talk to the Carpet" section — the three brains and what each
costs, why the default voice downloads nothing, hands-free listening, and the V
hotkey — plus V in the controls table and a line under What Works.

Four honest limits alongside it, because they're the ones a reader would
otherwise discover the hard way: speech recognition belongs to the browser, and
Chrome ships microphone audio to Google while Safari keeps it on-device and
Firefox has none, so only the on-device brain with the OS voice keeps a turn
local; a cloud key typed into settings lives in localStorage and goes straight
from the page to Anthropic; and the co-pilot edits the world through worldAPI,
which sits outside the fairness constraints, so terraforming mid-race doesn't
produce a comparable benchmark run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YYuB2vmEkWjXUWe6HzF8T1
The mobile rule sized the panel with `width: min(440px, calc(100vw - 20px))`
while it was still content-box, so the 20px of padding on each side and the
border landed on top of that width rather than inside it. At a 390px viewport
that made a 370px panel render 412px wide, pushing its right edge past the
screen and giving the whole page a horizontal scroll.

box-sizing: border-box in that same media query, so the width means the width.

Measured at 390x844 with a real iPhone UA, panel width / right edge /
documentElement.scrollWidth against a 390 viewport:

  before  412 / 401 / 401   (overflowing)
  after   370 / 380 / 390   (no overflow)

Holds on all three tabs and in both Agent states — the unconfigured one with the
settings form expanded is the widest case and still fits. Desktop is untouched:
the rule is inside the max-width 640px block, and at 1280x800 the panel still
measures 350 wide with no page overflow. Build, smoke and a headless boot clean.

Pre-existing bug, not from the recent UI work — I measured the same 412 at
269dea4 before touching it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YYuB2vmEkWjXUWe6HzF8T1
…cker

Nobody should be asked for a paid key to talk to the carpet. The Brain picker
now offers Groq, OpenRouter, a local server and on-device — all free — and
Anthropic is gone from the UI. Its code stays: createCloudProvider,
modelRouting.js and llmBudget.js are untouched on disk, so a config that already
names it keeps working and the cost meter keeps its pricing table.

These are presets, not new providers. Groq, OpenRouter and the local server all
resolve to the existing `openai` provider with different base URLs, so
createProvider()'s switch and createOpenAIProvider are unchanged.

That creates a hazard worth naming. Saved API keys were keyed by *provider*,
with a comment explaining the map exists so one provider's key is never sent as
a Bearer token to another's endpoint. Groq and OpenRouter are both `openai` at
runtime, so keeping that keying would have shipped your Groq key to OpenRouter.
Keys are now keyed by brain id instead, and _brainRuntime() translates the brain
id to {provider, baseURL} at the point of use. Verified in a browser: a key saved
under Groq and one under OpenRouter land in separate slots, each field shows only
its own, and neither is prefilled with the other's.

Model ids are fetched live rather than hardcoded, because hardcoded ones rot as
providers retire models. probeEndpoint() in localEndpoints.js already did exactly
`GET {baseURL}/models` for the local-server scan, so it gained one optional
apiKey header and now serves both callers. OpenRouter's list is filtered to the
`:free` suffix so only genuinely free models are offered; Groq's /models needs a
key, so it loads once one is entered, and either falls back to the free-text
model field rather than blocking anyone.

Verified: build and smoke pass, the game boots headless with no page errors, key
isolation holds under a real save/switch/save cycle, and a legacy
provider:'cloud' config still renders the panel with Talk working and 'cloud'
offered only because it was already saved.

Not verified here: the live model fetch. This sandbox's headless browser has no
outbound network (page-side `fetch` to openrouter.ai fails where curl through the
proxy succeeds), so only the fallback path was exercised — which degrades to the
text field with no page errors, as intended. The fetch itself needs a check on a
real machine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YYuB2vmEkWjXUWe6HzF8T1
…that works

macOS 26+ ships an `fm` CLI whose `fm serve` puts an OpenAI-compatible server on
port 1976. For anyone on a current Mac that is a brain with no key, no download
and no signup, already running. It is now probed alongside LM Studio and the
rest, second in the list, behind only this project's own server.

Three things were needed.

`createOpenAIProvider` now sends `stream: false` explicitly. fm serve returns an
event stream unless told otherwise, and res.json() cannot parse that, so without
this every turn against it fails. false is what every other OpenAI-compatible
server already does when the field is omitted, so it changes nothing for LM
Studio, Jan, Ollama, vLLM or llama.cpp.

fm serve reports two models: `system` runs on the Mac, `pcc` is Apple's Private
Cloud Compute. preferredModel() took models[0], and Apple lists `system` first —
so the default would have landed on the weaker one by accident of array order.
Endpoints can now carry `preferModels`, and Apple FM names `pcc` first. This is a
property of the endpoint table rather than a special case inside the function,
which stays a pure function of its argument. describeEndpoint() was reading
models[0] too, so the "found a server" banner would have advertised `system`
while adopting `pcc`; it now names the same model adoption will use.

The choice of `pcc` as default is measured, not assumed. Against the game's real
system prompt, 24 turns each on a Mac running fm serve: `pcc` scored 24/24 both
parseable and semantically correct at ~1650ms median, while `system` scored
13-17/24 across runs — unstable, and ~3400ms. Worth being precise about what
that buys: `pcc` is a network call to Apple's servers. Privacy-preserving, but
not on-device, and the README says so rather than letting "runs on your Mac"
quietly cover both models. `system` stays selectable for anyone who wants the
turn never to leave the machine.

Verified against a stub shaped like fm serve on 1976: discovery returns both
models and the preference, describeEndpoint reads "Apple FM · pcc", and a real
turn through createOpenAIProvider posts `stream=false` with `model=pcc`, which
the stub confirms receiving. preferredModel checked against a case table —
preference honoured in either list order, ignored when it names a model the
endpoint doesn't report, and falling back to models[0] and then 'local-model'.
Build, smoke and a headless boot clean.

Not verified here: a real fm serve process. No Mac in this environment; the
behaviours above were measured on one and encoded as the stub's contract.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YYuB2vmEkWjXUWe6HzF8T1
…ailed

Reported from a real LLM session: "let me fly the fox" left a giant static fox
in the world while the player stayed on the carpet, and "flamingo" did nothing.
Four bugs, none of them the model's output.

Array world ops were started in order, not applied in order. _applyWorld looped
`for (const op of w) this._applyWorldOp(op)`, but _applyWorldOp was synchronous
and fired async verbs as `Promise.resolve(...).then(...)` without returning
them. So the sequence the system prompt itself documents —
[{op:import,name:Fox},{op:vehicle,ride:Fox}] — kicked off the import's fetch and
then immediately ran vehicle's catalogue lookup, which missed because nothing
was saved yet. Both functions are async now and each op is awaited before the
next starts. One failing op still reports and still doesn't abort the rest.

Resolving is not succeeding. vehicle() returns false on a catalogue miss and
spawn() returns [], but the bridge did `.then(() => ok(), fail)` — so a resolved
false took the success path and the console said "now flying the Fox" while
nothing had happened. That is why the failure was silent. Every async verb now
inspects the resolved value.

The model was never told which repos exist. REPOS defines khronos, threejs,
cesium and local, but the prompt only ever showed "repo":"khronos" and the
bridge defaulted to it — so a flamingo (three.js) was unreachable by voice. The
prompt now names all four and what each is for, which also fixes the standing
advice about animation: the threejs birds and cesium vehicles ship clips, most
khronos assets don't. _guessRepo covers the case where the model omits a repo,
matching the name against each repo's curated examples before falling back.
Prompt grew 288 chars, ~72 tokens.

Conjured objects defaulted to scale 100 while ridden ones use 20 — hence "huge"
once the vehicle swap failed. Now 40, reasoned from the drop distance and the
carpet's own 8-unit length rather than copied from the vehicle path.

Verified in a browser against a stubbed worldAPI whose import resolves after a
deliberate 300ms, run against both the old and new code:

  before   vehicle Fox RAN, catalog=[]     + "now flying the Fox"
           spawn resolving [] reported as  "conjured a sphere ahead"
  after    import Fox RESOLVED -> vehicle Fox RAN, catalog=[Fox]
           spawn resolving [] reported as  world edit didn't take

_applyWorld stays un-awaited in _sayTurn on purpose: the reply is meant to play
over the edit, and _applyWorldOp funnels every branch through try/catch to
ok()/fail() so it cannot reject. Ordering within an array is what needed fixing,
not the speech overlap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YYuB2vmEkWjXUWe6HzF8T1
Reported from a real OpenRouter session. The model's reply was cut off mid-object:

  {"reply":"Summoning a flamboyant Flamingo for you to ride!","intent":"chat",
   "target":null,"world":[{"op":"import","repo":"threejs","name":"Flamingo

extractJSON needs balanced braces, so it returned null, and _sayTurn's fallback
put that entire string into the chat bubble AND spoke it aloud. The import op
sitting complete inside the text never ran either, because `world` was never
parsed. Three fixes.

extractJSON now repairs a truncated object as a last resort, after the existing
balanced-brace scan fails. It re-walks the text tracking what each open brace or
bracket is expecting, and at EOF either closes an open value string in place —
keeping content the model actually produced — or rewinds to the last complete
key/value pair and drops the unfinished tail. It never guesses a missing key or
a half-typed literal, and still returns null when repair won't parse.

_sayTurn no longer speaks raw output. When parsing fails on text that looks
structured, it pulls just the reply sentence out; failing that it says something
honest of its own. Prose that was never JSON is spoken unchanged, as before.

The voice budget goes 400 -> 900 tokens. A reply plus a two-op world array
routinely exceeded 400, and some providers spend budget on preamble before the
object. Repair covers what still gets cut; a turn that fits needs none of it.

The repair deliberately completes a truncated value string rather than dropping
it, which is the one place it can return plausible-but-partial data. That is
safe here because every consumer validates: an unknown op fails and says so,
worldAPI's numeric setters ignore a non-finite value, a name that doesn't match
misses the catalogue, and the enum-ish fields fall through to their defaults.
Checked against a truncated op name, a severed numeric, a cut ride target and a
cut `where` — all degrade to an honest failure or a correct default, none to a
silent wrong action.

Verified with a case table: the real flamingo string recovers its reply and the
complete import op; truncation inside a key, immediately after an opening
bracket, and inside a string containing an escaped quote all behave; well-formed
objects, prose-wrapped objects, prose, empty and garbage input are unchanged. In
a browser, the truncated turn now shows and speaks the sentence and runs the
import; an unrecoverable blob still yields the sentence rather than the blob.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YYuB2vmEkWjXUWe6HzF8T1
…them

Reported from a real session: picking the OpenRouter brain auto-selected
thinkingmachines/inkling-small:free and every turn failed with

  OpenAI-compat 403: thinkingmachines/inkling-small:free is only available on
  agentic harnesses.

The model list is whatever /models returns, filtered to ids ending :free, and
the first entry wins. That entry is currently gated to agentic harnesses — free
to price, unusable from an app like this.

There is no way to see this coming. I fetched the model's full object: pricing
is {prompt:"0",completion:"0"}, per_request_limits is null, and no field
anywhere flags the restriction. So rather than curate a list that would rot, or
invent a heuristic that pretends to know, the picker now learns from the one
authoritative signal available — the request actually failing.

A turn that fails with an OpenAI-compat 403 whose text names the model in use
marks that id unusable for that brain, stored in vc.voice.blockedModels keyed by
brain exactly as apiKeys already is. The id drops out of the dropdown, the saved
model advances to the next cached candidate, and the player is told once. At
most one advance per running session, so a bad list can't cascade into retrying
nineteen models. A "Clear N blocked models" button undoes it, since a model can
stop being gated.

Requiring the model id in the error text is the discriminator that matters: a
bad or revoked key also returns 4xx, and blocklisting on any 403 would quietly
empty the dropdown and leave no way to tell why. Verified both shapes — a
gating 403 naming the model blocks it and advances; "OpenAI-compat 403: No auth
credentials found" leaves the blocklist untouched and the model unchanged; an
ordinary assistant reply does nothing; and two gating failures in one session
still block exactly one model and toast exactly once.

Also fixes a regression this would otherwise have introduced: the settings form's
persist() builds its save object by hand rather than spreading the current
config, so pressing Save would have wiped blockedModels every time. It now
re-reads that field fresh at save time, so a block recorded by a live turn
failure survives a Save made while the panel is open.

The live session is stopped rather than hot-swapped, matching what Save already
does — the provider captures its model id at construction, and reaching into a
running VoiceCopilot to swap it is more invasive than telling the player to
press Talk again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YYuB2vmEkWjXUWe6HzF8T1
…y they're stored

Reported: "let me fly the flamingo" summons one and leaves you on the carpet,
while "I want the flamingo as a vehicle" works. The console said exactly why:

  🪄 summoned a Flamingo ahead
  (world edit "vehicle" didn't take — "Flamingo" isn't saved yet — import or
   spawn it first)

Capital F. uniqueName() slugifies, so import saved the entry as "flamingo",
while catalog.get() is an exact, case-sensitive Map lookup. Every capitalised
name missed — Flamingo, Fox, Duck, Parrot, Horse — and the model has no way to
know the slug, because it is generated after the model has already written its
ops.

That also explains why the second phrasing worked. It is a second turn, and by
then _summonContext() has folded the real catalogue into the prompt, so the
model can read the stored "flamingo" and use it. No phrasing can rescue the
first turn, which is why the fix belongs here and not in the prompt.

GenieCatalog gains resolve(): slugify the query with the same rule uniqueName()
writes with — now a shared static so the two cannot drift — then match exact
family, then startsWith, then includes, never consulting a looser tier once a
tighter one has hit. Where a tier yields several (repeated imports leave
flamingo, flamingo-2, flamingo-3) the newest wins, so summoning a thing and then
riding it rides the one just summoned. get() stays strict for internal callers
that already hold a stored name; resolve() is for names arriving from outside —
model output, a voice transcript, a typed field. vehicle() and spawn({catalog})
are the two fed that way and now both use it.

Checked against 14 cases: the reported "Flamingo" resolves; so do "flamingo",
"FLAMINGO" and the partial "flam"; a collision family returns the newest while
an exact sibling stays reachable by its full name; "Flamingo" does not steal
"flamingo-jr"; and "Fox" resolves to fox rather than foxglove — over-eager
matching that rides the wrong object would be worse than failing. Empty
catalogue, empty query, symbols-only, null and undefined all return null.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YYuB2vmEkWjXUWe6HzF8T1
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants