Real stories:How a football-school coach shipped his apps in one week soon

Models & animation

Step 2 of the 3D track. Primitives from the previous step are great for a set, but you can't build a character out of boxes. Characters arrive as GLB — a file holding meshes, a skeleton and baked animation clips. In LeCodes that's its own node kind: Model.

A fox moves into the yard: we'll load the model, work through its clips, multiply it without loading it again, and send it running in circles.

Loading a model

Model.load() returns a promise. And here's the first runtime quirk: there is no top-level await in a program — the bundle runs as an ordinary function. Wrap anything that loads in an async function and call it:

TypeScript
const scene = new Scene({ skybox: "#1b2233" })
scene.add(Light.sun({ direction: [-1, -2, 0.7], intensity: 90000, shadowsQuality: 2 }))
scene.add(Mesh.plane({ material: Material.lit({ color: "#2f4a35", roughness: 1 }), normal: [0, 1, 0], scale: 24, receiveShadows: true }))
scene.camera.position = [0, 0.9, 1.9]
scene.camera.lookAt([0, 0.2, 0])

const status = UIText("Loading the model…").style({ fontSize: 13, color: "#c7ccd6" })
const hud = UIWidget(status).style({ top: "max(safe-top, 16px)", left: 16, right: 16 })
hud.attachTo(scene)
scene.open()
hud.show()

