Director’s commentary · Now
Seven ages on one skeleton
How the game plans to show me at seven ages, from three to 42: proportions as head-to-body ratios, one shared skeleton, faces swapped by UV offset, and placeholder people until the photo-based Meshy models exist.
The spec says I appear at seven ages, from a three-year-old on a doorstep to the 42-year-old writing this, each one modelled in Meshy from photos. None of those seven people is me yet. Every character in the game today, me included, is a procedural toy figure built out of simple shapes when the chapter loads. They exist so that every chapter can be played, timed and shipped now, and they're built so that when the real models arrive, nothing else has to change.
Proportions before anything else
The style bible fixes proportions before a single model is generated, as head-to-body ratios: how many heads tall each age is. It's the cheapest way to make seven different figures read as one person growing up, and it's what stops a stylised three-year-old looking like a very small adult.
| Model | Age | Heads tall | Notes |
|---|---|---|---|
| D1 | about 3 | 2.4 | Big round head, short limbs, barely any neck, tummy forward |
| D2 | 7 | 3.2 | Head still big; longer legs than D1 |
| D3 | 8–10 | 3.5 | One model; outfit changes per chapter |
| D4 | 15 | 4.6 | Lanky, school uniform |
| D5 | 16–19 | 5.0 | Two outfits (college, uni) |
| D6 | early 20s | 5.2 | Mid-2000s office wear |
| D7 | 42 | 5.4 | The reference model, and the narrator portrait |The placeholder people are built straight from those numbers. Each model is a height and a heads-tall figure plus a handful of proportions: where the hips sit, how long the neck is, how chunky the limbs are and how far the tummy sticks out.
d1: dave({ height: 0.95, headsTall: 2.4, legShare: 0.42, neck: 0.03, headAspect: 1.02, shoulders: 0.34, girth: 1.45, reach: 0.95, tummy: 0.9, hair: "#B88A52", hairStyle: "tuft", brows: "#9A7148", outfit: "prologue", skin: "#F5CBAC" }),
d2: dave({ height: 1.22, headsTall: 3.2, legShare: 0.5, neck: 0.08, headAspect: 0.97, shoulders: 0.48, girth: 1.18, reach: 0.82, tummy: 0.3, hair: "#8A6242", hairStyle: "bowl", outfit: "type-in" }),
d3: dave({ height: 1.35, headsTall: 3.5, legShare: 0.52, neck: 0.09, headAspect: 0.95, shoulders: 0.52, girth: 1.1, reach: 0.78, tummy: 0.15, hair: "#7D5838", hairStyle: "messy", outfit: "we-made-our-own" }),
d4: dave({ height: 1.65, headsTall: 4.6, legShare: 0.56, neck: 0.12, headAspect: 0.9, shoulders: 0.6, girth: 1.02, reach: 0.72, tummy: 0, hairStyle: "curtains", outfit: "because-i-could" }),
d5: dave({ height: 1.75, headsTall: 5.0, legShare: 0.56, neck: 0.13, headAspect: 0.89, shoulders: 0.66, girth: 1.08, reach: 0.72, tummy: 0, hairStyle: "spiky", outfit: "skip-intro" }),
d6: dave({ height: 1.78, headsTall: 5.2, legShare: 0.56, neck: 0.13, headAspect: 0.88, shoulders: 0.68, girth: 1.1, reach: 0.72, tummy: 0, hairStyle: "side-part", outfit: "no-reload" }),
d7: dave({ height: 1.78, headsTall: 5.4, legShare: 0.56, neck: 0.13, headAspect: 0.88, shoulders: 0.7, girth: 1.14, reach: 0.72, tummy: 0.1, hair: "#5E4332", hairStyle: "neat", outfit: "now" }),Everything else falls out of the head. bodyDims divides the height by the heads-tall figure to get the head, and a single childness value, 0 at the adult's 5.4 heads and 1 at the toddler's 2.4, deepens the torso and gives children bigger, lower, wider-set eyes. That's most of what makes a small child look small.
export function bodyDims(p: Proportions): Dims {
const head = p.height / p.headsTall;
const neck = head * p.neck;
const below = p.height - head - neck;
const hipJoint = below * p.legShare;
const pelvis = below * 0.05;
const torso = below - hipJoint - pelvis;
const legR = p.height * 0.034 * p.girth;
const armR = legR * 0.84;
const headW = head * p.headAspect;
const childness = clamp01((5.4 - p.headsTall) / 3);
// …
depth: lerp(0.64, 0.84, childness),One skeleton, one library of moves
Every character shares one skeleton with one set of bone names, from the toddler to the lecturer. Even the rabbits in the last chapter use the same names, with the arms doing duty as front legs. It's the rule that matters most for the Meshy models later, because it means one animation library drives every age.
/** The shared skeleton (style bible): every model, every age, one set of names. */
export const BONE_NAMES = [
"hips", "spine", "chest", "neck", "head",
"shoulder.L", "upperArm.L", "foreArm.L", "hand.L",
"shoulder.R", "upperArm.R", "foreArm.R", "hand.R",
"upperLeg.L", "lowerLeg.L", "foot.L",
"upperLeg.R", "lowerLeg.R", "foot.R",
] as const;The clips are written as poses rather than keyframed in a tool: Euler angles in degrees for each bone, plus a hips offset measured in hip-joint heights rather than metres. That last choice is what lets the same bite, the same typing and the same waggle fit a 95-centimetre toddler and a 1.78-metre adult. When a model is first used, its clips are baked into ordinary three.js AnimationClips at its own scale and shared by every instance.
/**
* One clip library for every age. Poses are Euler angles in degrees per bone (XYZ), plus
* `pos`, a hips offset in units of hip-joint height, so the same numbers fit a toddler
* and a 42-year-old. Conventions (character faces +Z, L is +X):
* +X on an upright bone pitches it forward (head nods down, spine leans in);
* on a hanging limb −X swings it forward, and +X bends a knee back.
* +Z swings a left limb outward (mirror() handles the right).
*/
// …
bite: {
body: "upper",
duration: 1.3,
loop: false,
pose: keyed([
[0, "rest"],
[0.4, { ...arms([-58, 0, -10], [-100, 0, -24]), head: [2, 0, 0] }],
[0.55, { ...arms([-60, 0, -10], [-104, 0, -24]), head: [14, 0, 0], neck: [6, 0, 0] }],
[0.8, { ...arms([-52, 0, -8], [-92, 0, -22]), head: [4, 0, 0] }],
[1.3, "rest"],
]),
},Faces by UV offset
Meshy rigs bodies, not faces, so faces are done the way games did them for years: a small texture atlas and a UV offset. There's one atlas for everyone, drawn once onto a canvas: a row of eyes (open, half and closed), a row of brows, a row of six mouths and a row of eyes with lashes. Each feature gets its own clone of the texture, which shares the image but has its own offset and repeat, so changing an expression is a couple of assignments and no texture upload.
/** Texture offset and repeat that show one cell, optionally mirrored. */
export function frameUV(col: number, row: number, flipX = false, flipY = false) {
const w = 1 / ATLAS.cols;
const h = 1 / ATLAS.rows;
const u = col * w;
const v = 1 - (row + 1) * h; // canvas textures are flipped, so row 0 is at the top
return {
offset: [flipX ? u + w : u, flipY ? v + h : v] as [number, number],
repeat: [flipX ? -w : w, flipY ? -h : h] as [number, number],
};
}
// …
function show(f: Feature | null, col: number, row: number, flipX = false, flipY = false) {
if (!f) return;
const { offset, repeat } = frameUV(col, row, flipX, flipY);
f.map.offset.set(offset[0], offset[1]);
f.map.repeat.set(repeat[0], repeat[1]);
}Blinks run on a random timer of two to six seconds, with a small chance of a quick second blink straight after. While a character's line is on screen, the mouth flips between shapes about eight times a second and never shows the same one twice in a row. It's cheap, and at phone size it reads better than lip sync would.
Where the real people come in
The plan in the spec hasn't changed. Each of me starts from photos of me at that age, on my own, so that nobody else's likeness sneaks in. Those become a front, side and back concept turnaround in an A-pose, then Meshy's multi-view image-to-3D, a remesh to twelve to eighteen thousand triangles, Meshy's auto-rig, a clean-up and face set-up in Blender, and the same optimiser the props go through. D7, the 42-year-old, gets built first because he's the reference for the rest. D1 gets tested earliest, because children's proportions are where auto-rigging is most likely to struggle.
The seam they plug into already exists. Nothing in the game touches a character except through CharacterHandle: the Mover walks it, Ink tags play its clips and set its expression, and the narrator panel makes it talk. A rigged Meshy model that implements this interface can replace a toy figure without a single chapter noticing.
export interface CharacterHandle {
id: string;
model: CharacterModelId;
root: THREE.Object3D;
play(clip: ClipName, opts?: { loop?: boolean; fade?: number; speed?: number }): Promise<void>;
setExpression(expr: Expression): void;
setTalking(on: boolean): void;
lookAt(target: THREE.Vector3 | null): void;
update(dt: number): void;
dispose(): void;
}What's next
Photos first, and that part's on me. If Meshy's rigs come back with different bone names or proportions from one age to the next, they get retargeted onto the one skeleton in Blender rather than the other way round. The procedural clip library may well outlive the placeholder bodies, because the custom motions (the bite, the typing, the waggle, the sword fights) are exactly the ones no preset library has.
My mum, my brother and my sister stay stylised and unnamed unless each of them decides otherwise. That was always going to be their call, and the placeholders mean nobody has to decide until they've seen the game.