ignifx
GitHubnpm · soon
All examples

Third-person 3D game

Templates3D

  • Keyboard
  • Gamepad
  • Touch
  • Mouse

One of the four templates `create-ignifx` scaffolds, running here exactly as it runs on a developer's machine. The character is a `CharacterController` capsule driven by `ThirdPersonController`; the orbit camera sweeps a sphere along its boom, so geometry in the way pulls the camera in rather than letting it sit inside a wall; an `Animator` state machine blends idle, walk and run on a rigged model and triggers the jump; the companion is a `NavMeshAgent` on a surface baked from the geometry the level builds in code; and the crates are rigid bodies you can push. Walking up to a beacon lights it, scores a point and autosaves. The title screen, pause menu, settings, key rebinding and save file are all real.

A grey tiled courtyard ringed by dark panelled walls under a pale sky: the rigged character stands in the middle among scattered wooden crates, three beacon posts sit around the floor, and a chip in the top corner reads the player's speed and the companion's distance.
Open standalone

` devtools

WebGPU: checking…

Try this

  • Walk with WASD, look with the mouse and hold Shift to sprint; the rig blends idle, walk and run.
  • Walk into one of the three beacons to light it — that scores a point and autosaves the run.
  • Press Escape, then Settings, and turn shadows or post-processing off while the game runs.
Show source

Source

src/main.ts
// oxlint-disable no-underscore-dangle -- `window.__ignifxReady` is a test hook, and the double// underscore is what says it is not part of the game's API. The visual suite reads it by name.import {  Animator,  ANIMATOR_ASSET_TYPE,  NavMeshAgent,  NavMeshSurface,  ThirdPersonCamera,  ThirdPersonController,  threeD,} from "@ignifx/3d";import { audio, AUDIO_ASSET_TYPE, AUDIO_BUSES_ASSET_TYPE, AudioListener, AudioSource } from "@ignifx/audio";import { Camera, createApp, isIgnifxError, Model, PostProcessStack } from "@ignifx/core";import { electron } from "@ignifx/electron";import { input, INPUT_ACTIONS_ASSET_TYPE } from "@ignifx/input";import { CharacterController, physics } from "@ignifx/physics";import { I18N_ASSET_TYPE, ui } from "@ignifx/ui";// Vite virtual modules the plugin serves, typed by `@ignifx/vite-plugin/client`.import { manifest } from "virtual:ignifx/manifest";import { acceptHotReload, scripts } from "virtual:ignifx/scripts";import { installDesktopProbe } from "./desktop-probe.js";import { installFrameTimeProbe } from "./frame-time-probe.js";import { createGameUi, hasTouch } from "./game-ui.js";import { buildLevel } from "./level.js";import { createGameMenus } from "./menus/game-menus.js";import { applySettings, loadInputOverrides, loadSettings } from "./menus/settings-store.js";import { createRun } from "./run.js";import { Companion } from "./scripts/companion.js";import { HeroAnimation } from "./scripts/hero-animation.js";import { HudLine } from "./scripts/hud-line.js";import { MenuController } from "./scripts/menu-controller.js";import { SaveGame } from "./scripts/save-game.js";import type { Level } from "./level.js";import type { GameMenus } from "./menus/game-menus.js";import type { GraphicsHooks } from "./menus/settings-store.js";import type { AnimatorAsset } from "@ignifx/3d";import type { AudioBusesAsset, AudioClip } from "@ignifx/audio";import type { App, AssetHandle, Entity, MaterialAsset, ModelAsset } from "@ignifx/core";import type { InputActionsAsset } from "@ignifx/input";import type { LocaleAsset } from "@ignifx/ui";/** * A third-person 3D game: a walking, jumping, sprinting character on a `CharacterController`, an * orbit camera that will not clip through a wall, an `Animator` state machine on a rigged model, a * navmesh-driven companion, pushable crates, a loading screen and a pause menu. * * The shape of this file is the shape of every ignifx game: * * 1. `createApp` with the extensions the game uses; `ignifx.config.ts` carries the settings. * 2. Load every asset and **await it before `app.start()`**. A load awaited before the loop runs *    settles as soon as it finishes; once the loop is running, delivery waits for a `PreUpdate`, *    so awaiting after `start()` needs frames to be pumped. * 3. Build the world. * 4. `app.start()`. * * ## Query flags * * - `?static=1` builds the scene **without** the controllers, the camera rig and the navigating *   companion, places the camera by hand, and stops the clock before the first frame. No fixed step *   ever runs, so nothing falls, no clip advances and no path is computed: the frame is exactly *   what was authored. It is what the visual golden suite in `tests/visual/` opens. * - `?locale=fr` switches `app.i18n` before the menus are built, which is the whole demonstration *   that the UI strings live in `assets/strings.i18n.json` and not in this file. */declare global {  interface Window {    /** Resolves once the game has presented a settled frame. See the module comment. */    __ignifxReady: Promise<AppStatus>;  }}/** What `window.__ignifxReady` resolves to. */type AppStatus = "ready" | "unsupported";/** How many animation frames the scene is given before the image is called settled. */const SETTLE_FRAMES = 12;/** The character's capsule height, in metres. The rig is about 1.45 m tall. */const PLAYER_HEIGHT = 1.5;/** Where the character starts, on the ground. */const PLAYER_SPAWN = { x: 0, y: 0, z: -3 } as const;/** Where the capsule's centre starts: the spawn, lifted by half the capsule's height. */const PLAYER_SPAWN_POSE = { x: PLAYER_SPAWN.x, y: PLAYER_HEIGHT / 2, z: PLAYER_SPAWN.z } as const;/** Where the companion starts, on the ground. */const COMPANION_SPAWN = { x: 5, y: 0, z: 6 } as const;/** The camera pose `?static=1` uses, chosen once so the golden frames the whole courtyard. */const STATIC_EYE = { x: 0, y: 3.6, z: -10.5 } as const;/** What the `?static=1` camera looks at. */const STATIC_FOCUS = { x: 0, y: 1, z: -2.5 } as const;/** * The asset type to load each document as. * * `@ignifx/vite-plugin` types a file by its extension and gets every one of these right, so the * overrides are documentation rather than necessity: they say at the call site which loader is * meant, and they keep working if the file is ever renamed. */const MODEL = { type: "model" } as const;const MATERIAL = { type: "material" } as const;const ANIMATOR = { type: ANIMATOR_ASSET_TYPE } as const;const ACTIONS = { type: INPUT_ACTIONS_ASSET_TYPE } as const;const BUSES = { type: AUDIO_BUSES_ASSET_TYPE } as const;const CLIP = { type: AUDIO_ASSET_TYPE } as const;const STRINGS = { type: I18N_ASSET_TYPE } as const;/** Everything the world is built from. */interface Assets {  readonly player: AssetHandle<ModelAsset>;  readonly companion: AssetHandle<ModelAsset>;  readonly stateMachine: AssetHandle<AnimatorAsset>;  readonly floor: AssetHandle<MaterialAsset>;  readonly wall: AssetHandle<MaterialAsset>;  readonly crate: AssetHandle<MaterialAsset>;  readonly sky: AssetHandle<MaterialAsset>;  readonly emissive: AssetHandle<MaterialAsset>;  readonly footstep: AssetHandle<AudioClip>;  readonly jump: AssetHandle<AudioClip>;  readonly land: AssetHandle<AudioClip>;  readonly pickup: AssetHandle<AudioClip>;  readonly uiClick: AssetHandle<AudioClip>;  readonly uiHover: AssetHandle<AudioClip>;  readonly ambient: AssetHandle<AudioClip>;}/** The placeholder `announceReady` holds until the promise below hands over its resolver. */function noop(): void {  // Nothing to do: the promise executor runs synchronously and replaces this on the next line.}let announceReady: (status: AppStatus) => void = noop;window.__ignifxReady = new Promise<AppStatus>((resolve) => {  announceReady = resolve;});/** * Waits for one animation frame. * * @returns A promise that resolves inside the next frame callback. */function nextFrame(): Promise<void> {  return new Promise<void>((resolve) => {    requestAnimationFrame(() => {      resolve();    });  });}/** * Waits for several animation frames, so a newly built scene has presented. * * @param frames - How many frames to wait for. * @returns A promise that resolves after the last of them. */function settle(frames: number): Promise<void> {  let chain = Promise.resolve();  for (let index = 0; index < frames; index += 1) {    chain = chain.then(nextFrame);  }  return chain;}/** * The clip behind a handle, or `null` when the browser refused to decode it. * * @remarks * `AssetHandle.value` is only meaningful once the handle is `"loaded"`; every sound in this * template is optional, so a failed decode costs the game that sound and nothing else. * * @param handle - The handle to read. * @returns The clip, or `null`. */function clipOrNull(handle: AssetHandle<AudioClip>): AudioClip | null {  return handle.state === "loaded" ? handle.value : null;}/** Swaps the canvas for the "no WebGPU here" panel in `index.html`. */function showUnsupported(): void {  document.body.dataset["webgpu"] = "unavailable";}/** * Builds the character: a capsule that walks, a child that carries the model, and the state * machine that poses it. * * @remarks * The model hangs off a **child** entity. A `CharacterController`'s capsule is centred on its own * entity, and the rig's origin is between its feet, so the two cannot share one transform without * the character being buried to the waist. * * @param app - The running app. * @param assets - The loaded assets. * @param isStatic - Whether the scene is being built for a golden. * @returns The character's entity. */function buildPlayer(app: App, assets: Assets, isStatic: boolean): Entity {  const player = app.world.createEntity("Player", {    position: { x: PLAYER_SPAWN.x, y: PLAYER_HEIGHT / 2, z: PLAYER_SPAWN.z },  });  player.layer = app.world.layers.requireIndex("Player");  player.addComponent(CharacterController, { height: PLAYER_HEIGHT, radius: 0.35, slopeLimit: 50 });  const body = app.world.createEntity("Player Body", { parent: player });  body.transform.localPosition.set(0, -PLAYER_HEIGHT / 2, 0);  body.addComponent(Model, { model: assets.player.retain(), castShadows: true, receiveShadows: true });  // One `Animator` per **model asset**: `player.glb` and `companion.glb` are two copies of one  // generated rig for exactly this reason. Babylon Lite binds an animation group to a single  // manager, so two animators over one asset would fight over one pose.  body.addComponent(Animator, { animator: assets.stateMachine.retain() });  if (!isStatic) {    player.addComponent(ThirdPersonController, {      walkSpeed: 3.5,      sprintSpeed: 6.5,      jumpHeight: 1.1,      stepHeight: 0.35,      rotateToMovement: true,    });    // The animation script lives on the same entity as the controller it reads, and reaches the    // `Animator` on the child through the entity tree.    const animation = player.addComponent(HeroAnimation);    animation.footstep = assets.footstep;  }  return player;}/** * Builds the companion: a second copy of the rig, a crowd agent, and the follow script. * * @param app - The running app. * @param assets - The loaded assets. * @param player - The entity to follow. * @param isStatic - Whether the scene is being built for a golden. * @returns The companion's follow script, or `null` in a static scene. */function buildCompanion(app: App, assets: Assets, player: Entity, isStatic: boolean): Companion | null {  const companion = app.world.createEntity("Companion", { position: COMPANION_SPAWN });  companion.layer = app.world.layers.requireIndex("Companion");  companion.addComponent(Model, { model: assets.companion.retain(), castShadows: true, receiveShadows: true });  companion.addComponent(Animator, { animator: assets.stateMachine.retain() });  if (isStatic) {    return null;  }  companion.addComponent(NavMeshAgent, { speed: 3.6, acceleration: 12, radius: 0.4, stoppingDistance: 0.4 });  return companion.addComponent(Companion, { target: player, followDistance: 2.5 });}/** * Builds the camera: the orbit rig in a running game, a fixed vantage in a golden. * * @param app - The running app. * @param player - The entity the rig orbits. * @param isStatic - Whether the scene is being built for a golden. * @returns The camera's entity. */function buildCamera(app: App, player: Entity, isStatic: boolean): Entity {  const eye = app.world.createEntity("Main Camera");  eye.addComponent(Camera, { near: 0.1, far: 200, fov: 55 });  // The listener rides the camera, so a spatial sound is panned from where the player is looking.  eye.addComponent(AudioListener);  if (isStatic) {    eye.transform.position = STATIC_EYE;    eye.transform.lookAt(STATIC_FOCUS);    return eye;  }  // `ThirdPersonCamera.awake` reads the entity's current facing as the starting orbit, so the  // downward tilt is authored here rather than as a field.  eye.transform.localEulerAngles = { x: 12, y: 0, z: 0 };  eye.addComponent(ThirdPersonCamera, {    target: player,    distance: 4.5,    shoulderOffset: { x: 0.4, y: 0.55, z: 0 },    damping: 0.06,    minPitch: -20,    maxPitch: 55,    sensitivity: 0.12,    collisionEnabled: true,    collisionRadius: 0.25,    // Only scenery shortens the boom. Leaving this empty would let the character's own capsule and    // the companion pull the camera in whenever one drifted behind it.    collisionLayers: ["Level", "Prop"],  });  return eye;}/** * Builds the world. * * @param app - The running app. * @param assets - The loaded assets. * @param isStatic - Whether the scene is being built for a golden. * @returns The level, the character, the companion's script and the camera. */function buildWorld(app: App, assets: Assets, isStatic: boolean): World {  const level = buildLevel(app, {    floor: assets.floor,    wall: assets.wall,    crate: assets.crate,    sky: assets.sky,    emissive: assets.emissive,  });  const player = buildPlayer(app, assets, isStatic);  const companion = buildCompanion(app, assets, player, isStatic);  const eye = buildCamera(app, player, isStatic);  return { level, player, companion, eye };}/** * Bakes the navmesh from the level's own geometry. * * @remarks * From `addSource` triangles rather than from the scene's `MeshRenderer`s: the soup is world-space * and known before the first frame, so the bake does not depend on anything having been uploaded * to the GPU, and it is the same on a machine with no GPU at all. Babylon Lite 1.27.0 has no * navmesh serialization, so every bake is a runtime bake (`NavMeshSurface.prebaked` says so and * bakes anyway); keeping the source geometry this small is what keeps that cheap. * * @param app - The running app. * @param level - The level whose geometry is baked. * @returns A promise that settles once the crowd exists. */async function bakeNavigation(app: App, level: Level): Promise<void> {  const surface = app.world.createEntity("Navigation").addComponent(NavMeshSurface, {    bakeOnAwake: false,    agentRadius: 0.4,    agentHeight: PLAYER_HEIGHT,    agentClimb: 0.4,    cellSize: 0.2,    cellHeight: 0.2,    maxAgents: 8,  });  surface.addSource(level.navPositions, level.navIndices, null);  await surface.bake();}/** What {@link buildWorld} produced. */interface World {  /** The level. */  readonly level: Level;  /** The character. */  readonly player: Entity;  /** The companion's follow script, or `null` in a static scene. */  readonly companion: Companion | null;  /** The camera. */  readonly eye: Entity;}/** * Builds the front end and the save file over a world that is already standing. * * @param app - The running app. * @param assets - The loaded assets. * @param world - What {@link buildWorld} produced. * @param hud - The HUD element, or `null` under an app with no DOM overlay. * @param isBench - Whether the frame-time harness is driving, in which case the game starts *   immediately instead of waiting on a title screen. * @returns A promise for the callback that switches the post-process effects on; it has to run *   after `app.start()` (see below). */async function installFrontEnd(  app: App,  assets: Assets,  world: World,  hud: HTMLDivElement | null,  isBench: boolean,): Promise<() => void> {  // The chain is built once and switched with `enabled`, because `rendering.features.postProcessing`  // is read when `app.start()` registers the scene and asking for it later is `IGX-0704`. The  // template pays for the offscreen target either way; the toggle only decides whether the two  // effects run.  //  // The two effects are switched on by the callback this function returns, **after** `app.start()`.  // Enabling them earlier makes Lite record the first bloom task against a source that is still the  // swapchain, and the frame is rejected with `sourceTexture has no color texture`: the offscreen  // target the presenter builds does not exist until the scene is registered.  const post = world.eye.addComponent(PostProcessStack);  post.bloom.threshold = 0.85;  post.bloom.weight = 0.35;  const sun = world.level.sun;  const graphics: GraphicsHooks = {    supportsShadows: true,    supportsPostProcessing: true,    setShadows: (enabled: boolean): void => {      // `rendering.features.shadows` stays on: it is what compiled the shadow pass. What a player      // turns off is this light's own casting, which is a live flag.      sun.shadows.enabled = enabled;    },    setPostProcessing: (enabled: boolean): void => {      post.enabled = enabled;    },  };  await loadInputOverrides(app);  const settings = await loadSettings(app, app.i18n.locale);  applySettings(app, settings, graphics);  const host = app.world.createEntity("Game UI");  const saveGame = host.addComponent(SaveGame);  let menus: GameMenus | null = null;  const run = createRun(world.player, world.level.beacons, PLAYER_SPAWN_POSE, (): void => {    if (saveGame.checkpoint()) {      menus?.toast(app.i18n.t("toast.checkpoint"));    }  });  saveGame.run = run.state;  menus = createGameMenus(app, {    settings,    graphics,    gameplayMap: "Player",    rebindable: [      { action: "Jump", labelKey: "action.jump" },      { action: "Sprint", labelKey: "action.sprint" },      { action: "Pause", labelKey: "action.pause" },    ],    creditKeys: ["credits.engine", "credits.art", "credits.license"],    sounds: { click: clipOrNull(assets.uiClick), hover: clipOrNull(assets.uiHover) },    onStartNew: (): void => {      saveGame.restart();    },    onContinue: (save): void => {      saveGame.restore(save);    },    onSaveNow: (): Promise<boolean> => saveGame.save(),    onQuitToTitle: (): void => {      saveGame.restart();    },  });  host.addComponent(MenuController).menus = menus;  const line = host.addComponent(HudLine);  line.element = hud;  const controller = world.player.requireComponent(ThirdPersonController);  const companion = world.companion;  line.render = (): string =>    app.i18n.t("hud.status", {      speed: controller.speed.toFixed(1),      distance: (companion?.distanceToTarget() ?? 0).toFixed(1),    });  // The ambient pad loops on the `Music` bus, which `game.audio.json` marks as not pausable so the  // menus can duck it rather than silence it.  if (clipOrNull(assets.ambient) !== null) {    app.world.createEntity("Ambience").addComponent(AudioSource, {      clip: assets.ambient.retain(),      bus: "Music",      loop: true,      playOnAwake: true,      volume: 0.9,    });  }  if (!isBench) {    // The game boots into its title screen. `MenuController` reconciles `app.pause()` against the    // screen stack every frame, so this one call is what stops the world until "New game". The    // frame-time harness measures a *running* game, so it skips this and nothing else.    menus.show("title");    app.pause();  }  return (): void => {    post.bloom.enabled = true;    post.smaa.enabled = true;    post.enabled = settings.postProcessing;  };}/** * Builds and runs the game. * * @returns The status `window.__ignifxReady` resolves to. */async function main(): Promise<AppStatus> {  const canvas = document.querySelector("#game");  if (!(canvas instanceof HTMLCanvasElement)) {    throw new Error('3d-third-person needs a <canvas id="game"> element on the page.');  }  const flags = new URLSearchParams(window.location.search);  const isStatic = flags.get("static") === "1";  const isBench = flags.get("bench") === "1";  const showOverlay = (!isStatic || flags.get("hud") === "1") && !isBench;  let app: App;  try {    app = await createApp({      canvas,      settings: import.meta.env.IGNIFX_CONFIG,      // The address-to-URL table `@ignifx/vite-plugin` built from `assets/`. Importing the virtual      // module rather than fetching `assets.manifest.json` means the table is in the bundle, so      // the first asset request needs no round trip.      assets: { manifest },      // Order matters twice: `threeD()` requires `physics()` and `input()` to be registered before      // it, and `ui()` finds `@ignifx/input`'s focus flags structurally at registration time.      // `electron()` is registered in **both** builds. Without a preload bridge it is inert — one      // debug line, and an `app.desktop` that answers `isElectron === false` — so the browser build      // is unchanged and the desktop build needs no second entry point. It goes first because it      // only requires core, and because `app.storage` should be the file backend before any other      // extension reads a setting from it.      extensions: [electron(), physics(), input(), audio(), threeD(), ui()],    });  } catch (error) {    // IGX-0701 is the one failure a shipped game must handle itself: the browser has no WebGPU and    // ignifx has no fallback renderer by decision (ADR-0001).    if (isIgnifxError(error) && error.code === "IGX-0701") {      showUnsupported();      return "unsupported";    }    throw error;  }  // Every class under `src/scripts/**` with a `static typeId`, from the plugin's virtual registry; in  // development the registry hot-reloads edited scripts through `app.hotReload` (`"patch"` by default).  app.registerComponents(scripts);  acceptHotReload(app);  // The strings come first and alone: every label below is read out of them, and the document is  // under a kilobyte, so nothing is gained by making the loading screen wait for it.  const strings = app.assets.load<LocaleAsset>("strings.i18n.json", STRINGS);  await strings.promise;  await app.i18n.load(strings);  const locale = flags.get("locale");  if (locale !== null && app.i18n.availableLocales.includes(locale)) {    app.i18n.locale = locale;  }  const gameUi = createGameUi(app, {    loadingLabel: app.i18n.t("loading.label"),    touch: !isStatic && !isBench && hasTouch(),  });  // The golden is about the rendered scene, not about how this machine draws a system font, and a  // frame-time run should not be measuring DOM layout either.  app.ui.visible = showOverlay;  const assets: Assets = {    player: app.assets.load<ModelAsset>("player.glb", MODEL),    companion: app.assets.load<ModelAsset>("companion.glb", MODEL),    stateMachine: app.assets.load<AnimatorAsset>("hero.animator.json", ANIMATOR),    floor: app.assets.load<MaterialAsset>("floor.material.json", MATERIAL),    wall: app.assets.load<MaterialAsset>("wall.material.json", MATERIAL),    crate: app.assets.load<MaterialAsset>("crate.material.json", MATERIAL),    sky: app.assets.load<MaterialAsset>("sky.material.json", MATERIAL),    emissive: app.assets.load<MaterialAsset>("emissive.material.json", MATERIAL),    footstep: app.assets.load<AudioClip>("footstep.wav", CLIP),    jump: app.assets.load<AudioClip>("jump.wav", CLIP),    land: app.assets.load<AudioClip>("land.wav", CLIP),    pickup: app.assets.load<AudioClip>("pickup.wav", CLIP),    uiClick: app.assets.load<AudioClip>("ui-click.wav", CLIP),    uiHover: app.assets.load<AudioClip>("ui-hover.wav", CLIP),    ambient: app.assets.load<AudioClip>("ambient.wav", CLIP),  };  const actions = app.assets.load<InputActionsAsset>("game.input.json", ACTIONS);  const buses = app.assets.load<AudioBusesAsset>("game.audio.json", BUSES);  await Promise.all([    assets.player.promise,    assets.companion.promise,    assets.stateMachine.promise,    assets.floor.promise,    assets.wall.promise,    assets.crate.promise,    assets.sky.promise,    assets.emissive.promise,    actions.promise,    buses.promise,  ]);  // The clips are awaited together and separately from the rest: a browser that refuses to decode  // a sound should cost the game its audio, not its first frame.  await Promise.all(    [assets.footstep, assets.jump, assets.land, assets.pickup, assets.uiClick, assets.uiHover, assets.ambient].map(      async (handle: AssetHandle<AudioClip>): Promise<void> => {        await handle.promise.catch((error: unknown) => {          app.log.warn("a sound could not be decoded: {error}", String(error));        });      },    ),  );  // Installed here rather than through `input.actions` in `ignifx.config.ts`, so that the maps  // exist before the first `update` runs. See the comment in that file.  app.input.loadActions(actions.value);  await app.audio.buildBuses(buses.value.buses);  const world = buildWorld(app, assets, isStatic);  let enableEffects: (() => void) | null = null;  if (isStatic) {    // Stopping the clock *before* `start()` means no fixed step ever runs, so nothing falls,    // nothing animates and no path is computed: the frame is exactly what was authored. That is    // stronger than freezing the scene after a frame or two, and it needs no navmesh at all.    app.time.timeScale = 0;  } else {    // Recast is WebAssembly in a chunk of its own; this is the only thing that fetches it, and the    // loading screen is still up while it does.    await bakeNavigation(app, world.level);    enableEffects = await installFrontEnd(app, assets, world, gameUi.hud, isBench);  }  if (isStatic && showOverlay && gameUi.hud !== null) {    // `?static=1` leaves the front end out, so nothing writes the HUD. `?hud=1` says the overlay is    // wanted anyway — the gallery capture asks for exactly that — so the zero state is written once.    gameUi.hud.textContent = app.i18n.t("hud.status", { speed: "0.0", distance: "0.0" });  }  gameUi.loading.hide();  // A test-only hook, and only under `?probe=1`: `tests/visual/tests/desktop.spec.ts` uses it for  // the Phase 9 device-loss exit criterion. See `src/desktop-probe.ts`.  if (flags.get("probe") === "1") {    installDesktopProbe(app, world.level.crates);  }  if (isBench) {    installFrameTimeProbe(app);  }  await app.start();  // The post-process chain is switched on only now: its source is the offscreen target the  // presenter builds while `start()` registers the scene, and a task recorded before that samples  // the swapchain, which WebGPU rejects.  enableEffects?.();  await settle(SETTLE_FRAMES);  app.log.info("3d-third-person running: {calls} draw calls", app.renderer.drawCalls);  return "ready";}void main().then(announceReady, (error: unknown) => {  showUnsupported();  announceReady("unsupported");  // Rethrown out of the promise chain so it reaches `window.onerror` as an uncaught error rather  // than a swallowed rejection. Templates never log through `console` (coding standards §6).  setTimeout(() => {    throw error;  }, 0);});
src/level.ts
import { Light, MeshAsset, MeshRenderer } from "@ignifx/core";import { BoxCollider, Rigidbody } from "@ignifx/physics";import { Beacon } from "./scripts/beacon.js";import type { App, AssetHandle, Entity, MaterialAsset } from "@ignifx/core";/** * The courtyard: a floor, a ring of wall panels, two interior stubs for the camera to collide * with, and a handful of pushable crates. * * ## Why the walls are panels rather than four long boxes * * `MeshAsset.box` gives every face the unit UV square, so a single 24 by 3 metre wall would stretch * one 128-pixel texture over 24 metres. Six 4-metre panels share **one** mesh and one material * instead, so the texture reads at its authored density, the draw calls stay in the same range, and * each panel is its own collider — which is also what lets the navmesh bake carve the ring out. * * ## Why the crate placement is seeded * * `?static=1` has to produce the same picture on every run and on every machine. A seeded generator * is the cheapest way to have scattered-looking props that are nevertheless in exactly the same * place in the golden as they were when it was taken. *//** Half the courtyard, in metres: the floor spans `[-12, 12]` on both axes. */export const ARENA_HALF = 12;/** How tall the perimeter is, in metres. */const WALL_HEIGHT = 3;/** How thick a wall panel is, in metres. */const WALL_THICKNESS = 0.5;/** How wide one wall panel is, in metres. `ARENA_HALF * 2` divided by this must be a whole number. */const PANEL_WIDTH = 4;/** The edge of a crate, in metres. */const CRATE_SIZE = 1;/** How many crates are scattered. */const CRATE_COUNT = 6;/** The seed the crate scatter uses, so the authored scene is the same on every run. */const CRATE_SEED = 0x1f35_a7c1;/** * Where the three beacons stand, in metres. * * Hand-placed at a radius of about ten, which is outside the 4.5-to-8.5 ring the crates scatter in * and inside the twelve-metre wall — so a beacon never lands on a crate, and all three are in front * of the `?static=1` camera, which looks down `+z` from `z = -10.5`. */const BEACONS: readonly (readonly [number, number])[] = Object.freeze([  [-9.5, 3],  [9.5, 3],  [0, 10],]);/** How wide a beacon's base is, in metres. */const BEACON_BASE = 0.8;/** How tall a lit beacon's column is, in metres. */const BEACON_HEIGHT = 2.4;/** How far away the sky sphere is, in metres. Inside the camera's 200 m far plane. */const SKY_DIAMETER = 180;/** The materials a level is built from. */export interface LevelMaterials {  /** The floor slab. */  readonly floor: AssetHandle<MaterialAsset>;  /** The perimeter and the interior stubs. */  readonly wall: AssetHandle<MaterialAsset>;  /** The pushable crates. */  readonly crate: AssetHandle<MaterialAsset>;  /** The unlit gradient drawn on the inside of the sky sphere. */  readonly sky: AssetHandle<MaterialAsset>;  /** The glowing column of a lit beacon. */  readonly emissive: AssetHandle<MaterialAsset>;}/** What {@link buildLevel} produced. */export interface Level {  /** World-space triangle soup for `NavMeshSurface.addSource`, in Recast's flat layout. */  readonly navPositions: Float32Array;  /** The indices into {@link Level.navPositions}. */  readonly navIndices: Uint32Array;  /** The crates, so the HUD can count the ones still standing. */  readonly crates: readonly Entity[];  /** The sun, so the settings screen can turn its shadows off. */  readonly sun: Light;  /** The beacons, which are what the run's progress is counted in. */  readonly beacons: readonly Beacon[];}/** * A tiny deterministic generator, so a regenerated scene is the scene the golden was taken of. * * @param seed - The starting state. * @returns A function returning the next value in `[0, 1)`. */function rng(seed: number): () => number {  let state = seed >>> 0;  return (): number => {    state = (Math.imul(state, 1_664_525) + 1_013_904_223) >>> 0;    return state / 4_294_967_296;  };}/** Accumulates world-space triangles for the navmesh bake. */class NavSource {  readonly #positions: number[] = [];  readonly #indices: number[] = [];  /**   * Adds an axis-aligned box's twelve triangles, in the winding Recast reads as "outward".   *   * @param cx - The centre's x.   * @param cy - The centre's y.   * @param cz - The centre's z.   * @param hx - Half the width.   * @param hy - Half the height.   * @param hz - Half the depth.   */  box(cx: number, cy: number, cz: number, hx: number, hy: number, hz: number): void {    const base = this.#positions.length / 3;    for (let corner = 0; corner < 8; corner += 1) {      this.#positions.push(        cx + ((corner & 1) === 0 ? -hx : hx),        cy + ((corner & 2) === 0 ? -hy : hy),        cz + ((corner & 4) === 0 ? -hz : hz),      );    }    for (let index = 0; index < BOX_TRIANGLES.length; index += 1) {      this.#indices.push(base + (BOX_TRIANGLES[index] ?? 0));    }  }  /**   * Freezes the accumulated soup into the typed arrays Recast reads.   *   * @returns The positions and the indices.   */  build(): { readonly positions: Float32Array; readonly indices: Uint32Array } {    return { positions: Float32Array.from(this.#positions), indices: Uint32Array.from(this.#indices) };  }}/** * The twelve triangles of a box, indexing the eight corners {@link NavSource.box} emits: corner * `c` is `-x` unless bit 0 is set, `-y` unless bit 1 is set, `-z` unless bit 2 is set. * * The winding is **clockwise seen from outside**, which is what Babylon Lite's Recast bridge reads * as an outward normal — its own documented example, a floor quad wound * `(-x,-z) (+x,-z) (+x,+z) (-x,+z)` with indices `0 1 2 / 0 2 3`, has a cross product pointing at * `-y` and still bakes as walkable ground. A box wound the other way makes every face point inward, * Recast finds no walkable triangle at all, and `createNavMesh` fails outright rather than * returning an empty mesh. That is worth stating here because it is the one thing about * `addSource` that a caller cannot guess. */const BOX_TRIANGLES: readonly number[] = Object.freeze([  0, 1, 2, 1, 3, 2, 4, 6, 5, 5, 6, 7, 0, 4, 1, 1, 4, 5, 2, 3, 6, 3, 7, 6, 0, 2, 4, 2, 6, 4, 1, 5, 3, 3, 5, 7,]);/** One shared wall mesh and the half-extents that go with it. */interface Panel {  /** The mesh every panel of this orientation shares. */  readonly mesh: AssetHandle<MeshAsset>;  /** Half the panel's width along x. */  readonly halfX: number;  /** Half the panel's depth along z. */  readonly halfZ: number;}/** * Adds one wall panel: a mesh instance, a static collider, and a box in the navigation soup. * * @param app - The running app. * @param panel - The shared mesh and its half-extents. * @param material - The wall material. * @param nav - The navigation soup being accumulated. * @param name - The entity name. * @param x - The panel centre's x. * @param z - The panel centre's z. */function addPanel(  app: App,  panel: Panel,  material: AssetHandle<MaterialAsset>,  nav: NavSource,  name: string,  x: number,  z: number,): void {  const entity = app.world.createEntity(name, { position: { x, y: WALL_HEIGHT / 2, z } });  entity.layer = app.world.layers.requireIndex("Level");  entity.addComponent(MeshRenderer, {    mesh: panel.mesh.retain(),    materials: [material.retain()],    castShadows: true,    receiveShadows: true,  });  // No `Rigidbody`: a collider on its own is placed once as an implicit static body, which is  // exactly right for scenery that never moves (`IGX-0901` is what a moving one would report).  entity.addComponent(BoxCollider, { size: { x: panel.halfX * 2, y: WALL_HEIGHT, z: panel.halfZ * 2 } });  nav.box(x, WALL_HEIGHT / 2, z, panel.halfX, WALL_HEIGHT / 2, panel.halfZ);}/** * Builds the whole level. * * @param app - The running app. * @param materials - The three materials the level is drawn with. * @returns The navigation geometry and the crates. */export function buildLevel(app: App, materials: LevelMaterials): Level {  const levelLayer = app.world.layers.requireIndex("Level");  const propLayer = app.world.layers.requireIndex("Prop");  const nav = new NavSource();  // The sky: one inverted sphere with an unlit gradient. `Environment.skybox` wants a `.dds` or  // `.env` cube map, which cannot be generated from arithmetic the way every other asset in this  // repository is (CONSTITUTION.md §11.3) — a big sphere with `unlit` and `doubleSided` on gives  // the same horizon for one draw call and one 2 KB PNG.  const skyMesh = MeshAsset.sphere(app, { diameter: SKY_DIAMETER, segments: 24 });  const sky = app.world.createEntity("Sky");  sky.addComponent(MeshRenderer, {    mesh: skyMesh.retain(),    materials: [materials.sky.retain()],    castShadows: false,    receiveShadows: false,  });  skyMesh.release();  const sun = app.world.createEntity("Sun", { position: { x: -8, y: 14, z: -6 } });  sun.transform.lookAt({ x: 0, y: 0, z: 0 });  const light = sun.addComponent(Light, { type: "directional", intensity: 2.9 });  light.shadows.enabled = true;  // ESM, not PCF: Babylon Lite can rebuild ESM shadow maps after a lost device (`lib/shadow/shadow-recovery.js`  // refuses every other technique), and a desktop build must survive a GPU reset (Phase 9).  light.shadows.technique = "esm";  light.shadows.mapSize = 1024;  light.shadows.normalBias = 0.02;  app.world.createEntity("Fill").addComponent(Light, {    type: "hemispheric",    intensity: 0.45,    color: { r: 0.72, g: 0.79, b: 1, a: 1 },  });  const floor = app.world.createEntity("Floor");  floor.layer = levelLayer;  floor.addComponent(MeshRenderer, {    // `uvScale` is why the 128-pixel slab texture is not smeared across 24 metres: the ground's    // UVs are multiplied so one tile covers about two and a half metres.    mesh: MeshAsset.ground(app, { width: ARENA_HALF * 2, height: ARENA_HALF * 2, subdivisions: 1, uvScale: [10, 10] }),    materials: [materials.floor.retain()],    castShadows: false,    receiveShadows: true,  });  // A slab rather than an infinite plane: Havok has no plane shape in this build, and a thick box  // is what stops a fast faller tunnelling through.  floor.addComponent(BoxCollider, {    size: { x: ARENA_HALF * 2, y: 1, z: ARENA_HALF * 2 },    center: { x: 0, y: -0.5, z: 0 },  });  nav.box(0, -0.5, 0, ARENA_HALF, 0.5, ARENA_HALF);  const alongX: Panel = {    mesh: MeshAsset.box(app, { width: PANEL_WIDTH, height: WALL_HEIGHT, depth: WALL_THICKNESS }),    halfX: PANEL_WIDTH / 2,    halfZ: WALL_THICKNESS / 2,  };  const alongZ: Panel = {    mesh: MeshAsset.box(app, { width: WALL_THICKNESS, height: WALL_HEIGHT, depth: PANEL_WIDTH }),    halfX: WALL_THICKNESS / 2,    halfZ: PANEL_WIDTH / 2,  };  const perSide = (ARENA_HALF * 2) / PANEL_WIDTH;  for (let index = 0; index < perSide; index += 1) {    const offset = -ARENA_HALF + PANEL_WIDTH / 2 + index * PANEL_WIDTH;    addPanel(app, alongX, materials.wall, nav, `Wall N${String(index)}`, offset, -ARENA_HALF);    addPanel(app, alongX, materials.wall, nav, `Wall S${String(index)}`, offset, ARENA_HALF);    addPanel(app, alongZ, materials.wall, nav, `Wall W${String(index)}`, -ARENA_HALF, offset);    addPanel(app, alongZ, materials.wall, nav, `Wall E${String(index)}`, ARENA_HALF, offset);  }  // The two interior stubs. They are what the third-person camera's boom collides with when the  // player backs into the corner they make, and what the companion's path has to walk around.  addPanel(app, alongX, materials.wall, nav, "Stub A", -2, 3);  addPanel(app, alongZ, materials.wall, nav, "Stub B", -4, 5);  // The handles the two shared meshes were created with; every panel took its own reference.  alongX.mesh.release();  alongZ.mesh.release();  const crateMesh = MeshAsset.box(app, { size: CRATE_SIZE });  const random = rng(CRATE_SEED);  const crates: Entity[] = [];  for (let index = 0; index < CRATE_COUNT; index += 1) {    const angle = random() * Math.PI * 2;    // The ring starts outside the player's spawn, so no crate ever lands on the character.    const radius = 4.5 + random() * 4;    const crate = app.world.createEntity(`Crate ${String(index)}`, {      position: {        x: Math.cos(angle) * radius,        // Exactly half a crate above the slab, so nothing has to fall before the frame is honest:        // that is what makes `?static=1` show a settled scene without a single fixed step.        y: CRATE_SIZE / 2,        z: Math.sin(angle) * radius,      },    });    crate.layer = propLayer;    crate.transform.localEulerAngles = { x: 0, y: random() * 90, z: 0 };    crate.addComponent(MeshRenderer, {      mesh: crateMesh.retain(),      materials: [materials.crate.retain()],      castShadows: true,      receiveShadows: true,    });    crate.addComponent(BoxCollider, { size: { x: CRATE_SIZE, y: CRATE_SIZE, z: CRATE_SIZE } });    crate.addComponent(Rigidbody, { mass: 12 });    crates.push(crate);  }  crateMesh.release();  // The beacons. Each is a dark base with an emissive column that is hidden until the player  // reaches it; `src/scripts/beacon.ts` explains why the reach test is a distance and not a  // trigger volume.  const baseMesh = MeshAsset.box(app, { width: BEACON_BASE, height: 0.25, depth: BEACON_BASE });  const columnMesh = MeshAsset.cylinder(app, { diameter: 0.28, height: BEACON_HEIGHT });  const beacons: Beacon[] = [];  for (let index = 0; index < BEACONS.length; index += 1) {    const spot = BEACONS[index];    if (spot === undefined) {      continue;    }    const entity = app.world.createEntity(`Beacon ${String(index)}`, {      position: { x: spot[0], y: 0.125, z: spot[1] },    });    entity.layer = propLayer;    entity.addComponent(MeshRenderer, {      mesh: baseMesh.retain(),      materials: [materials.wall.retain()],      castShadows: true,      receiveShadows: true,    });    // Two columns in the same place, one dark and one emissive, and exactly one of them enabled.    // A `MeshRenderer` has no tint, and swapping a material list at run time rebuilds the renderer;    // two renderers and a boolean is the cheaper and clearer way to say "this one is lit".    const dark = app.world.createEntity(`Beacon ${String(index)} Column`, { parent: entity });    dark.transform.localPosition.set(0, BEACON_HEIGHT / 2 + 0.125, 0);    const darkRenderer = dark.addComponent(MeshRenderer, {      mesh: columnMesh.retain(),      materials: [materials.wall.retain()],      castShadows: true,      receiveShadows: true,    });    const column = app.world.createEntity(`Beacon ${String(index)} Light`, { parent: entity });    column.transform.localPosition.set(0, BEACON_HEIGHT / 2 + 0.125, 0);    const renderer = column.addComponent(MeshRenderer, {      mesh: columnMesh.retain(),      materials: [materials.emissive.retain()],      castShadows: false,      receiveShadows: false,    });    const beacon = entity.addComponent(Beacon);    beacon.column = renderer;    beacon.unlitColumn = darkRenderer;    beacon.setLit(false);    beacons.push(beacon);  }  baseMesh.release();  columnMesh.release();  const soup = nav.build();  return { navPositions: soup.positions, navIndices: soup.indices, crates, sun: light, beacons };}
src/scripts/hero-animation.ts
import { Animator, ThirdPersonController } from "@ignifx/3d";import { f32, Script } from "@ignifx/core";import type { AudioClip } from "@ignifx/audio";import type { AssetHandle, ScriptCallbacks } from "@ignifx/core";/** * The one script that connects the character controller to the animation state machine, and plays * a footstep while the character is walking. * * `@ignifx/3d` deliberately does not do this for you: an `Animator` reads a document whose * parameter names are the game's, not the engine's, so the mapping from "how fast is this character * moving" to "what is the `speed` parameter called" belongs in the game. This is that mapping, and * `assets/hero.animator.json` is the other half of it. * * Everything here runs in `update`, not `fixedUpdate`. The controller has already moved on the * fixed step; the animator runs in `PostUpdate`, after `update`. Writing the parameters on the * frame clock therefore reaches the same frame's pose, and doing it on the fixed clock would write * the same values twice whenever a frame carried two steps. */export class HeroAnimation  extends Script.define({    /** How far the character walks between footsteps, in metres. */    strideMetres: f32(1.9),    /** Below this speed, in m/s, the character is standing still and no footstep plays. */    walkThreshold: f32(0.6),  })  implements ScriptCallbacks{  static typeId = "third-person/HeroAnimation";  /** The footstep clip. Assigned by `main.ts`, because an asset handle is not a schema field here. */  footstep: AssetHandle<AudioClip> | null = null;  /** The state machine this drives. */  #animator: Animator | null = null;  /** The controller whose state the machine reads. */  #controller: ThirdPersonController | null = null;  /** Metres walked since the last footstep. */  #stride = 0;  /** Whether the jump trigger has already been set for the current airborne moment. */  #jumpArmed = true;  awake(): void {    // `getComponentInChildren` and not `getComponent`: the `Animator` sits on the child entity that    // carries the model, because a `CharacterController`'s capsule is centred on its own entity and    // the rig's origin is between its feet. The two cannot share one transform.    this.#animator = this.entity.getComponentInChildren(Animator);    this.#controller = this.entity.requireComponent(ThirdPersonController);    this.#animator?.onEvent.connect(      (name: string): void => {        if (name === "landed") {          this.app.log.debug("the hero lands");        }      },      { owner: this },    );  }  update(dt: number): void {    const controller = this.#controller;    if (controller === null) {      return;    }    const grounded = controller.isGrounded;    const speed = controller.speed;    const animator = this.#animator;    if (animator !== null) {      animator.setFloat("speed", speed);      animator.setBool("grounded", grounded);      // The trigger is armed on the ground and fired once on the way up, so a long hang time does      // not re-enter the jump state every frame.      if (grounded) {        this.#jumpArmed = true;      } else if (this.#jumpArmed && controller.verticalVelocity > 0) {        this.#jumpArmed = false;        animator.setTrigger("jump");      }    }    if (!grounded || speed < this.walkThreshold) {      this.#stride = 0;      return;    }    this.#stride += speed * dt;    if (this.#stride < this.strideMetres) {      return;    }    this.#stride -= this.strideMetres;    const clip = this.footstep;    if (clip !== null) {      // `playOneShot` routes into the `SFX` bus of `game.audio.json`. A browser keeps its audio      // context suspended until the player has interacted with the page, so the first footstep may      // be the one that unlocks it rather than the one that is heard.      this.app.audio.playOneShot(clip.value, { volume: 0.5 });    }  }}

Uses:CharacterControllerThirdPersonControllerThirdPersonCameraAnimatorNavMeshSurfaceNavMeshAgentPostProcessStackMenuMenuStackapp.storage

Assets:Template rig, textures and audio — Apache-2.0, Astrum Forge Studios