All examples
Devtools overlay
- Mouse
- Keyboard
- Touch
- Gamepad
`@ignifx/devtools` is one extension and one key. Register it, press backtick, and nine tabs appear over the canvas: frame numbers, the entity tree, a live component inspector, asset handles, input, audio, physics, the log and a per-phase timing graph. Every example on this site carries it; this is the one that opens it for you, pointed at the `Director` entity, whose serialised fields are rows you can edit. Closed, it costs nothing — no system runs and no DOM exists until `open()` — which is why a game registers it behind a development flag and forgets about it.

WebGPU: checking…See browser support
Try this
- Type a new number into the Inspector's `waveSeconds` row and watch the wave clock follow it.
- Open Stats for draw calls and engine CPU, then Timeline for the same frame, phase by phase.
- Select a drone in the Scene tab: the Inspector follows, down to its live transform.
Show source
Source
import { Camera } from "ignifx";import { bootExample } from "../_kit/boot.ts";import { attachOrbit } from "../_kit/orbit.ts";import { bind, button, readout, slider, toggle } from "../_kit/panel.ts";import { createGridGround, createLightRig } from "../_kit/stage.ts";import { Director } from "./director.ts";import { createRing, Drone, START_SPEED } from "./ring.ts";import type { DevtoolsPanelName } from "ignifx";/** * The devtools overlay, open before you arrive. * * `@ignifx/devtools` is one extension and one key. Register it, press backtick, and nine tabs * appear over the canvas: frame numbers, the entity tree, a live component inspector, asset * handles, input, audio, physics, the log and a per-phase timing graph. It costs nothing while it * is closed — no system runs, no signal is subscribed to and no DOM exists until `open()` — which * is why a game registers it behind a development flag and then forgets about it. Every other * example on this site carries it too; press backtick on any of them. * * This is the one page that opens it for you, through the `devtools` settings section below. * `openOnStart` does it, `position: "left"` keeps it clear of the parameter panel, and `panels` * both re-orders and filters the tab strip — so the overlay opens on the **Inspector**, pointed at * the `Director` entity. * * The Inspector is where a game's own state shows up. Every `Script.define` field is a row it reads * and writes: type a number into `wave` and the next frame's `update` uses it, because the rows * write through to the live component. `director.ts` also publishes the same four numbers into * `app.diagnostics`, which is the API the Stats panel's own rows come from and the one a headless * test reads without opening anything. * * Worth knowing before copying that last part: the Stats panel draws a **fixed** list of engine * counters. A custom group is read back with `app.diagnostics.group("game")` — as the panel on the * right does — and is not a row on the overlay. *//** The nine panels, in the tab order this example wants: the Inspector first, then the rest. */const PANELS: readonly DevtoolsPanelName[] = [ "inspector", "stats", "scene", "timeline", "assets", "console", "input", "audio", "physics",];/** How many of {@link PANELS} get a "Show" button in the parameter panel. */const SHORTCUTS = 4;bootExample({ title: "Devtools overlay", settings: { rendering: { clearColor: { r: 0.043, g: 0.059, b: 0.094, a: 1 }, msaaSamples: 4, features: { shadows: true }, }, devtools: { openOnStart: true, position: "left", panels: [...PANELS], opacity: 0.94 }, time: { fixedDeltaTime: 1 / 60 }, }, async setup({ app, panel }) { app.registerComponents([Director, Drone]); const focus = { x: 0, y: 0.5, z: 0 }; const eye = app.world.createEntity("Main Camera"); eye.addComponent(Camera, { near: 0.1, far: 200, fov: 44 }); attachOrbit(app, eye, { yaw: 26, pitch: 27, distance: 4.4, target: focus, minDistance: 2, maxDistance: 14 }); const rig = createLightRig(app, { focus, shadows: true, shadowDarkness: 0.3 }); await createGridGround(app, { size: 24 }); createRing(app); // The entity the overlay opens pointed at. `select` works before `app.start()` — the service // exists from `createApp` — so the Inspector has something to show on its first refresh. const stage = app.world.createEntity("Director"); const director = stage.addComponent(Director); app.devtools.select(stage); panel({ title: "Devtools overlay", groups: [ { label: "Overlay", controls: [ toggle("Open", { value: true, change: (on: boolean): void => { // `open()` builds the DOM and registers the sampler; `close()` disposes the DOM. // The sampler stays registered afterwards and returns on its first line, because // core's scheduler has `registerSystem` and no `unregisterSystem`. if (on) { app.devtools.open(); } else { app.devtools.close(); } }, }), ...PANELS.slice(0, SHORTCUTS).map((name: DevtoolsPanelName) => button(`Show ${name}`, (): void => { app.devtools.panel(name).show(); }), ), ], }, { label: "Gameplay", controls: [ slider("Wave length", { min: 1, max: 12, step: 0.5 }, bind(director, "waveSeconds")), slider( "Drone speed", { min: 0, max: 120, step: 4 }, { value: START_SPEED, change: (speed: number): void => { for (const drone of app.world.components(Drone)) { drone.speed = speed; } }, }, ), toggle("Key light casts", bind(rig.key.shadows, "enabled")), button("Next wave", (): void => { director.advanceWave(); }), ], }, { label: "app.diagnostics", collapsed: true, controls: [ readout("game/wave", (): string => director.read("wave")), readout("game/spawned", (): string => director.read("spawned")), readout("game/alive", (): string => director.read("alive")), readout("game/score", (): string => director.read("score")), readout("Scripts updated", (): string => String(app.diagnostics.frame.scriptsUpdated)), ], }, ], }); },});/** * The gameplay the overlay watches: a wave counter with four serialized fields, published once a * frame into a counter group on `app.diagnostics`. * * @remarks * It is the shape `skills/ignifx/references/recipes/show-diagnostics-in-devtools.md` recommends, * and the reason it is that shape is the per-frame path. A counter group is a **fixed list of names * over a numeric array**: register it once, resolve each name to an index once in `awake`, then * `set` by index in `update`. No string is hashed and nothing is allocated after the first frame. * * `groupOrRegister` rather than `registerGroup`, because a second registration of the same name is * `IGX-1503` and a scene reload runs `awake` again. * * The fields are declared with `Script.define`, which is what puts them in the devtools Inspector * as editable rows: `wave` typed into the overlay is `wave` in the next frame's `update`. That is * the whole reason this class has fields rather than private state. */import { f32, i32, Script } from "ignifx";import type { DiagnosticsGroup, ScriptCallbacks } from "ignifx";/** The counter group's name, as `app.diagnostics.group("game")` asks for it. */export const COUNTER_GROUP = "game";/** The counters, in the order their indices are resolved. */export const COUNTER_NAMES = ["wave", "spawned", "alive", "score"] as const;/** One of {@link COUNTER_NAMES}. */export type CounterName = (typeof COUNTER_NAMES)[number];/** How many drones a wave is worth, so the numbers on screen mean something. */const SPAWNS_PER_WAVE = 7;/** * A wave director: four numbers a game would have anyway, on the clock and in the diagnostics * table. * * @example * ```ts * const stage = app.world.createEntity("Director"); * const director = stage.addComponent(Director); * app.devtools.select(stage); * ``` */export class Director extends Script.define({ wave: i32(1, { min: 1, tooltip: "Which wave is running." }), spawned: i32(0, { min: 0, tooltip: "How many drones have been spawned in total." }), alive: i32(SPAWNS_PER_WAVE, { min: 0, tooltip: "How many are still flying." }), score: i32(0, { min: 0, tooltip: "Points banked." }), waveSeconds: f32(6, { min: 0.5, tooltip: "How long one wave lasts, in seconds." }), }) implements ScriptCallbacks{ /** The namespaced registration id. */ static typeId = "devtools/Director"; /** The counter group, resolved once. */ #group: DiagnosticsGroup | null = null; /** The counters' indices, in {@link COUNTER_NAMES} order. */ readonly #indices: number[] = []; /** Seconds left in the current wave. */ #remaining = 0; /** Registers the counter group and resolves its indices — once, not per frame. */ awake(): void { const group = this.app.diagnostics.groupOrRegister(COUNTER_GROUP, [...COUNTER_NAMES]); this.#group = group; this.#indices.length = 0; for (const name of COUNTER_NAMES) { this.#indices.push(group.index(name)); } this.#remaining = this.waveSeconds; this.spawned = SPAWNS_PER_WAVE; } /** * Runs the wave clock and writes the four counters. * * @param dt - Seconds since the previous frame, scaled by `time.timeScale`. Under `?static=1` * the scale is zero, so the wave holds where it was authored and the overlay's own refresh — * which runs on the unscaled clock — keeps drawing it. */ update(dt: number): void { this.#remaining -= dt; if (this.#remaining <= 0) { this.advanceWave(); } else if (this.alive > 0 && dt > 0) { // A wave thins out across its length rather than in one step, so the numbers on the overlay // move while you watch them. const share = Math.floor((1 - this.#remaining / Math.max(this.waveSeconds, 0.5)) * SPAWNS_PER_WAVE); const cleared = Math.max(0, Math.min(SPAWNS_PER_WAVE, share)); const left = SPAWNS_PER_WAVE - cleared; this.score += Math.max(0, this.alive - left) * 10; this.alive = left; } const group = this.#group; if (group === null) { return; } group.set(this.#indices[0] ?? 0, this.wave); group.set(this.#indices[1] ?? 0, this.spawned); group.set(this.#indices[2] ?? 0, this.alive); group.set(this.#indices[3] ?? 0, this.score); } /** Starts the next wave: what the panel's button and the wave clock both call. */ advanceWave(): void { this.wave += 1; this.alive = SPAWNS_PER_WAVE; this.spawned += SPAWNS_PER_WAVE; this.score += 100; this.#remaining = this.waveSeconds; } /** * Reads one counter back out of the diagnostics table. * * @remarks * Through the group rather than off the field, on purpose: it is the same read a HUD, a test or * the Stats panel would do, and it proves the number reached the table. * * @param name - Which counter. * @returns The value, or `"—"` before `awake` has run. */ read(name: CounterName): string { const group = this.#group; if (group === null) { return "—"; } return String(group.get(group.index(name))); }}/** * The scene the overlay watches: a pylon and a ring of drones that keep moving, so the Stats * panel's numbers change and the Timeline graph has something to draw. * * @remarks * A separate file for the reason `pbr-model/shot.ts` is: none of it is a lesson about devtools. * `main.ts` is then the settings section that opens the overlay, the selection it opens pointed at, * and the panel that drives both. * * The loop at the bottom is exactly what the overlay's **Scene** tab shows: one entity per drone, * named, under the world's root. Selecting a row there points the Inspector at that drone, and the * Inspector's rows are this file's `Script.define` fields. */import { createMaterialAsset, f32, i32, MeshAsset, MeshRenderer, pbrMaterialDefinition, Script } from "ignifx";import type { App, ScriptCallbacks } from "ignifx";/** How many drones orbit the pylon. */export const DRONES = 7;/** The speed a drone starts at, in degrees per second, before its per-index stagger. */export const START_SPEED = 36;/** Carries one drone around the centre. */export class Drone extends Script.define({ index: i32(0, { min: 0, tooltip: "Which drone this is; sets its phase around the ring." }), speed: f32(START_SPEED, { min: 0, tooltip: "Degrees around the ring per second." }), radius: f32(1.35, { min: 0.1, tooltip: "How far out the drone flies, in metres." }), }) implements ScriptCallbacks{ /** The namespaced registration id. */ static typeId = "devtools/Drone"; /** How far round the circle this drone has travelled, in degrees. */ #angle = 0; /** * Advances the orbit. * * @param dt - Seconds since the previous frame, scaled — so `?static=1` holds the ring still * while the overlay keeps refreshing, because the overlay runs on the **unscaled** clock. That * is what makes this example's capture reproducible with the overlay up. */ update(dt: number): void { this.#angle += this.speed * dt; const phase = ((this.index / DRONES) * 360 + this.#angle) * (Math.PI / 180); this.transform.localPosition.set( Math.cos(phase) * this.radius, 0.5 + Math.sin(phase * 2) * 0.22, Math.sin(phase) * this.radius, ); }}/** * Builds the pylon and the ring. * * @remarks * `app.registerComponents([Drone])` is the caller's job, because a game registers every component * it uses in one place. * * @param app - The app the entities and the assets belong to. * * @example * ```ts * app.registerComponents([Drone]); * createRing(app); * ``` */export function createRing(app: App): void { const shell = createMaterialAsset( app, pbrMaterialDefinition({ name: "devtools/shell", baseColor: { r: 0.3, g: 0.42, b: 0.55, a: 1 }, metallic: 0.5, roughness: 0.32, }), [], ); const core = createMaterialAsset( app, pbrMaterialDefinition({ name: "devtools/core", baseColor: { r: 0.18, g: 0.2, b: 0.24, a: 1 }, metallic: 0.2, roughness: 0.55, emissive: { r: 0.55, g: 0.24, b: 0.06, a: 1 }, }), [], ); const pylon = app.world.createEntity("Pylon"); pylon.transform.localPosition.set(0, 0.42, 0); pylon.addComponent(MeshRenderer, { mesh: MeshAsset.cylinder(app, { height: 0.84, diameterTop: 0.24, diameterBottom: 0.44, tessellation: 24 }), materials: [core], castShadows: true, }); // One mesh shared by every drone, one entity each. const droneMesh = MeshAsset.capsule(app, { height: 0.34, radius: 0.09, tessellation: 12 }); for (let index = 0; index < DRONES; index += 1) { const drone = app.world.createEntity(`Drone ${String(index + 1)}`); drone.addComponent(MeshRenderer, { mesh: droneMesh, materials: [shell], castShadows: true }); drone.addComponent(Drone, { index, speed: START_SPEED + index * 3 }); }}