// The bundle runs without top-level await — wrap loading in an async function
async function main() {
  const fox = await Model.load("https://cdn.le.codes/guide-assets/fox-anim.glb")
  fox.castShadows = true
  scene.add(fox)

  // What the file actually holds is the only source of truth about clips
  status.text = fox.anim.clips.map(c => `${c.name}${c.duration.toFixed(1)}s`).join("\n")
  fox.anim.play("walk", { loop: true })

  let t = 0
  setLoop(dt => { t += dt; fox.eulerAngles = [0, Math.sin(t * 0.5) * 35, 0] })
}
main()
  • Open the scene first, add the model later. The scene is already drawn while the file downloads — the view is empty but not black, and there's somewhere to write "Loading…". Do the same for any slow resource.
  • A model is a Node, just another kind: position, scale, eulerAngles, castShadows, events — all as on a mesh. The GLB's internal nodes hang under it as children, and model.traverse(node => …) walks them (look one up by node.name).
  • Check the scale — GLB units can be anything. A model arrives in whatever units it was exported in, and the multiplier is often baked into the file itself (our fox's root node already carries a scale of 0.01, which is why the code never touches scale at all). If a model is invisible after add, or fills half the screen, that's the first thing to look at: model.scale = 0.01 on top of an already-shrunk file gives you a speck a tenth of a millimeter across. Think in meters: a grown fox is about 70 cm at the shoulder.
  • The source is an asset path — asset("./fox.glb") — in a real project, or a plain https:// URL as here. The promise rejects on an HTTP error or a bad file; in production wrap it in try/catch and show something human.
Caution

A top-level await in main.ts compiles without an error but the program silently never runs: both the web runner and the desktop host execute the bundle as a function. Blank screen and a quiet console? Look for an await outside a function first.

Clips

Animations ship inside the GLB and live on the model.anim aspect — already attached, nothing to create. clips is what's baked into the file; play() takes a name or an index:

TypeScript
const scene = new Scene({ skybox: "#1b2233" })
scene.add(Light.sun({ direction: [-1, -2, 0.7], intensity: 90000, shadowsQuality: 2 }))
scene.add(Mesh.plane({ material: Material.lit({ color: "#2f4a35", roughness: 1 }), normal: [0, 1, 0], scale: 24, receiveShadows: true }))
scene.camera.position = [0, 0.9, 1.9]
scene.camera.lookAt([0, 0.2, 0])

const label = UIText("…").style({ fontSize: 13, color: "#c7ccd6" })
const row = UIRow().style({ gap: 8 })
const hud = UIWidget(label, row).style({ top: "max(safe-top, 16px)", left: 16, right: 16, gap: 8 })
hud.attachTo(scene)
scene.open()
hud.show()

async function main() {
  const fox = await Model.load("https://cdn.le.codes/guide-assets/fox-anim.glb")
  fox.castShadows = true
  scene.add(fox)

  // Build the chips from what's actually in the file, not from a list of names off the top of your head
  const chip = (name: string) =>
    UIButton(UIText(name).style({ color: "#fff", fontSize: 13, fontWeight: 700 }))
      .style({ bgColor: "#ffffff22", borderRadius: 999, px: 12, py: 7, $pressed: { opacity: 0.6 } })
      .onClick(() => { fox.anim.play(name, { loop: true }); label.text = `clip: ${name}` })
  row.append(...fox.anim.clips.map(c => chip(c.name)))

  // One walk cycle without loop: when it ends, the completed event arrives
  fox.anim.play("walk", { loop: false })
  label.text = "clip: walk (once)"
  fox.addEventListener("completed", () => {
    label.text = "walk finished → idle, looping"
    fox.anim.play("idle", { loop: true })
  })
  fox.addEventListener("loopReached", clip => console.log("loop", clip))
}
main()

The fox walks one cycle and settles into idle — that was the completed event. The chips are built straight from anim.clips: as many buttons as the file has clips.

  • play(clip, { loop }) covers both looping and one-shot playback. There's also anim.speed (rate multiplier), anim.time (seek, in seconds) and anim.playing = false (pause).
  • The events land on the node, not on the aspect: model.addEventListener("completed" | "loopReached", clip => …), and the argument is the clip index. After completed the aspect sets playing = false itself.
  • An unknown name is ignored silently: play("Runn") doesn't throw — it just keeps playing the previous clip. Which is why anim.clips is the only source of truth: re-export the model with different names and code with hardcoded strings quietly stops animating. play(0) by index is safe from typos, but not from clips being reordered in the file.
  • There is no cross-fade: play() hard-switches on the next frame. For blended transitions, blend spaces and clips from separate files there's the Animator aspect — also what you need for Mixamo assets, where every animation is its own GLB.

The HUD buttons are ordinary UIButtons from the UI track: a UIWidget over a scene behaves like any container, imperative append/setContent included.

Many instances

The second character is not a second Model.load. A loaded model can clone(): the copy reuses the already-decoded GLB (no refetch, no reparse) and gets its own animation state:

TypeScript
const scene = new Scene({ skybox: "#1b2233" })
scene.add(Light.sun({ direction: [-1, -2, 0.7], intensity: 90000, shadowsQuality: 2 }))
scene.add(Mesh.plane({ material: Material.lit({ color: "#2f4a35", roughness: 1 }), normal: [0, 1, 0], scale: 30, receiveShadows: true }))
scene.camera.position = [0, 1.8, 3.2]
scene.camera.lookAt([0, 0.2, 0])
const label = UIText("…").style({ fontSize: 13, color: "#c7ccd6" })
const hud = UIWidget(label).style({ top: "max(safe-top, 16px)", left: 16, right: 16 })
hud.attachTo(scene)
scene.open()
hud.show()

async function main() {
  const template = await Model.load("https://cdn.le.codes/guide-assets/fox-anim.glb")
  const foxes = [template, ...Array.from({ length: 4 }, () => template.clone())]
  foxes.forEach((fox, i) => {
    const a = (i / foxes.length) * Mathf.TAU
    fox.position = [Math.cos(a) * 1.1, 0, Math.sin(a) * 1.1]
    fox.eulerAngles = [0, -a * RAD2DEG, 0]
    fox.castShadows = true
    scene.add(fox)
    // Every copy owns its animation state: its own clip, speed and phase
    fox.anim.play(i % 2 ? "walk" : "run", { loop: true })
    fox.anim.speed = 0.6 + i * 0.2
    fox.anim.time = i * 0.2
  })
  label.text = `${foxes.length} foxes, one load`
}
main()

Five foxes, one network request and one file decode. Clones inherit the original's transform as of the copy, then live their own lives — own position, own clip, own speed and own phase (anim.time staggers them so they don't march in lockstep).

  • Do the same for a pool of enemies or projectiles: one load at start-up, clone() on demand. Calling Model.load again for the same URL is wasted work and a hitch exactly at spawn time.
  • RAD2DEG and Mathf.TAU are globals. Node angles are in degrees, trigonometry is in radians; convert right at the boundary.
  • To remove a node, node.destroy() — after that it's unusable. Often it's cheaper to hide it (visible = false) and reuse it.

