All examples
Side-scrolling 2D game
- 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.

WebGPU: checking…See browser support
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
// 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);});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); } }}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); }}