The kit's guide: read this before building any game on the Gameable engine
30 September 2026. One guide for every template. The rules below are the three games' lessons (Tala Me This, Hollow Reign, Millennial: docs/kit/LEARNINGS.md, the reports in docs/games/) folded into what the engine's core now does for you. Each rule says why it exists and which command of the core does it. Plain words first; the engineers' names in code. The templates and the routing from a brief to the closest one: templates/ROUTING.md; each template's own guide is GUIDE.md beside its code.
Who reads this: the agent that builds a game from a template, before it writes a line. Isaac reads it to see what a game gets for free. The harness reads docs/games/<game>/game.json for the facts and docs/kit/COSTS.md for the cost of each game.
The one idea
A character enters a game at a level, never as a build. 512 / 256 / 128 are about 150,000 / 63,000 / 21,000 points (58 / 25 / 8 MB on the card); the budget is points posed a frame: 252,000 on a phone or a headset. A fight of two Full builds posed 1.3 to 1.9 million a frame on 24 Oct 2026 and took 5.7 to 7.9 GB: the phone dropped it. The recipe load-a-character says which level and why.
A game starts from the closest template and customises it. The core already does what every game this week built by hand: the characters' idle and life, touch, the loading order, the fades, the badge, the sound on a phone, the framings. Use them. Build only what is the game's own (its rules, its rounds, its world, its words).
The default standards: "the default is do this" unless the person asks otherwise (Isaac, 30 Sep, 5:50 pm)
A person who says nothing about these gets the proven look. Any of them can be changed on request; none is skipped because nobody asked.
| Standard | The default | Why | The core's command |
|---|---|---|---|
| A loading screen, always | The agent estimates the load from the manifest (the core's loading order reports bytes and files per character and in all), and makes a loading screen from the models and pictures the game already has (the characters' own pictures, the place's panorama, the game's sign): short when there is little to load, longer and richer when there is more. The person never has to ask for one. The bar shows real bytes, never a fake sweep. | Hollow Reign's first hero at 191 s on a 40 Mbit/s line with nothing on screen; Tala built a montage of poses while he loaded unseen (its best idea: keep it) | bridge.loading.onProgress(p => ...) (p.loaded, p.total, p.filesDone, p.files, and p.characters[] each with its copy, state and bytes), the template's loading.ts draws it from assets.json |
| Characters load in with a particle effect | Wherever the page can draw it (the new graphics path), a character forms out of a cloud of fine particles in its own colours, as it forms in the studio; on a phone without the path, a plain fade. | Isaac's standard; the studio's forming is the house arrival | extras: { pointHooks: true } and the forming hook (examples/forming/src/forming.ts, carried into the template as arrival.ts until the core has it as a helper; see "not in the core yet") |
| A character selector that looks like Hollow Reign's | The heroes in a row, EVERY one on screen at full size (the framing presets from each character's measures, the row fitted to the screen's width on a desk and a phone), each on its turntable with the weighted spin (slow at the front, fast round the back), the own idle and the life on, one in focus at the body framing, a tap or a key to choose, the chosen one loaded first. | The selector Isaac approved on Hollow Reign after three rounds | framingFor('body', entry.measures, aspect), turnSpeedDeg(TURN_CURVE, offDeg), wholeTurnSeconds, loading.order |
| The badge, touch, loading by order, the own idle and life, native speech | The core's, on by default; a template never switches one off. | Every game this week wrote its own or left it out | createBadge, input({ touch }), loading, ownIdle, life, a2f: 'isaac-gnm' |
| The movement system (4 Oct 2026) | An exported character is moved by whole takes of a performer matched to the velocity the game gives it (character.setState): its starts, stops and turns are the performer's own, joined by an inertial blend; its feet are held where they land; the take carries its walker, so nothing slides. On by default for every character with a motion set (its package's own over the engine's shared Everyday set; the shared set alone for a character with none); the set arrives after the character stands, so first sight is not delayed. At rest a character stands in its own captured stance. A game never wires a walk cycle, a start or a stop by hand, and never moves a character's feet. One switch turns it off for a game; a character with no set moves by the locomotion group below, as before. Actions (a hurdle, a jump) are in the set and off by default. | Isaac, 4 Oct: "I'd love to have that just running by default, like the baseline in the engine, like the main template" | on by default; off: features: { characters: { motion: false } } in src/game.ts (one character: "motion": false on its rig in assets.json); character.motion(entity, { carry, responsiveness, naturalness, set }), character.walkTo(entity, spot, { facing, pace }), character.do(entity, action, object); motion-event in ctx.events; docs/movement.md |
| The core's locomotion group | (What a character with no motion set plays, and every character in a game with the movement system off.) A character walks and runs through the core's locomotion group (character.setState with the ground speed): the clips are the ones Isaac supplies (his packs, named when they arrive), loaded for every exported character with createCharacterBridge({ locomotion: { set: '<url>' } }) and called by the core's own names (walk, jog, run, sprint, the strafes, the turns, the starts and stops, the jumps). A game never picks clips from the shared library itself (Isaac, 7:05 pm: those running clips are terrible), never bakes a clip per hero, and adds its own clips in the same style through the manifest's rig.clips. Without a set, a package plays its own idle / walk / run in the blend and nothing else (the fallback). The blend reads each clip's real foot travel on THIS character, so the feet do not slide at any speed. | Isaac on Hollow Reign: the animations "very rough, especially running: it looks very low; we need better packs and have them use them"; 7:12 pm: "the running sucks; I'll go find running clips" | character.setState(entity, state, vx, vy, vz, grounded); createCharacterBridge({ locomotion: { set } }); LOCOMOTION_CLIP_NAMES in gameable/animation; docs/concepts/characters.md, "Walking and running: the core's locomotion group" |
| Cutscenes: on request, never by default | A moment that matters (a boss falls, a level ends, a reveal) MAY get a cutscene: a short clip made from the game's own references (the character's pictures, the monster's, the game's style words) through the studio's drawing-and-clip road, made at build time and shipped with the game as a file, played over the stage at the event through the core's cutscene player (a fade in and out, skippable, the stage paused, the game's sound ducked). The kit's word: no template plays one on its own; a game gets one only when the person asks. Each template names its moments as slots (the action template: the boss's death and the level's end; the host show: the round's end) and shows a plain card there by default; when asked, the game calls cutscene.play. The end goal is a cutscene design panel in the studio. | Isaac, 30 Sep 6:05 pm: "when you finally kill the monster, instead of just blah ... a cool prompt creates a cool animated thing in the same style that does the thing. Have it come back, and just have it be a thing that works." | the maker POST /cutscenes/make (the studio's companion); cutscene.preload / cutscene.play(asset, { fadeIn, fadeOut, skippable, pause }) in gameable; createCutscenePlayer in core, createCutsceneBridge in wasm-host; the clip a video entry of the manifest; docs/cutscenes.md |
| Seen in motion on a phone before it is called ready | A recording on the browser's phone emulation and the desk, every control by touch. | Isaac on Hollow Reign's stills: "garbage" | the template's record.mjs |
The rules
1. The characters have physics bodies: a hit, a fall, a push are one command
- Why: Hollow Reign wrote its own reactions (knock-backs, deaths, staggers) and used none of the physics the characters carry. Isaac, 30 Sep: "the characters have ragdoll physics bodies ... just make them dynamically respond ... less work since it's already set up."
- What a character has: every character spawned from a studio export has a physics body: eighteen parts as rigid bodies on the engine's physics, joined with a real body's ranges, sized from its own skeleton; the parts follow the animation while it stands; a blow lets them go; it falls onto the place's collider, never through it. Nothing to set up beyond the physics module.
- The command, ONE, before writing any reaction:
character.hit(entity, { at, direction, strength }):atwhere the blow lands (the nearest part takes it, and the body reacts to WHERE it was hit),directionwhich way it travels (omitted: from the front),strength'small'(a flinch, back in about a second),'big'(down on the floor, stays down),'shot'/'big-shot'(sharp, with a flinch), or a number of newton-seconds for a push (40 staggers, 90 and over floors).character.recover(entity)blends back into the animation over about a second where the character stood (move the entity to where it lies first to have it get up there). The events, asanim-events on the clipragdollinctx.events:hit(the blow landed),fell(the floored body came to rest, within three seconds),recovered(back in the animation). - The rule: use the core's physics before writing a reaction. No hurt clips, no position nudges, no sinking into the floor, no fades, no hand-keyed staggers, no own 2D maths for the floor and the walls.
- The cost: a standing character's body about 0.1 ms a frame; four characters down at once about 0.2 ms a frame for the bodies. Measured on Hollow Reign's heroes: a small hit moves the chest 6 to 15 cm at most, back in 1.8 s; a big hit floors the hero in under a second, lying on the floor; a push of 60 newton-seconds moves the chest 21 cm.
- Where: on the landing branch since the evening of 30 Sep (
engine-land-09302d93c00); the pagedocs/character-physics.md; the facadecharacter.hit/character.recoveringameable. The action template keeps ONE place where every blow goes (applyHit()intemplates/action/src/systems/hits.ts: the damage, then that one call) and never a reaction of its own. A character WITHOUT a physics body (the engine's sample character, which placeholder enemies wear) is told so by the core's warning; the template lays such a one down by its turn, nothing else. The body's other physics stand anyway: the walker (physics.moveCharacter), impulses, queries (physics.raycast,physics.overlapSphere), contacts.
2. Speech: native face frames through the engine's speech player, never a jaw moved by hand
- Why: Millennial moved the jaw on a timer at 2 am because our own kit told it to; the result was a puppet mouth. Tala forked the relay to get the face's own frames. Both are the core's now.
- The command: the relay entry asks for the native frames (
characters: { <id>: { character, story, a2f: 'isaac-gnm' } }), the player plays them (createSpeechPlayer(context, presentation, bus, { gnmVoice })with the package'sgnm_voice.json;expression: (w, space) => characters.setExpression(entity, space, w)). A recorded line is the same road with a file. Neverw[17], never a timer, never the 52 standard weights for our own characters. - The mouth's inside: the package carries it; the bridge draws it (
extras.mouth,extras.mouthMesh: 'textured'). Never say "no teeth".
3. Idle: the character's own, on by default; never a pose made in code
- Why: Hollow Reign's heroes bounced on one joint, sunk 10 cm into the floor, heads bowed, because the game set a pose each frame and played a combat idle made for another body. Isaac: "not idling, frozen in place ... they should feel alive though not static"; his rule: no automatic rest pose.
- The command: nothing.
createCharacterBridge({ ownIdle: true, life: true })is the default: the captured stance, the hips at their rest height, the feet planted, the package's idle laid over the upper body, breath, a shift of weight, glances, the head and eyes following the person by the package's Life setting, blinks by it, the holds (Meg's crossed arms) kept. A game states what the character is doing (entry.life.setState('idle' | 'listening' | 'thinking' | 'speaking', voice)), never how the bones should sit. - Read the package's intention (
entry.animation:included/style/none, the clips it covers, the style line) before choosing clips; a named clip the character lacks is skipped, never faked.
4. Touch and the loading order come from the core
- Why: every game this week wrote its own touch layer (Hollow Reign 694 lines, Millennial its own, Tala by page buttons) and its own loader (Tala patched the browser's fetch; Hollow Reign loaded four heroes at once: the first hero at 191 s on a 40 Mbit/s line, 25 s in order). Isaac: "every game playable on phones and tablets unless said otherwise"; "load the first thing, then the second thing".
- The commands:
input({ touch: { sticks: 'both' | 'left' | 'none' }, overlay: true }); in the gameinput.pressed('Tap'),input.axis2(...)(folds the left stick in),input.touch.stick('right'),input.touch.pinch,input.touch.turn; the gesturesTap,DoubleTap,Hold,Drag,Pinchbind like keys. Nothing by hover. Loading:createCharacterBridge({ loading: { order: [...] } })(on by default; the first thing first, each character's light copy (rig.lightin the manifest) before its full one, the place after the first character:bridge.loading.placeTurn()),bridge.loading.onProgress(...)feeds the page's bar. Never patchfetch; never fetch everything at once; never a lower level to go faster. - The drawing is built before the first sight (
buildAhead, on by default): no freeze when a character first shows.
5. Framing from the character's measures, never numbers typed in
- Why: Hollow Reign hard-coded every camera number (head top = head joint + 0.24 m, five heights and pitches tuned by hand over a day); Isaac: "framing is bad", "too top-down and too close".
- The commands:
entry.measures(eyes, head top, crown, hips, feet, stature, width, read off the character);framingFor('portrait' | 'half' | 'body' | 'room', measures, aspect)(the studio's 35 mm lens);playCameraFor(measures)(12 degrees down, the chest at 42 % up the picture, 15 m ahead in frame);turnSpeedDeg(TURN_CURVE, offDeg)for a selector's turntable (slow at the front, fast round the back);jointOf(entity, 'head')for where the head is now. In the game:camera.follow(hero, {...})/camera.set(...)with those numbers.
6. The badge, the sound unlock, the fades, the joints: one call each
- Why: each game drew its own badge, wrote its own unlock, wrapped the animator for fades and clip ends, and reached into the scene for a bone.
- The commands:
createBadge({ corner })("Made with Gameable": the mint mark on black, white words); the audio module armscreateAudioUnlockitself (sound on the first touch, the silent switch respected, older phones covered);setClipWeightsfades over 0.4 s by default and the walk blend fills what the clips leave; the clip's end arrives as ananim-event(ended/loop);jointOf(entity, name)andattach(entity, joint, object)(a weapon in the hand); the spawn's rotation is the facing.
7. The look is Isaac's eye; a template uses the core's defaults
- Why: the games ran an engine copy off the line for the look and drifted in a day. The look's default is Isaac's call (pending); a template never sets
pointShapeorblendon its own. - Never edit
packages/*for a game. If the game cannot be written without an engine change, stop and say so: that is a missing feature of the core, not a workaround.
8. Never put a character's numbers in game code
- Why: a newer export is a drop-in only if the game reads the character (its measures, its head joint, its clips), never a height or a point count typed in. The character's address in one place; the engine's commit in one place.
- The one number a game may know about a character is the person's real height, and it goes in the manifest (
rig.height, metres;heightonloadGameableCharacterfor a three.js page): the engine draws the character at it, body, joints, physics and shadow together, andentryOf(entity).scalesays the factor (28 Oct 2026, from Fight Lab, which scaled every fighter by a typed ratio in five places). - A fighting game's moves are the fight pack's, never a table typed in the game: the frames, the damage, the stun, the reach, the limb, the take and the styles of every strike come from
docs/public/sets/fight-v1.motion.binthroughparseFightPack(docs/fight-pack.md; 28 Oct 2026, from Fight Lab's 814-line table). - A game that solves its own body pose (its own fight maths,
FightPoser; its own physics) hands it to the character withsetBonePose(entity, pose)on the bridge orcharacter.setBonePose(pose)on the three.js door, never by writing the bones itself; the face stays the character's andnullgives the body back to the clips (28 Oct 2026, from Fight Lab's view, which patched the clip player's writes).
9. Plain words on screen, no vendor or pipeline names
- "Made with Gameable"; a character's name; never a renderer, a model or a service by name; never "film"; "ordered splat", never a code word.
10. A game is ready only when seen in motion, on a phone
- Why: Hollow Reign was called live on stills; Isaac: "garbage". Ready means: a recording in motion (MP4 under 5 MB, a phone-light copy), on the browser's phone emulation and on the desk, every control reached by touch, the first character standing within the loading budget, the badge on, sound on the first tap.
- The proof of a template: a game rebuilt on it in a tenth of its code, seen in motion.
11. Publish is the review pass; a game ships as a built folder
- Why: a game on the site is a built folder of plain files (
docs/public/games/<name>/), every character file byte for byte as exported (its fingerprint is checked at load). Nothing key-shaped, no desk paths, no local addresses. - The character goes in as the studio exported it (the Share sheet's package; the phone copy as
rig.light). The shared clip pack one level above the character's folder.
12. An automated browser never takes pointer lock or moves the real cursor
- Why: on 2 Oct 2026, while games ran in test browsers on Isaac's desk, his real mouse kept getting confined to a small rectangle on one monitor: a test clicked into a game canvas with mouse-look, the page took pointer lock, and Windows confined the cursor to that browser window.
- How: block
requestPointerLockwith an init script in every context, before any page loads:context.addInitScript(...)that setsElement.prototype.requestPointerLockandHTMLCanvasElement.prototype.requestPointerLockto empty functions. Run headless wherever WebGPU allows it; never leave a test browser window open between runs; stop each browser by its process id when its run ends.
13. VR and AR: one engine part, a short setup, the game unchanged (2 and 3 Oct 2026, rounds one to three joined)
Why: Isaac, 2 Oct: "phone for AR and headset for VR is the priority ... phone whats cool is seeing an avatar in your room or playing a game with your real surroundings somehow - headset whats cool is immersing in the game"; "we'd want to build templates to easily let people build web games and just port them to vr ... we do the work to help 1 click work". The port is the engine's: a game registers one module and names where its content stands.
How a game switches it on (three lines, nothing else changes; the one gate every template shares, 6 Oct 2026):
- Ask the browser and decide the boot:
const boot = xrBoot(await detectXrSupport(), { search: location.search, storage: sessionStorageOrNull() })(gameable/xr/support).boot.lighteris true on a headset's browser, after a person pressed the button (the page reloaded into the lighter path, remembered for the tab), or on a test page (?xrtest=1); otherwise the page boots EXACTLY as before andboot.offernames the one button to show (offerXrButton(boot): the press remembers the entry and reloads). What a browser could do never changes a page nobody takes into a view. - On
boot.lighter:createEngine({ xr: true, renderer: { backend: boot.backend() }, modules: [..., ...headset.modules] }), the game's ownsrc/xr.ts(xr({ support, ... })andxrLights) loaded by a dynamic import. On every Quest and Android phone that is the WebGL path (a headset shows a page through the drawing context the session was made with; a WebGPU-made session exists only in Safari on Apple's headset); the characters then move through the fallback skin. A laptop keeps WebGPU. - The setup fields of
xr({...}):views(per mode, the first the default),content()(the floor point under the content and the yaw it faces),numbers(standBack2.2 m,tabletopScale0.1,tableHeight0.75 m),arShow('characters': only the characters and the lights in a phone's room),follow({ at(view), turn?(radians) }) for the following views,comfort,readoutfor tests,overlay(the page element seen over the phone's camera).
- Ask the browser and decide the boot:
The four views, and what they mean for a template's default:
lifeSize(the content stands in front of you in a headset; on your floor on a phone),tabletop(the scene small on a table: the whole room in sight, the natural port of a top-down or a selector),overShoulder(you stand behind your hero's shoulder, on the hero's floor: the port of a third-person game),throughEyes(you stand where your hero stands: the port of a first-person game). The scene never moves or scales; the camera's parent (the rig) does, so physics, logic and the characters are untouched. A game lists the views it offers inviews, the first the default; the in-headset panel and the page's view button offer those and no others.The following views (round two) take
follow.at(view): the FLOOR point under the hero and the game's own look yaw each frame (a template reads both off the host adapter: the hero's transform and the camera's, slot 0); the module adds the shoulder's offset itself (numbers.shoulder, 1.6 m back, 0.45 m right). The right stick's turn goes to the game throughfollow.turn(the input module'slook(dx, dy): a turn in the mouse's pixels, read by the game as locked mouse movement), so the hero and the view turn together and walking stays in the direction looked; head turns are free and change nothing in the game.Comfort, built in (round two;
comfort.ts, pure, tested): no camera motion the head did not make except a gentle catch-up (the view holds still while the hero stays within 0.35 m and 11 degrees of where it holds; past that it glides after it, or snaps in one step withcomfort.catchUp: 'snap'); a narrowed view (a soft-edged black tunnel on the camera, clear to 30 degrees each way and black from 42) while it moves; turns smoothly by default (90 degrees a second at full tilt, narrowed while turning), in 30-degree steps withcomfort.turn: 'steps'or the Turning lever; the floor always at the person's real floor height (the real floor lands on the hero's floor; a person sees from their own height, nothing moves the view up or down).Every template has its default set in its own
main.ts, so a game made from it has VR and AR on with no extra work:Template In a headset (first, then the others offered) On a phone Why First-person ( templates/fps)through the eyes, then the arena on a table the arena on a table the game is the eyes; a room-size arena over a phone's room would be odd, a table shows it whole Third-person ( templates/third-person)over the shoulder, then the arena on a table the arena on a table the hero is the thing to watch; on a phone the whole arena small is the simplest port Action ( templates/action)the heroes' row, then the room, on a table; life-size second on a table, life-size second Isaac: "the tabletop version is the easiest to port directly" Visit ( templates/visit)life-size, in the character's place (a soft floor without one); table size second life-size on your floor, the character alone (the place hidden); table size second a published character is a person to stand with Host show, Social (when built) life-size, the host or the cast in front of you life-size on your floor one or a few characters to stand with; a table when a stage or a room must be seen whole The buttons are the module's ("Enter VR", "View in your room", "Place here", the view switch named after the next view the game lists: "Table size", "Life size", "Over the shoulder", "Through the eyes", and "Leave"), in the Gameable look, shown only where the browser can; a page needs no stylesheet and no words of its own. Inside a headset the page is unseen, so every control a person needs floats on the module's panel (one button per other view the game lists, Leave, and the page's own through
panel: the Visit page puts Start talking and the microphone there), pointed at with the controller's ray and pressed with the trigger. The panel sits like a desk at the right hand (0.7 m ahead, 0.45 m right, 0.45 m below the eyes, tilted up): below every character's feet in view, covering nothing. Nothing may require a press on the flat page before entering.A person never types an address suffix: a start page with plain buttons (the hall, the hall on a table, a character to talk to) leads to each page; suffixes stay for tests.
In a headset a floor is what keeps a person upright: a page with no place gives the character a soft floor, a pool under the feet and a key light at the real floor height (the Visit page's
headsetStage).Controllers land on the game's existing input: left stick = W A S D (in the following views; the person's own walk in the fixed views), trigger = click and
Tap(or the game'spresslistener), right stick = a turn (of the viewer in the fixed views; of the game's own look in the following views). A game that reads keys and clicks plays in a headset as it is.While a headset or camera view shows, the studio characters' layer steps aside (the frame is drawn once per eye to the device): the characters blend linearly for those frames.
Proof so far (2 Oct, emulation on the desk, not a device): the hall of Hollow Reign's heroes (
examples/hollow-reign-xr) at 60 frames a second for two eyes with 598,200 points in view on the WebGL path; the buttons appear on an emulated Quest and stay away on a laptop; entering and leaving puts the page back; the Visit page talks in both views; the action template's selector and its first room stand on a table (the tabletop view first:templates/action, 58 to 60 frames a second, the view following the phase from the heroes' row to the room).The headset-to-desk log (
telemetry: { url: '/xr-log', page }): every three seconds the page sends the session's state, the frame split (work, drawing, the graphics card's own time where its timer exists), the frames a second, each eye's drawing size, the levers in force, the browser string, every input source, and between snapshots every press, squeeze, button, input-source arrival, error and warning. A test server appends the lines by the hour. A device try is data, not a memory.The test levers (
levers), each a button on the page and on the panel, the settings on the card's third line, nothing moved by default: Detail (the studio's 512 / 256 / 128 levels; the page swaps the package), Drawing (the share of each eye drawn, through the browser's viewport scaling), Foveation (the headset's, live), Points (an evenly spread thinning of the fallback skin's points), Sorting (when the draw order is remade), Turning (smooth, the default, or 30-degree steps), Speed-up (the fallback skin's presets, shown as plain words), Movement updates (how often every character is re-posed: every frame, 45 or 30 a second,setSkinOptions({ poseHz })), Shared budget (the scene's points re-posed a frame: off, 120,000 or 60,000,setPoseBudgetPoints; the card shows the posing rate the runtimes measure) and Both eyes at once (one pass for both eyes where the browser allows it; takes the next entry). The two movement levers appear only where the character host has the per-point work; a page offers them throughposeRate/poseBudget/posesPerSecondand verifies them from the stats like every other lever. "Lighter drawing: for 72" (last on the lever: the cost model's preset, tightest + the smaller buffer at 70 % + a 48 px cap, about 0.27 of the blended fragments) runs as tightest + the cap while both eyes at once is on, and the card says so. "Detail: automatic (scene budget)" iscreateSceneDetail: the looked-at or nearest character at 256, the others at 128, within a budget it derives from the characters' own levels (the hall's four, 21,400 at 128 and 63,000 at 256, give 129,744), the heavier level following a gaze that rests 1.5 s; the card lists each character's level. "Far characters: full / simple" (drawShapeauto: the disc stage under about 220 px on screen) is off by default. Every snapshot carries each character's draw counts (stats.counts: vertex work, texel fetches and blended fragments per eye) and their sum, with the headset's real eye size written tostats.eyePxeach frame; the draw-order watch readsstats.inViewfirst and the card says "in view: 3 of 4".A level is changed IN PLACE where it fits (
runtime.swapLevel(bundle, pause): the same textures, material and programs; the old points draw until the new set's first order), by the Detail lever, the governors and the speed test alike; a level past the runtime's capacity takes the fresh runtime once, sized for it from then on. A page sizes its runtimes at load through the bridge'swebglCapacity(a headset's heroes for 256, so 128 <-> 256 never stalls). Every swap is logged (level-swap:stats.lastSwap's slices and a page-side frame trace: the longest frame, frames over 20 and over 120 ms), the last one rides in every snapshot, the speed table's "level change 128 -> 256" row carries the longest frame, and the card's last line says the memory per character and in total (about 390 bytes a point: 8 MB at 128, 25 MB at 256, 58 MB at 512). A page picks its skin before the characters load (setWebglSkinDefaults, withswapBands: trueso a swap copies the new level in bands over frames), warms the skin variants it will switch between once the characters stand (warmSkinVariants(modes, nextFrame)), and fetches the neighbouring levels' bundles ahead when idle; the card says "the drawing reloaded" only when a switch did.The draw order watched (
xr({ warning })): a page reads each character'sstats.lastSortBins(one bin = every depth outside the sort's range, the file's index order: the see-through jumble of 3 Oct; a right order uses hundreds) and gives the module plain words when any character shown is under 64 ("drawing order broken: Elara (1 depth bins)"); the card grows a red fourth line, the page's readout carries it, every snapshot carriessortBinsandsortRange, and the log getsdrawing-order-broken/drawing-order-restoredonce per onset. The look test (emu/run-looktest.mjs+look-compare.py: the hero life-size and on the table from four sides and above, both eyes, each skin against the vertex reference) runs in the walkthrough before any skin change ships.Controllers at any frame rate: the controllers exist before the session starts (three hands an input source only to a controller that exists when it arrives, and the first arrival is the session's own start; made later they get none and every press is lost: the Quest 2 on 2 Oct); a press is taken from the event's own frame, not the next drawn one; the grip or B / Y held for a second leaves, on a timer, so leaving works when the frames have stopped.
Inside a headset the person walks and turns, one model for every view: in the fixed views the left stick walks the person along the way they face at 1.5 m/s (
walk: 'rig', the default; never through the content:obstacles) and a table view moves the table instead; in the following views the left stick is the game's W A S D (the hero walks, the view follows by the comfort rules). The right stick turns smoothly while held (90 degrees a second at full tilt, the default: steps were "impossible to use" for Isaac on 3 Oct) or in 30-degree steps when the Turning lever says so; a turn is of the viewer about their own spot in a life-size view, of the table about itself on a table, and of the game's own look in a following view that givesfollow.turn(else of the view's facing, kept across the catch-up). B or Y leaves at once, the grip held a second too; A or X hides the panel; the safety net leaves by itself under 15 frames a second for 6 s. The panel is two columns of four beside the content, never over it: from about 20 to under 38 degrees to the right (a character straight ahead at 1.8 m is about 11 degrees each way), every row within 25 degrees below, measured bypanelAngles()(each button's edges and corners); it follows the person. Past eight buttons it pages (Leave on every page, the last slot flips): a page's own buttons first, then the levers, then the speed test; words that do not fit go on two lines, never smaller than 20 px on the canvas. What a lever SHOWS is plain words (shownWord: the skin's presets read "Speed-up: off / 1 / 2 / …, simpler colour", the eyes "Both eyes at once: off / on"); the ids and the log's names stay the presets' own. A second lever, "Lighter drawing" (fill, the fill presets), has the same guard. "Detail: automatic" hands the level to the governor (createAutoDetailofgameable/character): the page gives each character its levels, points, height on screen (screenHeightPx) and a swap that loads the new level beside the old;detail.cardputs each character's level on the card. A talk page asks for the microphone as its own step on the flat page ("Allow the microphone", never on the Enter VR press, which it would eat on a Quest; asked inside a headset view it ends the view), resumes the sound inbeforeEnter, and shows the talk state and the words heard on acaptioncard by the character. No lever ends a headset view (a browser lets no page re-enter by itself): the drawing size and the eyes' passes apply from the next entry and the button says "(from the next time you enter)"; the safety net waits while the speed test runs, which cuts a step under 15 frames a second after 1.5 s as "too slow", skips the rest of that level and always ends with its table; steps that need an entry (needsEntry) run after a fresh one. On a headset a page enters at its lightest level and takes a remembered choice after a 5 s hold; automatic detail starts from the lowest level. After every swap a page applies every option as one object and verifies it from the runtime's own stats (inForce,mismatch,inForceWords): the card says when the runtime runs something other than what was asked (3 Oct 2026, Isaac's Quest 2 run).backputs a way back on the flat page;speedTesta one-button test that steps through the page's settings inside the headset and logs each.A page a person tests on a device is a FROZEN build served by a plain file server, never a dev server (a rebuild under them stalls the desk and the page drops what it was loading); tests run on other ports. A level or character switch never empties the stage: the new one loads beside the old (a stand-in entity),
xr.note()says "Loading 256…" on the card, and a download that fails leaves the old one and says so (onLoadFailedreaches the page; the fetches retry twice).desk: '/desk-alive'makes the flat page say when the desk stops answering; the log queues and resends. The right stick turns smoothly by default (Turning: smooth / steps). A microphone is asked for as its own step on the flat page, never on the Enter VR press (it eats the press on a Quest).Proof, round two (2 Oct, emulation on the desk, not a device; the rig is
tools/xr-emu): the first-person template through the eyes and the third-person template over the shoulder at 60 frames a second for two eyes (the desk's number); the walk on the left stick carries the view after the hero; the right stick's flick turns the game's look 30 degrees (measured on the flat page: a walk after a 90-degree turn goes 90 degrees to the left); the panel offers only the other listed view (Table size) and switches to it; the arena stands on a table on an emulated phone (121,385 points in view); the Visit page talks in both views with the talk started from the panel (the strongest face weight 0.34 in the headset, 0.37 in the room).Porting checklist for Gizmo: a game built on any template already has this; a game built otherwise is ported in these steps, in order, nothing skipped:
- Read the game. Find its
createEnginecall, itsinput()module, how its camera is driven (the player block'scamera: 'firstPerson' | 'thirdPerson', or its own), where its content stands (a selector's row, a room's floor centre, a character's feet) and which way it faces, and whether the page has its own buttons a person needs while playing (talk, a choice, retry). - Pick the view by the template the game is closest to (the table above), else by its camera: through the eyes for a first-person camera, over the shoulder for a camera that follows a hero, a table for a scene seen from above or a selector, life-size for a character to stand with. Offer at most one more (the table for a following view; life-size for a table). Say why in one line in the game's AGENTS.md.
- Fill the setup.
const support = await detectXrSupport();createEngine({ xr: true, renderer: { backend: pickXrBackend(support) }, modules: [..., xr({ support, views, content, numbers, follow, arShow, panel }) ] }).content()is the floor point under the content and its yaw; for a table setnumbers.tabletopScaleso the scene's longest side is under a metre (Math.min(0.1, 0.9 / longest)). For a following viewfollow.atreturns the hero's floor point (adapter.transforms.getPosition(hero)minus the capsule's centre height) and the look yaw off the camera transform (slot 0);follow.turnis(r) => input.look(-r / sensitivity, 0)with the player block'ssensitivity;follow.atmay add anoffset(metres right, up and back in the view's frame) where the shoulder defaults (numbers.shoulder) do not fit.content,numbers,arShowandpanelmay be functions (a game whose phase changes them;refreshPanel()rebuilds the panel). A page with its ownoverlaymakes its backgrounds see-through underhtml.xr-ar; a page without a place gives the character a floor in a headset (the Visit page'sheadsetStage). For a test page add the log (telemetry: { url: '/xr-log', page }), the levers (levers, with the page's Detail hook when its character has levels;leversStart128 on a headset),back,speedTestanddesk; the safety net (watchdog) is on by default. - Map the inputs. Nothing to write when the game reads keys and clicks: left stick = W A S D, trigger = click and
Tap, right stick = a turn. Anything the page needs pressed while playing goes on the panel (panel: label, disabled, onPress mirroring the page's own button). Nothing may require a press on the flat page before entering. A game that picks what is pointed at listens topress(the ray in scene units;handled = truekeeps the module from clicking or placing for it). Exposewindow.__AOS_XR__ = { presenting, enter, exit, place, panel }for the rig. - Test in the emulator (
tools/xr-emu/README.md):node run-vr.mjs <url> <name>andnode run-ar.mjs <url> <name>; look at every shot (nothing covering a character, feet on the floor, no white voids, the panel reachable, the narrowed view during the walk), read the report'slogsfor errors andafterViewPressfor the switch;node walkthrough.mjs <url> <name>for the whole path that fails loudly (Enter VR on the first press, walk, turn, every panel button on every page, Detail up and down with the character never gone, the speed test to its table, B to leave, re-enter, the safety net on a forced slow loop, the panel's angles and overlap); then the device over HTTPS (a frozen build on a plain file server, never a dev server) and the frames a second on the card; the Quest 2 is the floor. Emulation frames a second say nothing about a Quest.
- Read the game. Find its
The join of the three headset branches (3 Oct 2026,
xr-all): one package, proven bytools/xr-emu/walkthrough.mjson the frozen builds of every template (first person, third person, action, the Visit page with Tala's three levels) and of the real Hollow Reign (the hall at 128 with the Detail lever up to 512 and down again, the first room on the table, the boss court): Enter VR on the first press, both eyes, the walk, smooth turning and the Turning lever's steps, every panel button on every page within the angles, Detail with the characters never gone, the speed test to its table, B to leave, the safety net on a forced slow loop. Emulation frames a second say nothing about a Quest: the device log decides. The test pages Isaac opens are frozen builds on a plain file server (the real game on one port, the templates' start page on another), never a dev server. Known and carried: in the real hall the heroes stand 3 m apart, so the panel's band (20 to 38 degrees to the right) covers the hero on the right at 2.6 m; A or X hides the panel; the measured rule holds for the character straight ahead.Round two of the join (3 Oct 2026, after Isaac's shots): the panel rests as one "Menu" handle (the first column's lowest slot, 23 degrees below and 20 to 28 to the side) and opens to the two columns when the ray points at it or A / X is pressed; it rests again 4 s after the ray left (never mid-lever or mid-speed-test), and starts open for 6 s counted from the first frame drawn (a heavy page's first frames come 10 to 15 s after the session starts). A with the ray still on the panel does not open it again until the ray has been off it for 400 ms. The flat page's pills wrap whole. Every cast member of a game picks up its detail levels the moment its folders exist (
<stem>_ogs512|256|128beside the kit's folders, asked for by a HEAD each on a headset); a character without any starts thinned on a headset (a quarter of its points, a sixteenth beyond 6 m) and the card says so; Detail and its automatic govern every character in play, live, through a stand-in swap of the actor (the new level loads under a hidden carrier and takes over the frame it stands). The rooms on a table are the room alone: no sky dome, far backdrop, rain or splashes, the fog's dark colour around, the wet floor's sky reflection at 30 % (from above the puddles and cobbles read the storm sky as a white blaze) and the hero's pool light at 30 % from higher.The port of the REAL Hollow Reign (2 Oct 2026, round two; the game's branch
xr, one new filesrc/xr.tsof about 330 lines and small hooks in six of its files; nothing changes on a computer). What every game will meet, in the order it bit, and what was generic (the package has it now) against what stayed the game's:- Boot. A game that insists on WebGPU (
'gpu' in navigator,backend: 'webgpu', acaps.webgpucheck) must let the headset path through: askdetectXrSupport()first and relax all three wherevr || ar. Extras that need the compute skin (mouth,corrective) are switched bycaps.webgpu. Game. - Nothing may hide a character behind a WebGPU-only step. Hollow Reign's forming (a cloud of points that becomes the hero) reads the points back from the GPU and sets a per-point reveal; on the fallback skin there is nothing to read and the heroes stayed unseen (the first headset shot showed four weapons and no one holding them). A plain road (
setFormingPlain: shown whole the moment they stand) is chosen on the WebGL path. The same goes for a lens blur pass (the depth texture read failed to compile on WebGL): keep it to the WebGPU path, and guard its focus distance (NaN once, the frame the headset let go of the camera). Game. - The camera is the head. A selector that glides its own camera (
setCam,lookAt,fov) writes into a camera the headset overwrites every frame, and readscamera.positionas if it were the world: inside the rig that is real metres, not the scene. Give the game oneviewerpoint (the head's world position, set each frame) and route every "where is the camera" read through it: the turntable's facing, the look at the camera, the pool behind the hero, the audio listener. In play, hand the place and the effects a camera standing where the head really is (XrAim.camera, decomposed from the engine camera's world matrix). Game, by thefollow/contentcontract. - Picking is a ray, never a screen point. The hall picked a hero by projecting its spot to pixels. The package now hands the game every trigger pull and tap as a ray in scene units (
press: origin, direction,handled), and the game picks against each hero's standing room (a half-metre post from feet to head). A handled press is neither the game's click nor, on a phone, a second placing. Package (the event) + game (the pick). - The panel follows the phase. Choosing has Previous / Next / Take up the oath; play has the views, a health line and Pause; a fall or a win puts that screen's buttons on the panel (
Screens.labels()/press(i)).panelmay be a function andrefreshPanel()rebuilds it; the game refreshes when its phase key changes. Package (function + refresh) + game (the buttons). - Comfort in the following views is the package's, the aim the game's. The game's first port held the facing, glided the point and darkened the edges itself (
src/xr.ts, 2 Oct); since the join of 3 Oct the module does all three for every game (the comfort rules: hold within a dead zone, then glide or snap, the soft tunnel while moving; the right stick's turns, smooth by default, added to the facing or handed tofollow.turn).follow.at()names the hero's floor point and facing and may add anoffsetin the view's frame (right, up, back:[0.35, 0, 1.7]over the shoulder,[0, 0, -0.28]ahead of the eyes); the package turns the offset with the facing. What stays the game's: through the eyes the hero faces where the head looks (faceYaw); otherwise the right controller's ray onto the floor is the game's ownaimAt(its test-player hook, already there), and the left stick moves by where the head looks (moveYaw). The ring on the floor stays where the controller points. Package (hold, glide, tunnel, turns, offset) + game (aim). - The room on a table has four traps. (a) The scene's fog is in scene units and blacks out a table seen from twenty scene metres: the package stretches the fog's distances by the rig's scale while the table view is on (removing the fog rebuilt every shader: a stall of seconds inside the headset). (b) A closed hall: the eyes at
standBack / scalebehind the content must stay inside the walls (the hall is shut 13 m before the dais, so the hall's table stands 1.2 m ahead at an eighth, the rooms 1.3 m ahead at a twentieth;numbersmay be a function). (c) A game's own cut-away keeps every vault for a camera 17 m up: the table view cuts the roofs down to 4.6 m over the hero itself (afterLevel, after the place's update). (d) A hero walks on:content()is read every frame in a fixed view and the table slides with the hero (a glide, never a jump). Package (a, d) + game (b, c). - Headset settings of a place, not of the characters. Measured on the desk with the frame rate uncapped (two eyes; the desk's card, so only the shares mean anything; two runs, the desk's spread about 5 %), the real hall's frame of 7.7 to 8.3 ms was: the moon's 2048 shadow about 18 %, the light shafts 9 to 10 %, the floor mist 7 to 8 %, the dust nothing at 2.6 m (8 % when the heroes stood at 1.2 m), the four effects together 22 to 24 %, the four heroes 4 to 15 %, the stone and the lights the rest. The hall's headset setting (
Stage.effects: the shadow map at 1024, the dust left out; the mist, the shafts and the candles as built) takes 6 to 11 % off and keeps the look; the one big lever left, the moon's shadow, is the look's (its light on the floor) and is Isaac's call. A shadow switched off live costs a shader rebuild and read a null depth texture, so a shadow is sized, never switched. Game. - A phone's room keeps the characters and whatever is flagged
xrKeep: the weapons moved to a group of their own beside the hall (they vanished with it), with a plain hemisphere light at zero intensity raised only in the room (the hall's lights are hidden with the hall; the weapons drew black).arShowmay be a function (the heroes alone in the hall, everything in the rooms). Game. - A device's copies. A headset's browser string names no phone: the game's "lighter copies" rule must name the Quest too (
OculusBrowser|Quest|Pico|Wolvic). Game. - Prove it as a flow, not a page:
tools/xr-emu/walkthrough.mjson the real game enters in the hall, walks, turns, presses every panel button on every page, chooses from the panel, reads the first room on the table, walks over the shoulder with the stick (the view narrows), turns, looks through the eyes, switches Detail with the heroes never gone, runs the speed test to its table, leaves by B and by the safety net; the boss court the same way.
- Boot. A game that insists on WebGPU (
14. The screens come from the screen kit (29 Oct 2026, from Fight Lab)
- Why: every game drew its own loading screen, selector, versus screen, HUD and touch controls (Fight Lab: six files, a thousand lines, and the same bugs each time: a fight that started before a fighter was in, a tap that was also a mouse click, a stick that did not reverse). A script game ships no HTML at all, so its screens can only be the engine's.
- The commands (
gameable/screens,docs/screens.md):createScreens({ root, roster, pictures, touch, on }), thenshow('loading' | 'select' | 'versus' | 'none'),fight(on),loading.set,select.set,versus.show / progress,hud.set / callout,touch.state / held,pads.read,pause.show;createFiguresfor the loading rule (a pick loads at once, at most four kept, a fight waits for every figure,versusDone). A page game names the slots in its own markup (data-screen,data-slot) and keeps its CSS; a script game sends thescreensblock of its HUD model and the host draws the kit's default markup (screensHud,screensEvents). - Never a locked template: the words, the look and the rules (whose pick, who is in the rumble) stay the game's.
15. A character's hair moves by its package, never by game code (29 Oct 2026)
- Why: Isaac, 29 Oct: the Full build's body, cloth and hair come together, and a game gets them with the character. The studio's packer writes Sara's strand hair into the package (
hair/); the engine makes her strands bone slots after the rig's joints and runs her runtime each frame (docs/hair.md). - How: nothing in the game.
loadGameableCharacterand the bridge load the hair when the package carries it;hair: false(extras: { hair: false }) draws the hair as captured.character.hair(the entry'shair) reports the strands, slots, rows and her runtime's milliseconds;reset()after a teleport. - What is not in yet: body collision (off until her clean body lands) and the cloth, which follows in the same shape. Engine, studio.
(14 is the screen kit's rule, landed apart.)
What is NOT in the core yet (the game still builds it, or waits)
| Not in the core | Who builds it | Until then |
|---|---|---|
A selector on turntables as one call (the screen kit's select draws tiles and the game's pictures; a turntable is the game's figure hook) | the core, after the screen kit (29 Oct 2026) | the action template's selector; createScreens({ figure }) for the slot |
| The forming (the particle arrival) as a core helper every template calls | the core, about a day (today an example: examples/forming) | the template carries arrival.ts copied from the example |
| A loading screen helper (the estimate from the manifest, the screen from the game's own pictures) | landed: gameable/screens's loading (29 Oct 2026); the estimate stays the bridge's | the page's own markup with the slots, or the kit's default |
| Isaac's locomotion packs (the clips for the core's locomotion group) | Isaac supplies them; the mechanics landed (branch animation-packs) | the package's own idle / walk / run in the blend, nothing else |
| Enemies as characters with a chase and an attack on OUR characters (the fps template has it on the sample rig) | the action template | the fps template's enemyAI.ts on the splat path |
A named glance or talking clip from the package's animation.life played by the life layer | the core, half a day | the layer's own glance; the game plays a talking clip by name |
| Levels by distance (today by device only: the light copy on a phone, the full one on a desk) | the core, later | fullCopies: false on a phone |
| Publish's review pass (cull unseen points, a level per framing, a visibility mask) | the studio, later | ship the export as it is |
| The published page asking the conversation service for native frames; the line tool (recorded lines) in the kit | the studio | the game's own relay entry with a2f: 'isaac-gnm'; recorded lines made in the studio's tool |
| The export's face rule (lips tied to lips, the face rig where the studio places it) on by default | the studio (speak-right, not landed) | a re-export after it lands; never work round it in the game |
| The studio's layers in the skin pass (the ordered splat's lift, the per-slot face rules, the corrections, the fill, the hands' rows, swinging parts) | phases 1 to 3 | the character draws as the package came |
| The decoders (expressions and pose on the ordered splat) | the team, then the core | paused |
| A moving character on a phone without the new graphics path | decision pending (count first) | a still character and "you can still talk" |
| The look's default (the studio's point and the stored blend) | Isaac's call | a template sets neither |
| A loading screen's words and art (the bar's numbers are the core's) | each template | the visit page's bar |
| A relay that knows the game's characters, with the keys (the hello relay knows one) | each game runs its own | createConversationRelay with the game's characters and origins |
| A party room, a QR code, a scoreboard (Tala), a day's script (Millennial), combat rules (Hollow Reign) | the game's own rules in game.ts | the template's rules block |
Engineers' appendix: where each command lives (landing branch engine-land-0930, worktree D:\claude-temp\engine-lessons\wt-1)
- Touch:
packages/input/src/touch.ts; the gesturespackages/sdk/src/keycodes.tslines 183 to 189; the facadepackages/sdk/src/input.tslines 161 to 212. - Loading by order and
buildAhead:packages/wasm-host/src/loading.ts; the optionspackages/character/src/characters.tslines 458 to 473; the manifest'srig.lightpackages/assets/src/manifest.ts. - The mixer's manners:
packages/animation/src/clips/fadeWeights.ts;clipEndscharacters.tslines 708 to 715. jointOf/attach:characters.tslines 834 to 867.- The badge:
packages/core/src/badge/badge.tsline 97; the unlockpackages/audio/src/unlock.tsline 98. - The own idle:
packages/character/src/aosrigSplat/ownIdle.ts; the life layerpackages/animation/src/life/(setting, model, layer, holds, animationIntent); the optionscharacters.tslines 507 to 524. - Framings:
packages/character/src/framing.ts,measures.ts(gameable/host/characters);entry.measurescharacters.tslines 533 to 553. - Speech:
packages/conversation/src/playback.tsline 154 (createSpeechPlayer),relay.ts(a2f), the README's "Native face frames". - Physics:
packages/physics-jolt/; the facadepackages/sdk/src/physics.ts; the ragdoll'shiton branchengine-ragdollwhen it lands. - The look switches:
characters.tslines 398 to 414 (pointShape,blend); the mouthextras.mouth,mouthMesh. - Ships:
docs/public/games/<name>/(commitfa46298),npm run docs:games.
Starting play
A title or start screen registers its Play with onStart from gameable/host in page code. Play calls startGame({ reason: 'player' }); gameplay starts only through that contract. Guest latches accept the start event in ctx.events or use the persistent ctx.started. The sandbox and playtest agent call window.__gameable.startGame({ reason: 'capture' }) or { reason: 'playtest' }. An onStart(({ reason }) => ...) callback must skip pointer lock for capture and keep audio permission optional. See the engine recipe start-a-game.md.