The whole screen

Bring back the yard from step 1 and send three foxes around it: each with its own radius, speed and clip. The position is computed in the frame loop, and the heading comes from the same angle:

TypeScript
// The yard from step 1 plus foxes: one GLB load, three instances, each with its own animation.
const scene = new Scene({ skybox: "#1b2233" })
scene.add(Light.sun({ direction: [-1, -2, 0.7], intensity: 90000, shadowsQuality: 2 }))

const lit = (color: string, roughness = 1) => Material.lit({ color, roughness })
scene.add(Mesh.plane({ material: lit("#2f4a35"), normal: [0, 1, 0], scale: 30, receiveShadows: true }))

function Tree(x: number, z: number, height = 1.6) {
  const tree = new Node()
  tree.position = [x, 0, z]
  const crown = Geometry.cylinder({ radiusTop: 0, radiusBottom: 0.55, edges: 8, smooth: false })
    .scale(1, height, 1).translate(0, height / 2 + 0.6, 0)
  tree.add(
    Mesh.cylinder({ material: lit("#6b4a2b"), radius: 0.12, scale: [1, 0.7, 1], position: [0, 0.35, 0], castShadows: true }),
    Mesh.from(crown, { material: lit("#3d8b4f"), castShadows: true }),
  )
  return tree
}
scene.add(Tree(-1.9, -2.2, 2), Tree(1.9, -2.8, 1.6), Tree(-2, 1.8, 1.3), Tree(2, 1.4, 2.2))

scene.camera.position = [0, 3.2, 5.5]
scene.camera.lookAt([0, 0.3, 0])

const status = UIText("Loading foxes…").style({ fontSize: 13, color: "#c7ccd6" })
const hud = UIWidget(
  UIText("The yard").style({ fontSize: 22, fontWeight: 800, color: "#ffffff" }),
  status,
).style({ top: "max(safe-top, 16px)", left: 16, right: 16, gap: 2 })
hud.attachTo(scene)
scene.open()
hud.show()

async function main() {
  const template = await Model.load("https://cdn.le.codes/guide-assets/fox-anim.glb")

  // Each fox runs its own circle; clone() copies without loading again
  const walkers = [
    { model: template, radius: 1.2, speed: 0.5, clip: "walk" },
    { model: template.clone(), radius: 2, speed: 0.9, clip: "run" },
    { model: template.clone(), radius: 0.6, speed: 0.3, clip: "walk" },
  ]

  walkers.forEach(({ model, clip }, i) => {
    model.castShadows = true
    scene.add(model)
    model.anim.play(clip, { loop: true })
    model.anim.speed = 0.9 + i * 0.2
  })

  status.text = `${walkers.length} foxes · clips: ${template.anim.clips.map(c => c.name).join(", ")}`

  let t = 0
  setLoop(dt => {
    t += dt
    for (const w of walkers) {
      const a = t * w.speed
      w.model.position = [Math.cos(a) * w.radius, 0, Math.sin(a) * w.radius]
      w.model.eulerAngles = [0, -a * RAD2DEG, 0]
    }
  })
}
main()

Step speed and clip speed are matched by eye: anim.speed is tuned so the paws don't skate over the ground. That's normal hand-tuning — anim has no automatic link between animation and movement (root motion); Animator does.

Moving a node by assigning position is fine right up until physics takes it over — which is what the next steps are about.

What you learned

  • Model.load(url | asset("./file.glb")) returns a promise; there's no top-level await, so wrap it in async function main().
  • Check a model's scale against the file: it's often already baked into the GLB's root node, and an extra scale makes the model invisible.
  • A Model is a Node: transform, scale into meters, castShadows, traverse over its internals.
  • model.anim: clips, play(name | index, { loop }), speed, time, playing; completed / loopReached fire on the node with the clip index.
  • An unknown clip name is ignored silently; there's no cross-fade — Animator handles transitions and blending.
  • clone() is a cheap copy of a loaded model with its own animation state — that's how pools are built.

Reference: Model & ModelAnimation · Node · Aspects · Host globals.

Next: Camera & picking — orbit the yard with a finger and hit things.