ignifx
All examples

Side-scrolling 2D game

Templates2D

  • Keyboard
  • Gamepad
  • Touch
  • Mouse

A playable starting point for a platformer. Jump across slopes and platforms, collect coins and drop through wooden planks. The template includes scrolling backgrounds, a pixel-perfect camera, menus, settings and saves.

A pixel-art dusk landscape: a hill and a treeline behind a grass-topped dirt tilemap, a wooden plank and a brick platform with gold coins hanging above them, the character standing on the left, and a chip in the top corner that reads No coins.
Open standalone

` devtools

WebGPU: checking…

Try this

  • Run with WASD or the arrows and jump with Space; taking a coin scores a point and autosaves.
  • Hold S or the down arrow and press Space on one of the planks to drop through it.
  • Press Escape for the pause menu, then Settings and Rebind controls, and give jump a new key.
Show source code

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 {  Camera2D,  Camera2DFollow,  ParallaxLayer,  spawnTilemapObjects,  SPRITE_ANIMATION_ASSET_TYPE,  SPRITE_ATLAS_ASSET_TYPE,  SpriteAnimator,  SpriteRenderer,  Tilemap,  TILEMAP_ASSET_TYPE,  TilemapRenderer,  twoD,} from "@ignifx/2d";import { audio, AUDIO_ASSET_TYPE, AUDIO_BUSES_ASSET_TYPE, AudioListener, AudioSource } from "@ignifx/audio";import { createApp, isIgnifxError, Vec2 } from "@ignifx/core";import { electron } from "@ignifx/electron";import { INPUT_ACTIONS_ASSET_TYPE, input } from "@ignifx/input";import { CharacterController2D, CircleCollider2D, physics2d, TilemapCollider2D } from "@ignifx/physics-2d";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 { installGameplayProbe } from "./gameplay-probe.js";import { createGameMenus } from "./menus/game-menus.js";import { applySettings, loadInputOverrides, loadSettings } from "./menus/settings-store.js";import { createRun } from "./run.js";import { Collectible } from "./scripts/collectible.js";import { HudLine } from "./scripts/hud-line.js";import { MenuController } from "./scripts/menu-controller.js";import { PlatformerController } from "./scripts/platformer-controller.js";import { SaveGame } from "./scripts/save-game.js";import type { GameMenus } from "./menus/game-menus.js";import type { GraphicsHooks } from "./menus/settings-store.js";import type { Run } from "./run.js";import type { SpriteAnimationAsset, SpriteAtlasAsset, TileObjectContext, TilemapAsset } from "@ignifx/2d";import type { AudioBusesAsset, AudioClip } from "@ignifx/audio";import type { App, AssetHandle, Entity } from "@ignifx/core";import type { InputActionsAsset } from "@ignifx/input";import type { LocaleAsset } from "@ignifx/ui";/** * A pixel-perfect 2D side-scroller: three parallax bands, a tilemap with slopes and one-way * platforms, collectible coins, and a reference platformer controller. * * The build order is the same as every ignifx game's — create the app, load and **await** every * asset before `app.start()`, build the world, start — and the interesting parts are: * * - `Camera2D.pixelPerfect` with a `referenceResolution` of 320 by 180. The camera snaps its zoom *   to a whole number and its position to the pixel grid, so one source texel always covers an *   exact square of screen pixels. It does **not** change how the atlas is sampled: that is *   `"sampling": "nearest"` in each `.atlas.json`, because a texture's sampler is fixed at upload. * - One `ParallaxLayer` per band. The component slows a whole *sorting layer* down, which is why *   `ignifx.config.ts` declares `Sky`, `Hills` and `Trees` as separate layers. * * ## Query flags * * - `?static=1` stops the clock before the first frame and leaves the front end out, so the picture *   is exactly the authored scene. It is what the visual golden suite in `tests/visual/` opens. * - `?hud=1` keeps the DOM overlay visible in a `?static=1` scene, which is what the gallery *   capture script uses. * - `?probe=1` installs `window.__ignifxGameplay`, the read-only test hook the gameplay half of *   `tests/visual/tests/templates.spec.ts` measures the character with. See `src/gameplay-probe.ts`. * - `?locale=<tag>` picks a locale from `assets/strings.i18n.json` 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 map is 64 by 20 cells of one metre, and the camera may not leave it. */const LEVEL_SIZE = { x: 64, y: 20 };/** The design resolution the pixel-perfect camera fits a whole-number zoom to. */const REFERENCE_RESOLUTION = { x: 320, y: 180 };/** * One parallax band: which sorting layer it owns, which atlas frame it draws, how much of the * camera's motion it follows, and where it sits. * * A factor of `1` means "moves with the world"; `0` means "pinned to the screen", which is what a * sky wants. The bands in between are what produce the sense of depth. */const BANDS = [  { layer: "Sky", frame: "sky", factor: { x: 0, y: 0 }, y: 0, copies: 1, span: 40 },  { layer: "Hills", frame: "hills", factor: { x: 0.25, y: 1 }, y: 6, copies: 5, span: 20 },  { layer: "Trees", frame: "trees", factor: { x: 0.5, y: 1 }, y: 1.75, copies: 5, span: 20 },] as const;/** Explicit loader types for the template assets. */const ATLAS = { type: SPRITE_ATLAS_ASSET_TYPE } as const;const CLIPS = { type: SPRITE_ANIMATION_ASSET_TYPE } as const;const MAP = { type: TILEMAP_ASSET_TYPE } as const;const ACTIONS = { type: INPUT_ACTIONS_ASSET_TYPE } as const;const CLIP = { type: AUDIO_ASSET_TYPE } as const;const BUSES = { type: AUDIO_BUSES_ASSET_TYPE } as const;const STRINGS = { type: I18N_ASSET_TYPE } as const;/** Everything the world is built from. */interface Assets {  readonly tiles: AssetHandle<SpriteAtlasAsset>;  readonly heroAtlas: AssetHandle<SpriteAtlasAsset>;  readonly heroClips: AssetHandle<SpriteAnimationAsset>;  readonly coinAtlas: AssetHandle<SpriteAtlasAsset>;  readonly coinClips: AssetHandle<SpriteAnimationAsset>;  readonly parallax: AssetHandle<SpriteAtlasAsset>;  readonly level: AssetHandle<TilemapAsset>;  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>;}/** What {@link buildWorld} produced. */interface World {  /** The character. */  readonly player: Entity;  /** Where the character starts. */  readonly spawn: Vec2;  /** Every coin the objects layer spawned. */  readonly coins: readonly Collectible[];}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;});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 three parallax bands. * * @param app - The running app. * @param assets - The loaded assets. */function buildParallax(app: App, assets: Assets): void {  const atlas = assets.parallax.value;  for (const band of BANDS) {    const controller = app.world.createEntity(`Parallax ${band.layer}`);    controller.addComponent(ParallaxLayer, {      sortingLayer: band.layer,      factor: band.factor,      // Wrapping the offset by one repetition is what stops a distant band from drifting away      // from the camera — and what keeps the number in single-precision range on a long level.      repeatX: band.copies > 1,      repeatWidth: band.span,    });    // The component offsets the layer; it does not duplicate sprites. Enough copies are placed by    // hand to cover the widest view plus one repetition on each side.    const first = -Math.floor(band.copies / 2);    for (let index = 0; index < band.copies; index += 1) {      const strip = app.world.createEntity(`${band.layer} ${String(index)}`);      strip.transform.position2D = new Vec2((first + index) * band.span, band.y);      // `frame` is a runtime property rather than a schema field, so it is assigned after the      // component exists; passing it to `addComponent` is `IGX-0607`.      strip.addComponent(SpriteRenderer, {        sprite: assets.parallax.retain(),        sortingLayer: band.layer,      }).frame = atlas.requireFrame(band.frame);    }  }}/** * Registers the factories the tilemap's objects layer names. * * @param app - The running app. * @param assets - The loaded assets. * @param coins - The list every spawned coin is appended to. * @returns A function that answers with the player entity and its spawn point once the map has been *   walked. */function registerObjectFactories(  app: App,  assets: Assets,  coins: Collectible[],): () => { readonly player: Entity | null; readonly spawn: Vec2 } {  let player: Entity | null = null;  let spawn = new Vec2();  const pickup = app.world.layers.requireIndex("Pickup");  app.twoD.registerTileObjectFactory("spawn", (context: TileObjectContext): Entity => {    const entity = app.world.createEntity(context.name);    entity.layer = app.world.layers.requireIndex("Player");    spawn = new Vec2(context.position.x, context.position.y);    entity.transform.position2D = new Vec2(spawn.x, spawn.y);    entity.addComponent(SpriteRenderer, { sprite: assets.heroAtlas.retain(), sortingLayer: "Default" });    entity.addComponent(SpriteAnimator, {      animations: assets.heroClips.retain(),      defaultClip: "idle",      playOnAwake: true,    });    // `shape: "box"` is not a style choice: with the default capsule of radius 0.2 the autostep    // clears about 0.15 m, and a box is what actually gets `stepOffset` metres of step    // (ADR-0006 Validation). A stair-climbing character needs the box.    entity.addComponent(CharacterController2D, {      shape: "box",      radius: 0.28,      height: 0.9,      offset: { x: 0, y: 0.45 },      slopeLimit: 50,      stepOffset: 0.3,      snapToGround: 0.25,      onOneWayPlatforms: true,    });    const controller = entity.addComponent(PlatformerController);    // The clips are handed over rather than loaded inside the script, so a hot-reloaded script    // does not re-request a file the asset service has already delivered.    controller.footstep = clipOrNull(assets.footstep) === null ? null : assets.footstep;    controller.jumpSound = clipOrNull(assets.jump) === null ? null : assets.jump;    controller.landSound = clipOrNull(assets.land) === null ? null : assets.land;    player = entity;    return entity;  });  app.twoD.registerTileObjectFactory("coin", (context: TileObjectContext): Entity => {    const entity = app.world.createEntity(context.name);    entity.layer = pickup;    entity.transform.position2D = new Vec2(context.position.x, context.position.y);    entity.addComponent(SpriteRenderer, { sprite: assets.coinAtlas.retain(), sortingLayer: "Default" });    entity.addComponent(SpriteAnimator, {      animations: assets.coinClips.retain(),      defaultClip: "spin",      playOnAwake: true,    });    entity.addComponent(CircleCollider2D, { radius: 0.35, isTrigger: true });    const coin = entity.addComponent(Collectible);    if (clipOrNull(assets.pickup) !== null) {      coin.clip = assets.pickup;    }    coins.push(coin);    return entity;  });  return () => ({ player, spawn });}/** * Builds the world. * * @param app - The running app. * @param assets - The loaded assets. * @returns The player entity. */function buildWorld(app: App, assets: Assets): World {  buildParallax(app, assets);  const coins: Collectible[] = [];  const take = registerObjectFactories(app, assets, coins);  const level = app.world.createEntity("Level");  level.layer = app.world.layers.requireIndex("Terrain");  const map = level.addComponent(Tilemap, { map: assets.level.retain(), chunkSize: 16 });  level.addComponent(TilemapRenderer, { atlas: assets.tiles.retain(), cullChunks: true });  // Solid tiles become merged outlines; a `oneWay` tile contributes only its top edge, which is  // what `CharacterController2D.onOneWayPlatforms` collides against.  level.addComponent(TilemapCollider2D).collisionData = map.collisionData;  spawnTilemapObjects(app, app.twoD, map);  const spawned = take();  const player = spawned.player;  if (player === null) {    throw new Error('level.tilemap.json has no object of type "spawn".');  }  const eye = app.world.createEntity("Main Camera");  eye.transform.position2D = new Vec2(player.transform.position2D.x, player.transform.position2D.y + 1.5);  eye.addComponent(Camera2D, {    // `orthographicSize` is ignored while `pixelPerfect` is on: the zoom comes from    // `viewportHeight / referenceResolution.y`, snapped to a whole number.    pixelPerfect: true,    referenceResolution: REFERENCE_RESOLUTION,    follow: player,    followDamping: 0.1,    followOffset: { x: 0, y: 1.5 },    deadZone: { x: 1.2, y: 1.5 },    boundsMin: { x: 0, y: 0 },    boundsMax: LEVEL_SIZE,  });  eye.addComponent(Camera2DFollow);  // The ears ride the camera, so a sound is panned from where the player is looking.  eye.addComponent(AudioListener);  return { player, spawn: spawned.spawn, coins };}/** * 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 that answers with the run once the front end is up. */async function installFrontEnd(  app: App,  assets: Assets,  world: World,  hud: HTMLDivElement | null,  isBench: boolean,): Promise<Run> {  // A 2D sprite scene has no shadow-casting light and no post-process chain, so the settings  // screen leaves both graphics rows out rather than offering a switch that does nothing.  const graphics: GraphicsHooks = {    supportsShadows: false,    supportsPostProcessing: false,    setShadows: (): void => {      // No shadows in a sprite scene.    },    setPostProcessing: (): void => {      // No post-process chain in a sprite scene.    },  };  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.coins, world.spawn, (): 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: "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", { score: run.score() });  // 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 frame-time harness measures a *running* game, so it skips the title screen. Everything    // else is the scene a player gets.    return run;  }  // 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".  menus.show("title");  app.pause();  return run;}async function main(): Promise<AppStatus> {  const canvas = document.querySelector("#game");  if (!(canvas instanceof HTMLCanvasElement)) {    throw new Error('2d-sidescroller 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 },      // `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(), twoD(), physics2d(), input(), audio(), ui()],    });  } catch (error) {    // IGX-0701: the browser has no WebGPU, and ignifx has no fallback renderer (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 three kilobytes, 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;  }  // The overlay comes up before the first asset is requested, so the loading bar sees every byte.  const gameUi = createGameUi(    app,    app.i18n.t("loading.label"),    [{ control: "jump", label: "" }],    !isStatic && hasTouch(),  );  app.ui.visible = showOverlay;  const assets: Assets = {    tiles: app.assets.load<SpriteAtlasAsset>("tiles.atlas.json", ATLAS),    heroAtlas: app.assets.load<SpriteAtlasAsset>("hero.atlas.json", ATLAS),    heroClips: app.assets.load<SpriteAnimationAsset>("hero.spriteanim.json", CLIPS),    coinAtlas: app.assets.load<SpriteAtlasAsset>("coin.atlas.json", ATLAS),    coinClips: app.assets.load<SpriteAnimationAsset>("coin.spriteanim.json", CLIPS),    parallax: app.assets.load<SpriteAtlasAsset>("parallax.atlas.json", ATLAS),    level: app.assets.load<TilemapAsset>("level.tilemap.json", MAP),    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.tiles.promise,    assets.heroAtlas.promise,    assets.heroClips.promise,    assets.coinAtlas.promise,    assets.coinClips.promise,    assets.parallax.promise,    assets.level.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 world = buildWorld(app, assets);  if (isStatic) {    // No fixed step ever runs, so nothing falls and nothing animates: the frame is exactly what    // was authored, which is what a golden needs.    app.time.timeScale = 0;  } else {    const run = await installFrontEnd(app, assets, world, gameUi.hud, isBench);    // A read-only test hook, and only under `?probe=1`: the gameplay half of the template spec    // measures the character in metres rather than in pixels. See `src/gameplay-probe.ts`.    if (flags.get("probe") === "1") {      installGameplayProbe(app, world.player, run);    }  }  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", { score: 0 });  }  gameUi.loading.hide();  if (isBench) {    installFrameTimeProbe(app);  }  await app.start();  await settle(SETTLE_FRAMES);  app.log.info("2d-sidescroller running; sprites:", app.twoD.spriteCount);  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/scripts/platformer-controller.ts
import { SpriteAnimator, SpriteRenderer } from "@ignifx/2d";import { bool, f32, Script, Vec2 } from "@ignifx/core";import { CharacterController2D } from "@ignifx/physics-2d";import type { AudioClip } from "@ignifx/audio";import type { AssetHandle, MutableVec2, ScriptCallbacks, Vec2Like } from "@ignifx/core";import type { InputAction } from "@ignifx/input";import type { CharacterCollision2D } from "@ignifx/physics-2d";/** The probe box used to tell a thin platform from real ground, in metres. */const PROBE_SIZE: Vec2Like = { x: 0.3, y: 0.3 };/** Degrees to radians, for reading `slopeLimit` as an angle. */const DEGREES_TO_RADIANS = Math.PI / 180;/** * How sideways an *unwalkable* contact normal has to be before the surface is a candidate wall * rather than a ledge the character clipped a corner of. A brick's normal is 1 across. */const WALL_NORMAL_X = 0.5;/** How far down a contact normal has to point before the surface counts as a ceiling. */const CEILING_NORMAL_Y = -0.5;/** The slope limit assumed if the controller has gone away mid-frame, in degrees. */const DEFAULT_SLOPE_LIMIT = 45;/** * How far a ground normal has to lean before the run is rotated onto it. Flat ground reports * exactly `(0, 1)`, so this only has to be above the noise in a merged tilemap outline. */const SLOPE_NORMAL_X = 0.01;/** * How much of the requested horizontal speed a step has to lose before an unwalkable contact * counts as a wall that stopped the character rather than one it brushed past. */const WALL_STALL_RATIO = 0.5;/** * The reference platformer controller: coyote time, a jump buffer, a variable jump height, and * drop-through on one-way platforms. * * ## Why the input is read in `update` and the movement happens in `fixedUpdate` * * A `CharacterController2D` only moves when `move()` is called from a fixed step, and a frame may * carry zero, one or two of those. `wasPressedThisFrame`, on the other hand, is true for exactly * one *frame*. Reading it inside `fixedUpdate` therefore either misses a press (no step this * frame) or acts on it twice (two steps). So the frame captures intent — "jump was pressed", "the * stick is at x = -0.8" — and the fixed step consumes it. * * ## Why gravity is here rather than in the physics settings * * `physics2d`'s `gravity` accelerates *rigid bodies*. A character controller is kinematic: it goes * exactly where `move()` says. That is a feature — it is what lets this script use one gravity * going up and a stronger one coming down, which is the oldest trick in the genre and the reason * a jump feels "snappy" rather than floaty. * * ## Why walls and ceilings are read from contact normals * * The obvious test — "the controller moved less than I asked for, so I hit something" — is wrong on * a slope. Collide-and-slide on a 45-degree surface legitimately returns about half the requested * horizontal motion, so a controller that zeroes its stored speed on that reading re-accelerates * from a standstill every fixed step and the character crawls up the hill at a fraction of its run * speed (measured 2026-09-08: 0.5 m/s against a designed 7). `CharacterController2D.onCollided` * reports the *geometry* instead — a normal per obstacle the move touched — and a normal sorts the * contact: pointing down is a ceiling, walkable is the surface the run is rotated onto, and too * steep to stand on *and* sideways is a candidate wall, confirmed by the short-move test the normal * has already kept slopes out of. The hits land after `fixedUpdate` (the step system runs at * `FixedUpdate +100`), so each step consumes the previous step's contacts, which is the same * one-step lag `controller.velocity` already has. * * ## Why a tap and a hold both give a usable jump * * Multiplying the rising velocity by a "cut" factor on *every* step the button is up compounds: * the same 16 m/s launch reached 0.41 m when the button was released after one frame and 3.42 m * when it was held (measured 2026-09-08), which is not a variable jump height so much as a * lottery. The genre's answer is to apply the cut **once**, on the release edge, as a clamp: the * rise is capped at whatever `minJumpHeight` needs and never below it, so the shortest possible tap * still clears a tile and a held button still reaches the top of the arc. */export class PlatformerController  extends Script.define({    /** Top running speed, in metres per second. */    speed: f32(7),    /** How fast the run reaches top speed on the ground, in metres per second squared. */    groundAcceleration: f32(70),    /** The same in the air, where a smaller number means less control. */    airAcceleration: f32(35),    /** The upward speed a jump starts at, in metres per second. */    jumpSpeed: f32(16),    /** Downward acceleration while rising, in metres per second squared. */    riseGravity: f32(36),    /** Downward acceleration while falling; larger than `riseGravity` on purpose. */    fallGravity: f32(52),    /** The fastest the character may fall, in metres per second. */    terminalVelocity: f32(24),    /** How long after walking off a ledge a jump still works, in seconds. */    coyoteTime: f32(0.1),    /** How long before landing a jump press is remembered, in seconds. */    jumpBuffer: f32(0.12),    /**     * The rise a *tapped* jump is guaranteed to reach, in metres — a little over one tile.     * Releasing the button early clamps the climb to this once; holding it gives the whole     * `jumpSpeed` arc.     */    minJumpHeight: f32(1.1),    /**     * Below this height, in metres, the character has fallen out of the level and is put back on     * the last ground it stood on. The map's floor is `y = 0` and its two pits are bottomless.     */    fallLimit: f32(-3),    /** How far below its feet a drop-through moves the character, in metres. */    dropClearance: f32(0.45),    /**     * How far below the feet the "is this thin enough to drop through?" probe looks, in metres.     * Anything solid there means the character is standing on ground, not on a platform.     */    dropProbe: f32(0.5),    /** The downward speed a drop-through starts at, in metres per second. */    dropSpeed: f32(6),    /** Whether the sprite is mirrored when running left. */    flipSprite: bool(true),  })  implements ScriptCallbacks{  static typeId = "sidescroller/PlatformerController";  /** The footstep clip, or `null`. Assigned when the character is spawned. */  footstep: AssetHandle<AudioClip> | null = null;  /** The jump clip, or `null`. Assigned when the character is spawned. */  jumpSound: AssetHandle<AudioClip> | null = null;  /** The landing clip, or `null`. Assigned when the character is spawned. */  landSound: AssetHandle<AudioClip> | null = null;  /** Whether the character was on the ground at the end of the previous fixed step. */  #wasGrounded = true;  #controller: CharacterController2D | null = null;  #animator: SpriteAnimator | null = null;  #sprite: SpriteRenderer | null = null;  #move: InputAction | null = null;  #jump: InputAction | null = null;  /** The character's velocity in metres per second; the script owns it, not the controller. */  readonly #velocity: MutableVec2 = new Vec2();  /** The displacement handed to `move()`. Reused so the fixed step allocates nothing. */  readonly #step: MutableVec2 = new Vec2();  /** This frame's horizontal stick or key value, in `[-1, 1]`. */  #wishX = 0;  /** Whether the stick is pushed far enough down to mean "drop through". */  #wishDown = false;  /** Seconds left of the buffered jump press, or `0`. */  #buffered = 0;  /** Seconds left of coyote time, or `0`. */  #coyote = 0;  /** Whether the jump button is still held; releasing it early cuts the rise. */  #jumpHeld = false;  /** Whether the character is on the way up from a jump this script started. */  #rising = false;  /** Whether the rise of the current jump has already been clamped, so it is clamped only once. */  #cut = false;  /**   * Which way a wall blocked the character on the previous fixed step: `1` for a wall on its   * right, `-1` for one on its left, `0` for neither. Written from `onCollided`.   */  #blocked = 0;  /** Whether the previous fixed step ended against a ceiling. Written from `onCollided`. */  #ceiling = false;  /**   * The most upward-facing walkable normal the previous fixed step touched, or `(0, 1)` when it   * touched nothing. This is the surface the run is rotated onto.   *   * @remarks   * `CharacterController2D.groundNormal` is not used for this: it keeps the last normal it was   * given, so at the crest of a hill it still reads as the slope and the run rotates itself off   * the flat ground it has just reached, one launch per step.   */  readonly #surface: MutableVec2 = new Vec2(0, 1);  /** The `y` of {@link PlatformerController.#surface}, so the best of a step's normals wins. */  #surfaceY = -2;  /** The last place the character stood, which is where a fall out of the level puts it back. */  readonly #safeGround: MutableVec2 = new Vec2();  /** Scratch for the drop-through probe, so the press path allocates nothing of its own. */  readonly #probe: MutableVec2 = new Vec2();  /** The clip that is playing, so `play` is called only when it changes. */  #clip = "";  /**   * The character's velocity, in metres per second.   *   * @remarks   * The script owns this, not the controller: a kinematic character has no velocity of its own,   * and `CharacterController2D.velocity` reports what the *last* step resolved rather than what   * this one intends.   *   * @returns A live view; copy it if you keep it.   */  get velocity(): Vec2Like {    return this.#velocity;  }  /**   * Where a fall out of the level puts the character back: the last ground it stood on, or its   * spawn until it has stood anywhere.   *   * @returns A live view; copy it if you keep it.   */  get respawnPoint(): Vec2Like {    return this.#safeGround;  }  awake(): void {    const controller = this.entity.requireComponent(CharacterController2D);    this.#controller = controller;    this.#animator = this.entity.getComponent(SpriteAnimator);    this.#sprite = this.entity.getComponent(SpriteRenderer);    const here = this.transform.position2D;    this.#safeGround.set(here.x, here.y);    // One handler per obstacle the move touched, raised from the step system at `FixedUpdate +100`    // — after this script's `fixedUpdate`. Each normal is classified here and thrown away rather    // than kept: the event object and its vectors are reused for every hit of every step.    controller.onCollided.connect(      (hit: CharacterCollision2D): void => {        this.#noteContact(hit.normal);      },      { owner: this },    );    const actions = this.app.input.actions;    this.#move = actions.find("move");    this.#jump = actions.find("jump");    // The two `footstep` markers in `hero.spriteanim.json`'s run clip are what time the sound, so a    // step lands on the frame the foot lands on rather than on a timer of our own.    this.#animator?.onEvent.connect(      (name: string): void => {        if (name === "footstep") {          this.#playClip(this.footstep, 0.45);        }      },      { owner: this },    );  }  update(dt: number): void {    const move = this.#move;    const vector = move === null ? null : move.vector;    this.#wishX = vector === null ? 0 : vector.x;    this.#wishDown = vector !== null && vector.y < -0.5;    const jump = this.#jump;    this.#jumpHeld = jump !== null && jump.isPressed;    if (jump?.wasPressedThisFrame === true) {      this.#buffered = this.jumpBuffer;    } else if (this.#buffered > 0) {      this.#buffered = Math.max(0, this.#buffered - dt);    }    this.#animate();  }  fixedUpdate(dt: number): void {    const controller = this.#controller;    if (controller === null) {      return;    }    const here = this.transform.position2D;    if (here.y < this.fallLimit) {      // Both pits in `level.tilemap.json` are bottomless, so without this the character falls for      // ever behind a camera clamped at the level's lower bound.      this.respawn();      return;    }    const grounded = controller.isGrounded;    if (grounded && !this.#wasGrounded) {      this.#playClip(this.landSound, 0.5);    }    this.#wasGrounded = grounded;    this.#coyote = grounded ? this.coyoteTime : Math.max(0, this.#coyote - dt);    if (grounded) {      // Standing anywhere makes that spot the place a fall puts the character back, which is the      // lip of the pit it just ran off rather than the far side of the level.      this.#safeGround.set(here.x, here.y);      this.#rising = false;    }    this.#applyContacts(controller);    this.#accelerate(dt, grounded);    if (this.#buffered > 0 && this.#coyote > 0) {      this.#buffered = 0;      if (grounded && this.#wishDown && this.#dropThrough(controller)) {        this.#velocity.y = -this.dropSpeed;      } else {        this.#coyote = 0;        this.#velocity.y = this.jumpSpeed;        this.#rising = true;        this.#cut = false;        this.#playClip(this.jumpSound, 0.55);      }    }    // The whole of "variable jump height", applied **once** per jump: the first step in which the    // button is no longer held clamps the climb to what `minJumpHeight` needs. A clamp rather than    // a multiplier is what makes the shortest tap and a one-frame-longer tap land in the same    // place, and it can only ever shorten a jump — a button released near the apex finds the rise    // already below the clamp and nothing happens.    if (this.#rising && !this.#cut && !this.#jumpHeld) {      this.#cut = true;      this.#velocity.y = Math.min(this.#velocity.y, this.#shortJumpSpeed());    }    if (this.#velocity.y <= 0) {      this.#rising = false;    }    const gravity = this.#velocity.y > 0 ? this.riseGravity : this.fallGravity;    this.#velocity.y = Math.max(-this.terminalVelocity, this.#velocity.y - gravity * dt);    if (controller.isGrounded && this.#velocity.y < 0) {      // Standing still on the ground with a growing downward velocity would defeat `snapToGround`      // on the way down a slope, so it is parked at a small negative value instead of zero.      // `isGrounded` is re-read because a drop-through has just moved the character off its floor.      this.#velocity.y = -1;    }    this.#stepAlong(grounded, dt);    controller.move(this.#step);    // Everything the contact handler collects from here on belongs to the step Rapier is about to    // run, which the *next* `fixedUpdate` reads.    this.#forgetContacts();  }  /**   * Applies the previous fixed step's contacts to the stored velocity: a wall zeroes the run, a   * ceiling ends the rise.   *   * @remarks   * A wall has to be both things at once. A normal alone is not enough: the leading vertex of a   * slope reports a normal 56 degrees off vertical for one step as the box rides onto it, and   * treating that as a wall costs the character its whole run speed at the foot of every hill.   * A short move alone is not enough either, for the reason in the class comment. So the test is   * "the surface was too steep to stand on **and** the character barely moved along it", and the   * slope's leading vertex fails the second half — the step it reports on resolved the full   * 7 m/s.   *   * @param controller - The controller, for what the last step actually resolved.   */  #applyContacts(controller: CharacterController2D): void {    if (this.#blocked !== 0 && Math.sign(this.#velocity.x) === this.#blocked) {      const resolved = controller.velocity;      if (Math.abs(resolved.x) < Math.abs(this.#velocity.x) * WALL_STALL_RATIO) {        this.#velocity.x = 0;      }    }    if (this.#ceiling && this.#velocity.y > 0) {      this.#velocity.y = 0;      this.#rising = false;    }  }  /** Starts collecting contacts for the step that is about to run. */  #forgetContacts(): void {    this.#blocked = 0;    this.#ceiling = false;    this.#surface.set(0, 1);    this.#surfaceY = -2;  }  /**   * Turns the velocity into the displacement `move()` is given, rotated onto the ground the   * character is standing on.   *   * @remarks   * A horizontal request on a slope is not a horizontal move: collide-and-slide projects it onto   * the surface, and a 45-degree surface keeps only `cos 45` of its length — so a run that follows   * the ground rather than cutting across it is the difference between 3.3 m/s up the level's first   * hill and the designed 7 along it (measured 2026-09-08). Rotating the *whole* velocity, rather than   * only its horizontal half, is what keeps the small downward bias below `snapToGround` pointing   * into the surface instead of into the hill.   *   * A jump is left alone: `jumpSpeed` goes straight up on a slope as it does on the flat, which is   * what a player expects and what keeps the arc the level's coins were placed against.   *   * @param grounded - Whether the character is on the ground.   * @param dt - The fixed step, in seconds.   */  #stepAlong(grounded: boolean, dt: number): void {    const velocityX = this.#velocity.x * dt;    const velocityY = this.#velocity.y * dt;    const normal = this.#surface;    if (!grounded || this.#velocity.y > 0 || Math.abs(normal.x) <= SLOPE_NORMAL_X) {      this.#step.set(velocityX, velocityY);      return;    }    this.#step.set(normal.y * velocityX + normal.x * velocityY, normal.y * velocityY - normal.x * velocityX);  }  /**   * Puts the character back on the last ground it stood on and clears everything the fall left   * behind. Nothing is scored and nothing is saved: falling into a pit costs progress through the   * level, not the coins already taken.   */  respawn(): void {    const controller = this.#controller;    if (controller === null) {      return;    }    // Through the controller, not the transform: a character controller owns a kinematic body, and    // writing the transform under it leaves the body where it was until the next move.    controller.teleport({ x: this.#safeGround.x, y: this.#safeGround.y });    this.#velocity.set(0, 0);    this.#buffered = 0;    this.#coyote = 0;    this.#rising = false;    this.#cut = false;    this.#forgetContacts();    this.#wasGrounded = true;    this.#playClip(this.landSound, 0.6);  }  /**   * The rising speed a released jump is clamped to: exactly enough to climb `minJumpHeight`   * against `riseGravity`.   *   * @returns The speed, in metres per second.   */  #shortJumpSpeed(): number {    return Math.sqrt(2 * this.riseGravity * this.minJumpHeight);  }  /**   * Files one of the previous step's contact normals as a wall, a ceiling, or ground.   *   * @remarks   * The normal is the obstacle's outward normal, so a wall to the character's right reports   * `x = -1` and a ceiling reports `y = -1`. "Too steep to stand on" is the controller's own   * `slopeLimit`, which is what keeps a 45-degree slope — normal `y` of 0.707 against a limit of   * 50 degrees, or 0.643 — out of the wall case.   *   * @param normal - The obstacle's outward normal at the contact point.   */  #noteContact(normal: Vec2Like): void {    if (normal.y <= CEILING_NORMAL_Y) {      this.#ceiling = true;      return;    }    const limit = this.#controller?.slopeLimit ?? DEFAULT_SLOPE_LIMIT;    if (normal.y >= Math.cos(limit * DEGREES_TO_RADIANS)) {      // Walkable, so it is the surface the run is rotated onto. The most upward-facing one wins,      // which is what keeps a wall the character is also brushing out of the answer.      if (normal.y > this.#surfaceY) {        this.#surfaceY = normal.y;        this.#surface.set(normal.x, normal.y);      }      return;    }    if (Math.abs(normal.x) > WALL_NORMAL_X) {      // The obstacle's normal points away from its surface, so a wall the character runs into on      // its right pushes left. Whether it actually stopped the character is decided in      // `#applyContacts`.      this.#blocked = normal.x < 0 ? 1 : -1;    }  }  /**   * Plays one clip on the `SFX` bus, pitched a little differently each time so a run does not   * machine-gun.   *   * @param handle - The clip's handle, or `null` when the template did not load one.   * @param volume - The gain to play it at.   */  #playClip(handle: AssetHandle<AudioClip> | null, volume: number): void {    if (handle !== null && handle.state === "loaded") {      this.app.audio.playOneShot(handle.value, { volume, pitch: 0.94 + Math.random() * 0.12 });    }  }  /**   * Steps down off a one-way platform, if that is what the character is standing on.   *   * @remarks   * `CharacterController2D.onOneWayPlatforms` is not the switch it sounds like: leaving it on is   * what makes a platform passable from below, and turning it off makes it solid from *both*   * sides. The rule the runtime applies is "solid only while the character is descending and its   * feet are already at or above the platform's top", so the way down is to put the feet below   * that top — after which the platform stops existing for this character until it lands on it   * again.   *   * The probe is what keeps that from being a hole in the world: a box half a metre under the feet   * finds solid ground under solid ground, and finds nothing under a plank. `overlapBox` needs one   * completed fixed step behind it (`IGX-1153`), which by definition it has here.   *   * @param controller - The controller to move.   * @returns `true` when the character was moved down through a platform.   */  #dropThrough(controller: CharacterController2D): boolean {    const feet = this.transform.position2D;    this.#probe.set(feet.x, feet.y - this.dropProbe);    if (this.app.physics2d.overlapBox(this.#probe, PROBE_SIZE).length > 0) {      return false;    }    controller.teleport({ x: feet.x, y: feet.y - this.dropClearance });    return true;  }  /** Moves the horizontal velocity toward the wished-for speed at the right acceleration. */  #accelerate(dt: number, grounded: boolean): void {    const target = this.#wishX * this.speed;    const rate = (grounded ? this.groundAcceleration : this.airAcceleration) * dt;    const delta = target - this.#velocity.x;    this.#velocity.x += Math.abs(delta) <= rate ? delta : Math.sign(delta) * rate;  }  /** Chooses between idle, run, jump and fall, and mirrors the sprite. */  #animate(): void {    const sprite = this.#sprite;    if (sprite !== null && this.flipSprite && Math.abs(this.#wishX) > 0.05) {      sprite.flipX = this.#wishX < 0;    }    const animator = this.#animator;    const controller = this.#controller;    if (animator === null || controller === null) {      return;    }    const clip = controller.isGrounded      ? Math.abs(this.#velocity.x) > 0.4        ? "run"        : "idle"      : this.#velocity.y > 0        ? "jump"        : "fall";    if (clip !== this.#clip) {      this.#clip = clip;      animator.play(clip);    }  }}
src/scripts/collectible.ts
import { Script } from "@ignifx/core";import type { AudioClip } from "@ignifx/audio";import type { AssetHandle, ScriptCallbacks } from "@ignifx/core";import type { TriggerEvent2D } from "@ignifx/physics-2d";/** * Narrows the `unknown` a trigger callback receives. * * @remarks * `ScriptCallbacks` types the payload as `unknown` because `@ignifx/core` cannot depend on either * physics package — the same callback carries a 3D `TriggerEvent` in a 3D game. A type guard is * used rather than an assertion so that nothing is claimed about a value that was not checked. * * @param value - Whatever the runtime passed. * @returns `true` when the payload is a 2D trigger event. */function isTriggerEvent2D(value: unknown): value is TriggerEvent2D {  return typeof value === "object" && value !== null && "self" in value && "other" in value;}/** * A coin: a trigger that plays a one-shot and takes itself off the board the first time the player * touches it. * * ## Deactivated, not destroyed * * Phase 6's version called `entity.destroy()`, which was right when a coin was a demonstration and * wrong now that the template has a save file: "Continue" has to put a level back the way the * player left it, and a destroyed entity cannot be un-destroyed. Setting `entity.active = false` * takes the sprite and the collider out of the world just as completely, and `setCollected(false)` * puts them back — which is what a save restore and "New game" both need. * * The callback name is the 3D one (`onTriggerEnter`, not `onTriggerEnter2D`); only the payload is a * `TriggerEvent2D`. Both entities in a 2D trigger pair receive the callback and both sides of the * event are always real objects, so testing `other` is enough — 2D has none of the identity gaps * 3D physics documents. */export class Collectible extends Script implements ScriptCallbacks {  static typeId = "sidescroller/Collectible";  /** The clip to play on pickup. Assigned when the coin is spawned. */  clip: AssetHandle<AudioClip> | null = null;  /** Called the first time the player takes this coin. Assigned when the coin is spawned. */  onCollected: ((coin: Collectible) => void) | null = null;  /** Whether the coin has been taken. */  #taken = false;  /**   * The coin's stable id, which is the name the tilemap's objects layer gave the entity.   *   * @returns The id a save file stores.   */  get id(): string {    return this.entity.name;  }  /**   * Whether the coin has been taken.   *   * @returns `true` once the player has touched it.   */  get isTaken(): boolean {    return this.#taken;  }  /**   * Takes or replaces the coin without scoring it. This is what a save restore and a reset use.   *   * @param taken - Whether the coin should read as taken.   */  setCollected(taken: boolean): void {    this.#taken = taken;    this.entity.active = !taken;  }  onTriggerEnter(trigger: unknown): void {    if (!isTriggerEvent2D(trigger) || trigger.other?.name !== "Player" || this.#taken) {      return;    }    const handle = this.clip;    if (handle !== null && handle.state === "loaded") {      this.app.audio.playOneShot(handle.value, { volume: 0.5 });    }    this.setCollected(true);    this.onCollected?.(this);    this.app.log.info("coin taken:", this.id);  }}

Uses:TilemapTilemapRendererParallaxLayerSpriteAnimatorCamera2DCharacterController2DMenuMenuStackapp.storage

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