ignifx
GitHubnpm · soon
All examples

Side-scrolling 2D game

Templates2D

  • Keyboard
  • Gamepad
  • Touch
  • Mouse

One of the four templates `create-ignifx` scaffolds, running here exactly as it runs on a developer's machine. Three parallax bands slide behind a tilemap that carries 45-degree slopes and one-way planks; the reference platformer controller has coyote time, a jump buffer, a variable jump height and drop-through; the coins are trigger colliders that score a point and autosave when they are taken; and the camera is pixel-perfect against a 320 by 180 reference resolution, so one source texel covers a whole number of screen pixels. The title screen, pause menu, settings, key rebinding and save file are all real.

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

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 { 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 { 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. * - `?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;/** * The asset type to load each document as. * * `@ignifx/vite-plugin`'s manifest types a file by its extension, and its table does not yet know * the 2D suffixes: a `.atlas.json` arrives typed as plain `"json"`, which would pick the generic * JSON loader instead of the sprite-atlas one. Naming the type at the call site is what the * `LoadOptions.type` override is for, and it is exact rather than a guess from the file name. */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[];}/** The placeholder `announceReady` holds until the promise below hands over its resolver. */function noop(): void {  // Nothing to do: the promise executor runs synchronously and replaces this on the next line.}let announceReady: (status: AppStatus) => void = noop;window.__ignifxReady = new Promise<AppStatus>((resolve) => {  announceReady = resolve;});/** * Waits for one animation frame. * * @returns A promise that resolves inside the next frame callback. */function nextFrame(): Promise<void> {  return new Promise<void>((resolve) => {    requestAnimationFrame(() => {      resolve();    });  });}/** * Waits for several animation frames, so a newly built scene has presented. * * @param frames - How many frames to wait for. * @returns A promise that resolves after the last of them. */function settle(frames: number): Promise<void> {  let chain = Promise.resolve();  for (let index = 0; index < frames; index += 1) {    chain = chain.then(nextFrame);  }  return chain;}/** * The clip behind a handle, or `null` when the browser refused to decode it. * * @remarks * `AssetHandle.value` is only meaningful once the handle is `"loaded"`; every sound in this * template is optional, so a failed decode costs the game that sound and nothing else. * * @param handle - The handle to read. * @returns The clip, or `null`. */function clipOrNull(handle: AssetHandle<AudioClip>): AudioClip | null {  return handle.state === "loaded" ? handle.value : null;}/** Swaps the canvas for the "no WebGPU here" panel in `index.html`. */function showUnsupported(): void {  document.body.dataset["webgpu"] = "unavailable";}/** * Builds the 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 settles once the front end is up. */async function installFrontEnd(  app: App,  assets: Assets,  world: World,  hud: HTMLDivElement | null,  isBench: boolean,): Promise<void> {  // 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;  }  // 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();}/** * 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('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 {    await installFrontEnd(app, assets, world, gameUi.hud, isBench);  }  if (isStatic && showOverlay && gameUi.hud !== null) {    // `?static=1` leaves the front end out, so nothing writes the HUD. `?hud=1` says the overlay is    // wanted anyway — the gallery capture asks for exactly that — so the zero state is written once.    gameUi.hud.textContent = app.i18n.t("hud.status", { score: 0 });  }  gameUi.loading.hide();  if (isBench) {    installFrameTimeProbe(app);  }  await app.start();  await settle(SETTLE_FRAMES);  app.log.info("2d-sidescroller running: {sprites} 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";/** The probe box used to tell a thin platform from real ground, in metres. */const PROBE_SIZE: Vec2Like = { x: 0.3, y: 0.3 };/** * 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. */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),    /** What the rising velocity is multiplied by when the jump button is released early. */    jumpCut: f32(0.45),    /** 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;  /** 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 = "";  awake(): void {    this.#controller = this.entity.requireComponent(CharacterController2D);    this.#animator = this.entity.getComponent(SpriteAnimator);    this.#sprite = this.entity.getComponent(SpriteRenderer);    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 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);    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.#playClip(this.jumpSound, 0.55);      }    }    // Releasing the button on the way up cuts the rise short, which is the whole of "variable    // jump height": holding it gives the full arc, tapping gives a hop.    if (!this.#jumpHeld && this.#velocity.y > 0) {      this.#velocity.y *= this.jumpCut;    }    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.#step.set(this.#velocity.x * dt, this.#velocity.y * dt);    controller.move(this.#step);    // The controller reports what it *actually* did. Walking into a wall has to zero the stored    // horizontal speed, or the character keeps pressing into it and never accelerates away.    const actual = controller.velocity;    if (Math.abs(actual.x) < Math.abs(this.#velocity.x) * 0.5) {      this.#velocity.x = actual.x;    }    if (this.#velocity.y > 0 && actual.y <= 0) {      this.#velocity.y = 0;    }  }  /**   * 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 {id}", this.id);  }}

Uses:TilemapTilemapRendererParallaxLayerSpriteAnimatorCamera2DCharacterController2DMenuMenuStackapp.storage

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