ignifx
GitHubnpm · soon
All examples

First-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. `FirstPersonController` walks, sprints, crouches and jumps on a `CharacterController` capsule, with head bob and pointer lock, and the body owns the yaw while the head owns the pitch. The view model is parented to the rig's `hand` node, so the prop it carries follows the animation; a crosshair ray from the camera through `app.physics.raycast` reports what is in reach on the `Interactable` layer and lights a pedestal when you interact; and the crates are rigid bodies you can push. The title screen, pause menu, settings, key rebinding and save file are all real.

A first-person view across a grey tiled room ringed by dark panelled walls: blue-grey crates and three pedestals stand ahead, a small crosshair sits at the centre of the frame, the view model and its teal prop fill the bottom corner, and a chip in the top corner reads that no pedestals are lit and nothing is in reach.
Open standalone

` devtools

WebGPU: checking…

Try this

  • Click the frame to take pointer lock, then walk with WASD and hold C to crouch.
  • Look at a pedestal and press E — the heads-up display says what is in reach before you do.
  • 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, FirstPersonController, threeD } from "@ignifx/3d";import { audio, AUDIO_ASSET_TYPE, AUDIO_BUSES_ASSET_TYPE, AudioListener, AudioSource } from "@ignifx/audio";import {  Camera,  createApp,  createMaterialAsset,  isIgnifxError,  MeshAsset,  MeshRenderer,  Model,  pbrMaterialDefinition,  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 { 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 { AttachToHand } from "./scripts/attach-to-hand.js";import { HudLine } from "./scripts/hud-line.js";import { Interactor } from "./scripts/interactor.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 first-person 3D game: a walking, jumping, sprinting, crouching character whose head owns the * pitch, pointer lock on the first click, a crosshair ray that lights the pedestal it lands on, a * view model with a prop attached to the rig's `hand` node, 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 controller and without pointer lock, places the *   head by hand, and stops the clock before the first frame. No fixed step ever runs, so nothing *   falls and no clip advances: 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. */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 while standing, in metres. */const STAND_HEIGHT = 1.8;/** How high the eyes sit above the capsule's centre, in metres. */const EYE_OFFSET = 0.72;/** Where the character starts, on the ground. */const PLAYER_SPAWN = { x: 0, y: 0, z: 2.5 } as const;/** Where the capsule's centre starts: the spawn, lifted by half the standing capsule's height. */const PLAYER_SPAWN_POSE = { x: PLAYER_SPAWN.x, y: STAND_HEIGHT / 2, z: PLAYER_SPAWN.z } as const;/** The body's yaw in `?static=1`, in degrees: turned to face the pair of pedestals at `z = -6`. */const STATIC_YAW = 180;/** The head's pitch in `?static=1`, in degrees; a little down, so the floor carries the frame. */const STATIC_PITCH = 7;/** The asset type to load each document as; see the same table in the third-person template. */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 viewmodel: 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 pickup: AssetHandle<AudioClip>;  readonly footstep: AssetHandle<AudioClip>;  readonly jump: AssetHandle<AudioClip>;  readonly land: 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";}/** * Hangs the view model off the head, and a prop off the view model's `hand` node. * * @remarks * The rig is the same generated "box-man" the third-person template walks around with. In first * person you would normally see only arms; this template shows the whole thing, scaled down and * pushed forward and to the right of the eye, because that is what makes * `Model.attachToNode("hand", …)` visible — the point of the exercise is the bone attachment, and a * prop welded to a bone you cannot see demonstrates nothing. * * @param app - The running app. * @param assets - The loaded assets. * @param head - The entity the view model hangs from. */function buildViewModel(app: App, assets: Assets, head: Entity): void {  const viewmodel = app.world.createEntity("View Model", { parent: head });  viewmodel.transform.localPosition.set(0.3, -0.84, 0.78);  viewmodel.transform.localScale.set(0.38, 0.38, 0.38);  viewmodel.transform.localEulerAngles = { x: 0, y: 150, z: 0 };  viewmodel.addComponent(Model, { model: assets.viewmodel.retain(), castShadows: false, receiveShadows: false });  viewmodel.addComponent(Animator, { animator: assets.stateMachine.retain() });  // A small emissive bar standing in for a tool. It is created as a **root** entity and re-parented  // by `attachToNode`, which is what puts it under the glTF node rather than under an ignifx entity.  const prop = app.world.createEntity("Prop");  prop.addComponent(MeshRenderer, {    mesh: MeshAsset.box(app, { width: 0.12, height: 0.12, depth: 0.9 }),    materials: [      createMaterialAsset(        app,        pbrMaterialDefinition({          name: "prop",          baseColor: { r: 0.29, g: 0.82, b: 0.94, a: 1 },          emissive: { r: 0.06, g: 0.24, b: 0.3, a: 1 },          metallic: 0.6,          roughness: 0.3,        }),        [],      ),    ],    castShadows: false,    receiveShadows: false,  });  viewmodel.addComponent(AttachToHand).prop = prop;}/** * Builds the character: a capsule, a head that pitches, and the view model hanging off it. * * @param app - The running app. * @param assets - The loaded assets. * @param isStatic - Whether the scene is being built for a golden. * @returns The head entity, which is where the camera and the listener are. */function buildPlayer(app: App, assets: Assets, isStatic: boolean): { readonly player: Entity; readonly head: Entity } {  const player = app.world.createEntity("Player", {    position: { x: PLAYER_SPAWN.x, y: STAND_HEIGHT / 2, z: PLAYER_SPAWN.z },  });  player.layer = app.world.layers.requireIndex("Player");  player.addComponent(CharacterController, { height: STAND_HEIGHT, radius: 0.35, slopeLimit: 50 });  // Yaw lives on the body and pitch on this child. Rotating the capsule in pitch would tip the  // whole collider over, which is why `FirstPersonController` splits them.  const head = app.world.createEntity("Head", { parent: player });  head.transform.localPosition.set(0, EYE_OFFSET, 0);  head.addComponent(Camera, { near: 0.05, far: 200, fov: 68 });  // The listener rides the head, so a spatial sound is panned from where the player is looking.  head.addComponent(AudioListener);  buildViewModel(app, assets, head);  if (isStatic) {    // The split the controller would otherwise own: yaw on the body, pitch on the head.    player.transform.localEulerAngles = { x: 0, y: STATIC_YAW, z: 0 };    head.transform.localEulerAngles = { x: STATIC_PITCH, y: 0, z: 0 };    return { player, head };  }  player.addComponent(FirstPersonController, {    cameraPivot: head,    walkSpeed: 4,    sprintSpeed: 7,    crouchSpeed: 1.8,    standHeight: STAND_HEIGHT,    crouchHeight: 1.1,    jumpHeight: 1.1,    sensitivity: 0.12,    headBobAmplitude: 0.035,    // The first click on the canvas asks the browser for pointer lock; `<Mouse>/delta` keeps    // reporting while it holds, and `Escape` gives it back (which is also what opens the menu).    lockPointerOnClick: true,  });  return { player, head };}/** What the front end is built over. */interface World {  /** The level. */  readonly level: Level;  /** The character's body, which carries the `CharacterController`. */  readonly player: Entity;  /** The head, which carries the camera and the interaction ray. */  readonly head: Entity;  /** The crosshair element, or `null` under an app with no DOM overlay. */  readonly crosshair: HTMLElement | null;}/** * 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 - The level and the character. * @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 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.head.addComponent(PostProcessStack);  post.bloom.threshold = 0.8;  post.bloom.weight = 0.4;  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;    },  };  const interactor = world.head.addComponent(Interactor, { eye: world.head, reach: 3.5 });  interactor.targets = world.level.pedestals;  if (clipOrNull(assets.pickup) !== null) {    interactor.clip = assets.pickup;  }  interactor.crosshair = world.crosshair;  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, interactor, world.level.pedestals, 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: "Interact", labelKey: "action.interact" },      { 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;  line.render = (): string =>    app.i18n.t("hud.status", {      lit: interactor.litCount,      target: interactor.focus === null ? app.i18n.t("hud.nothing") : app.i18n.t("hud.reach"),    });  // 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-first-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,      assets: { manifest },      // `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);  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 = {    viewmodel: app.assets.load<ModelAsset>("viewmodel.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),    pickup: app.assets.load<AudioClip>("pickup.wav", CLIP),    footstep: app.assets.load<AudioClip>("footstep.wav", CLIP),    jump: app.assets.load<AudioClip>("jump.wav", CLIP),    land: app.assets.load<AudioClip>("land.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.viewmodel.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.pickup, assets.footstep, assets.jump, assets.land, 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));        });      },    ),  );  app.input.loadActions(actions.value);  await app.audio.buildBuses(buses.value.buses);  const level = buildLevel(app, {    floor: assets.floor,    wall: assets.wall,    crate: assets.crate,    sky: assets.sky,    emissive: assets.emissive,  });  const character = buildPlayer(app, assets, isStatic);  let enableEffects: (() => void) | null = null;  if (isStatic) {    // No fixed step ever runs, so nothing falls, no clip advances and no ray is cast: the frame is    // exactly what was authored.    app.time.timeScale = 0;  } else {    enableEffects = await installFrontEnd(      app,      assets,      { level, ...character, crosshair: gameUi.crosshair },      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", { lit: 0, target: app.i18n.t("hud.nothing") });  }  gameUi.loading.hide();  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-first-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 type { App, AssetHandle, Entity, MaterialAsset } from "@ignifx/core";/** * The room: a floor, a ring of wall panels, two interior stubs, a handful of pushable crates, and * three **interactable** pedestals the crosshair ray can find. * * ## 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. * * ## 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 room, 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 interactable pedestals stand, in metres. Hand-placed, not scattered. */const PEDESTALS: readonly (readonly [number, number])[] = Object.freeze([  [-4, -6],  [4, -6],  [0, 6],]);/** The edge of a pedestal, in metres. */const PEDESTAL_SIZE = 0.9;/** How high a pedestal's centre sits, in metres. */const PEDESTAL_Y = 1.1;/** How far away the sky sphere is, in metres. Inside the camera's 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 cap on top of each pedestal. */  readonly emissive: AssetHandle<MaterialAsset>;}/** What {@link buildLevel} produced. */export interface Level {  /** The pushable crates. */  readonly crates: readonly Entity[];  /** The interactable pedestals, each with a material of its own the ray can recolour. */  readonly pedestals: readonly Interactable[];  /** The sun, so the settings screen can turn its shadows off. */  readonly sun: Light;}/** One thing the crosshair ray can find. */export interface Interactable {  /** The entity the ray hits. */  readonly entity: Entity;  /** The pedestal's private material clone, so recolouring one does not recolour them all. */  readonly material: AssetHandle<MaterialAsset>;  /** The emissive cap, shown only while the pedestal is lit. */  readonly glow: MeshRenderer;}/** * 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;  };}/** 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 and a static collider. * * @param app - The running app. * @param panel - The shared mesh and its half-extents. * @param material - The wall material. * @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>,  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 } });}/** * Builds the whole level. * * @param app - The running app. * @param materials - The three materials the level is drawn with. * @returns The crates and the interactable pedestals. */export function buildLevel(app: App, materials: LevelMaterials): Level {  const levelLayer = app.world.layers.requireIndex("Level");  const propLayer = app.world.layers.requireIndex("Prop");  // 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 });  app.world.createEntity("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 },  });  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, `Wall N${String(index)}`, offset, -ARENA_HALF);    addPanel(app, alongX, materials.wall, `Wall S${String(index)}`, offset, ARENA_HALF);    addPanel(app, alongZ, materials.wall, `Wall W${String(index)}`, -ARENA_HALF, offset);    addPanel(app, alongZ, materials.wall, `Wall E${String(index)}`, ARENA_HALF, offset);  }  // Two interior stubs, so the room has a corner to walk around and something to break the  // sightline from one pedestal to the next.  addPanel(app, alongX, materials.wall, "Stub A", -2, 3);  addPanel(app, alongZ, materials.wall, "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 pedestals. Each carries its **own** clone of the crate material, because  // `setBaseColor` writes the Lite material every renderer sharing the asset draws with: without  // the clone, lighting one pedestal would light all three.  const interactableLayer = app.world.layers.requireIndex("Interactable");  const pedestalMesh = MeshAsset.box(app, { size: PEDESTAL_SIZE });  const pedestals: Interactable[] = [];  for (let index = 0; index < PEDESTALS.length; index += 1) {    const spot = PEDESTALS[index];    if (spot === undefined) {      continue;    }    const material = materials.crate.value.clone(app);    const pedestal = app.world.createEntity(`Pedestal ${String(index)}`, {      position: { x: spot[0], y: PEDESTAL_Y, z: spot[1] },    });    pedestal.layer = interactableLayer;    pedestal.addComponent(MeshRenderer, {      mesh: pedestalMesh.retain(),      materials: [material.retain()],      castShadows: true,      receiveShadows: true,    });    pedestal.addComponent(BoxCollider, { size: { x: PEDESTAL_SIZE, y: PEDESTAL_SIZE, z: PEDESTAL_SIZE } });    // A post under it, so a pedestal reads as furniture rather than as a floating box.    const post = app.world.createEntity(`Pedestal ${String(index)} Post`, {      position: { x: spot[0], y: (PEDESTAL_Y - PEDESTAL_SIZE / 2) / 2, z: spot[1] },    });    post.layer = levelLayer;    post.addComponent(MeshRenderer, {      mesh: MeshAsset.box(app, { width: 0.3, height: PEDESTAL_Y - PEDESTAL_SIZE / 2, depth: 0.3 }),      materials: [materials.wall.retain()],      castShadows: true,      receiveShadows: true,    });    // The emissive cap. It is hidden until the pedestal is lit, which is one boolean rather than a    // second material and a second draw call.    const cap = app.world.createEntity(`Pedestal ${String(index)} Cap`, { parent: pedestal });    cap.transform.localPosition.set(0, PEDESTAL_SIZE / 2 + 0.06, 0);    const glow = cap.addComponent(MeshRenderer, {      mesh: MeshAsset.box(app, { width: PEDESTAL_SIZE * 0.6, height: 0.12, depth: PEDESTAL_SIZE * 0.6 }),      materials: [materials.emissive.retain()],      castShadows: false,      receiveShadows: false,    });    glow.enabled = false;    pedestals.push({ entity: pedestal, material, glow });  }  pedestalMesh.release();  return { crates, pedestals, sun: light };}
src/scripts/interactor.ts
import { entityRef, f32, LayerMask, Script } from "@ignifx/core";import type { AudioClip } from "@ignifx/audio";import type { AssetHandle, ColorLike, Entity, MaterialAsset, MeshRenderer, ScriptCallbacks } from "@ignifx/core";import type { InputAction } from "@ignifx/input";/** * The crosshair ray: what a first-person game does instead of clicking on things. * * ## Where the ray comes from * * The camera's own transform, not the character's. In first person the body owns the yaw and the * head owns the pitch (`FirstPersonController.cameraPivot`), so only the head is looking where the * crosshair points. `transform.forward` on the head entity is that direction, already in world * space, and the ray starts at the head's position because the crosshair sits at the exact centre * of the screen — the projection of the camera's own forward axis. * * A `camera.screenToRay(x, y)` would be the right tool for a cursor that can be anywhere; it takes * **backing-store** pixels, which is the space `@ignifx/input` reports pointer positions in. * * ## Why the mask is a layer and not a component check * * `app.physics.raycast` filters by layer inside Havok, so a ray aimed at the `Interactable` layer * never reports the floor, the walls or the crates and never allocates a hit for them. Walking the * hits and asking "is this a pedestal?" would do the same work in JavaScript, every frame, for * every ray. *//** How the pedestal reads when the crosshair is not on it. */const IDLE_COLOR: ColorLike = Object.freeze({ r: 1, g: 1, b: 1, a: 1 });/** How the pedestal reads once it has been switched on. */const LIT_COLOR: ColorLike = Object.freeze({ r: 0.29, g: 0.82, b: 0.94, a: 1 });/** One thing this script can light up. */export interface InteractorTarget {  /** The entity the ray hits. */  readonly entity: Entity;  /** That entity's own material, so recolouring one does not recolour the others. */  readonly material: AssetHandle<MaterialAsset>;  /** The emissive cap, shown only while the pedestal is lit. */  readonly glow: MeshRenderer;}/** * Casts a ray down the crosshair every frame, and toggles whatever it finds when `Interact` is * pressed. */export class Interactor  extends Script.define({    /** The head entity the ray is cast from; usually the camera's entity. */    eye: entityRef<Entity>(),    /** How far the player can reach, in metres. */    reach: f32(3.5),  })  implements ScriptCallbacks{  static typeId = "first-person/Interactor";  /** The targets the ray may find, by entity. Assigned right after the component is added. */  targets: readonly InteractorTarget[] = [];  /** The blip played on a toggle. */  clip: AssetHandle<AudioClip> | null = null;  /** The crosshair element, so it can say when something is in reach. */  crosshair: HTMLElement | null = null;  /** Called whenever a pedestal is switched. Assigned right after the component is added. */  onToggled: ((name: string, lit: boolean) => void) | null = null;  /** The `Interact` action. */  #interact: InputAction | null = null;  /** Which layers the ray may hit, resolved once. */  #mask: LayerMask | null = null;  /** Which entities are currently lit, by name. */  readonly #lit = new Set<string>();  /** What the crosshair is on, or `null`. */  #focus: InteractorTarget | null = null;  awake(): void {    this.#interact = this.app.input.actions.find("Interact");    this.#mask = LayerMask.fromNames(this.world.layers, ["Interactable"]);  }  update(): void {    const eye = this.eye;    const mask = this.#mask;    if (eye === null || eye.isDestroyed || mask === null) {      return;    }    // Every query needs one completed fixed step behind it: Havok builds its broadphase there and    // reports `IGX-0902` before that. The app has always stepped by the time a script updates.    const hit = this.app.physics.raycast(eye.transform.position, eye.transform.forward, this.reach, {      layerMask: mask,    });    const found = hit === null ? null : (this.targets.find((target) => target.entity === hit.entity) ?? null);    if (found !== this.#focus) {      this.#focus = found;      // `dataset.target` is `data-target`; `index.html` styles the crosshair off it.      if (this.crosshair !== null) {        this.crosshair.dataset["target"] = found === null ? "none" : "hit";      }    }    if (found !== null && this.#interact?.wasPressedThisFrame === true) {      this.#toggle(found);    }  }  /**   * Whether a pedestal is currently switched on. The HUD counts them.   *   * @param target - The pedestal to ask about.   * @returns `true` when it is lit.   */  isLit(target: InteractorTarget): boolean {    return this.#lit.has(target.entity.name);  }  /**   * How many pedestals are switched on.   *   * @returns The count.   */  get litCount(): number {    return this.#lit.size;  }  /**   * Which pedestals are switched on, so a save file can store them.   *   * @returns Their entity names, sorted.   */  litNames(): readonly string[] {    return [...this.#lit].toSorted();  }  /**   * Switches one pedestal without playing a sound or reporting it. This is what a save restore and   * a reset use.   *   * @param target - The pedestal.   * @param lit - Whether it should read as lit.   */  setLit(target: InteractorTarget, lit: boolean): void {    const name = target.entity.name;    if (lit) {      this.#lit.add(name);    } else {      this.#lit.delete(name);    }    // `setBaseColor` writes the Lite material, which every renderer sharing this asset draws with.    // `src/level.ts` gives each pedestal its own `clone`, which is what makes that safe here.    target.material.value.setBaseColor(lit ? LIT_COLOR : IDLE_COLOR);    target.glow.enabled = lit;  }  /**   * What the crosshair is on.   *   * @returns The target, or `null` when nothing is in reach.   */  get focus(): InteractorTarget | null {    return this.#focus;  }  /**   * Switches one pedestal on or off.   *   * @param target - The pedestal.   */  #toggle(target: InteractorTarget): void {    const name = target.entity.name;    const lit = !this.#lit.has(name);    this.setLit(target, lit);    const clip = this.clip;    if (clip !== null && clip.state === "loaded") {      this.app.audio.playOneShot(clip.value, { volume: 0.6, pitch: lit ? 1 : 0.75 });    }    this.onToggled?.(name, lit);    this.app.log.info("{name} is now {state}", name, lit ? "lit" : "dark");  }}

Uses:CharacterControllerFirstPersonControllerAnimatorapp.physics.raycastPostProcessStackMenuMenuStackapp.storage

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