Skip to content

Load a character ​

Goal ​

An AvatarOS splat avatar stands in your scene, loaded by id from the manifest, with a face you can drive from game code. On a machine without WebGPU the game still boots and says why the character is missing, instead of throwing.

Files you will edit ​

  • assets.json
  • src/game.ts

0. Pick the level first, and say it ​

A character is never loaded as its build. It is loaded at a level: the package <stem>_ogs512, _ogs256 or _ogs128 beside the build, about 150,000 / 63,000 / 21,000 points, about 390 bytes a point on the card (58 / 25 / 8 MB). The scene's budget is points posed a frame: 252,000 on a phone or a headset (four characters at 256), a desk about four times that. Add up the characters you load at their levels before you load one; if the sum is over the budget, go down a level, never over.

where the game runsthe looked-at characterevery other characterone 512
a phone or a headset256128never
a desk512256the one in hand

A build without its ladder beside it (a Full build from the studio's long road, or a raw .ply) is not loadable into a game as it is: ask for its ladder (the studio's level step for a converted character; pooling the atlas for an ordered splat, minutes, no training). Write the level and the points of each character you load in the game's own notes (its AGENTS.md or README), so the next agent sees the sum.

The loader says what it loads in its first line ("Elio: 128,699 points, the _ogs256 level") and keeps it on the character (entryOf(entity).level). On a phone or a headset it refuses a package with more than 262,144 points, in words ("Elio: 1,735,813 points is a build, not a level; on a phone load _ogs256 (128,699 points)"); a desk loads it and says the same line as a warning. A package with no level name on its folder but within the cap is loaded on every device and said ("Kael: 200,000 points, a build (no level name on its folder kael), within 262,144, loaded"); the kit's <slug>_phone copies count as a level. No option turns this off.

The page swaps levels while it runs (swapLevel) and createSceneDetail keeps the sum inside the budget by itself (the looked-at character at 256, the rest at 128). A game whose engine bundle is older than the join of 24 Oct 2026 has neither: it chooses the level when the character loads, by device, and keeps the sum itself.

A game with an order of its own (a fight: the player first, the opponent next, the rest after) gives each character a rank (0 first) and the budget follows it before any size or view; planLevels(characters, budget) is the same plan on its own, for the levels a scene starts on: every character at its heaviest level when they all fit, else the first in rank at the heaviest and the rest a step down (two fighters at 256 fit a phone; a rumble of four starts with the player at 256 and three at 128).

0b. Say the character's real height ​

The studio makes every character about 1.75 m tall. Say the person's height in the manifest and the engine draws the character at it: its points, its joints, its physics body, its shadow and its measures all at that size (entryOf(entity).scale says the factor; measures are the drawn ones).

json
{
  "id": "char.logan",
  "type": "character",
  "src": "characters/logan/",
  "rig": { "backend": "aosrig-splat", "height": 1.88 }
}

Never type the exported height or a scale into game code: the manifest row is the one place, and the engine reads the stature off the character (the crown of its points, hair included).

Steps ​

  1. Declare the character in assets.json. src is the bundle directory — a character is a set of files (decoders, meshes, a rig pack) that are only meaningful together, so the id addresses the whole directory and no filename inside it ever appears in game code.

    json
    {
      "version": 1,
      "baseUrl": "assets/",
      "assets": [
        {
          "id": "char.myra",
          "type": "character",
          "src": "characters/myra/",
          "tags": ["cast"],
          "rig": { "backend": "orl" }
        }
      ]
    }

    The rig block is required, and it is the bundle's own answer to "which rig posed this face". Use "backend": "gnm" with a "pack": "characters/myra/myra.aosrig" for a baked parametric head; "orl" reads the rig out of the bundle itself.

  2. In src/game.ts, create the splat object the character writes into, then bring the bundle to life against it. The character does not own a scene object of its own — it writes gaussians into slot ranges of one AnimatedSplat, so a second character added later shares the same object and the same sort.

    ts
    import { createAnimatedSplat } from 'gameable/splat';
    import { createCharacter, loadCharacterBundle } from 'gameable/character';
    
    const splat = createAnimatedSplat(ctx.renderer, { capacity: 262144 });
    ctx.scene.add(splat.object3D);
    
    const bundle = await loadCharacterBundle(ctx.assets.url('char.myra'));
    const myra = await createCharacter(bundle, {
      renderer: ctx.renderer,
      scene: ctx.scene,
      sink: splat,
    });

    capacity is the number of gaussians, fixed for the object's lifetime at 52 bytes each. A character needs uv_res² slots per branch — 65,536 for a 256² head plus 9,216 for its eyes, on the shipped bundles. myra.memoryReport() prints what it actually took.

  3. Guard the WebGPU requirement. Characters are compute shaders writing into storage buffers; there is no WebGL path and no CPU fallback worth having, so check the capability rather than catching the error.

    ts
    if (!ctx.caps.characters) {
      ctx.log('this build needs WebGPU for characters');
      return;
    }
  4. Drive it and update it. setExpression takes whatever the bundle declares in myra.expressionSpace — ARKit-52 weights for an orl bundle, head_ext for a gnm one. Preallocate the weights array: this runs every frame.

    ts
    const weights = new Float32Array(myra.expressionSpace.dim);
    
    function frame(dt: number): void {
      weights[jawOpen] = talking ? 0.4 : 0;
      myra.setExpression(weights);
      myra.setLookAt(playerIsNear ? playerPosition : null);
      myra.update(dt, ctx.camera);
      splat.markGaussiansChanged();
    }

    update is cheap when nothing moved — it re-runs the appearance pass only once the camera has genuinely travelled about 2 mm — so calling it every frame is the intended use.

Verify ​

sh
npm run dev

The avatar is there, it blinks when you push a weight into it, and it stays sharp as you orbit — a character that goes soft or smears when the camera turns is the appearance pass not running, usually a markGaussiansChanged you forgot. F3 shows one draw call for the splat object no matter how many branches the bundle has.

Then assert it headlessly. A bundle parses with no GPU at all, which is the part worth putting in CI:

ts
const bundle = await loadCharacterBundle('assets/characters/myra/');
expect(bundle.manifest.rig.backend).toBe('orl');
expect(bundle.manifest.expressionSpace.dim).toBe(52);

If createCharacter rejects with CharacterUnsupportedError, the renderer is on the WebGL fallback — step 3 is the check that should have caught it.

See also ​