All examples

Platformer controller

Physics2D
  • Keyboard
  • Gamepad
  • Touch

Try a 2D controller on a course of ramps, steps and one-way platforms. Adjust slope and step limits to change where the character can go. The movement script also supports forgiving jumps and different jump heights.

Off airLivePaused

Platformer controller

Physics · 2D

A side-on pixel-art platformer level at dusk: a grassy earth floor with a dusk-blue pit cut through it on the left, a small blue-suited runner standing beside a flight of three low grassy steps, and two rows of wooden planks floating above and to the right.
Open standalone

WebGPU: checking…

Try this

  1. Pull Slope limit under 45 and walk at a ramp: the controller refuses the climb and slides you back.
  2. Drop Step offset to 0.1 and the three low steps become a wall; put it back to 0.3 and you walk up them.
  3. Stand on a plank and press Down and Jump together to fall through it; on solid ground the same press jumps.
Read the guide
Show source code
main.ts
import {  Camera2D,  Camera2DFollow,  CharacterController2D,  physics2d,  spawnTilemapObjects,  Tilemap,  TilemapCollider2D,  TilemapRenderer,  twoD,  Vec2,} from "ignifx";import { bootExample } from "../_kit/boot.ts";import { metres } from "../_kit/format.ts";import { bind, readout, slider, toggle } from "../_kit/panel.ts";import { buildSteps, loadCourse } from "./course.ts";import { attachTouchControls, RUN_ACTIONS, Runner, spawnRunner } from "./runner.ts";import type { PanelBinding } from "../_kit/panel.ts";import type { Entity, TileObjectContext } from "ignifx";/** * `CharacterController2D` on a course built for it: two 45-degree ramps, a pit, two tiers of * one-way planks, and a one-metre ledge with no ramp at all. * * The controller is a **kinematic** capsule or box that collides and slides through Rapier. It * applies no gravity of its own, which is the whole point: the script below owns the vertical * velocity, and that is what makes coyote time, a jump buffer and a variable jump height possible. * Four of its fields are on the panel because each one is a decision a platformer has to make. * * - **`slopeLimit`** is the steepest slope the character walks up. The ramps here are 45 degrees, *   so anything under 45 stops you at the foot of one. * - **`stepOffset`** is autostep: how tall a ledge the controller climbs without a jump. It needs *   `shape: "box"` — with the default capsule of radius 0.2 it clears about 0.15 m however large *   the number is. It is also a *small* number by nature: Rapier refuses a one-metre step at any *   `stepOffset`, which is why the three steps it is shown against are a quarter of a metre each *   and built from colliders rather than from cells. The one-metre ledge near the end of the course *   has to be jumped whatever the slider says. * - **`snapToGround`** keeps the feet on the floor going *down* a ramp instead of launching off *   the crest. * - **`onOneWayPlatforms`** is not the switch it sounds like: leaving it on is what makes a plank *   passable from below, and turning it off makes it solid from both sides. * * `course.tmj.json` beside this file is the Tiled export the level came from, and * `../tilemap/tools/build-2d-assets.ts` is what ran it through `importTiledMap` at build time. The * two ramp tiles carry triangular colliders; the plank tile carries a box a third of a cell tall * with `oneWay` set. *//** The course is fifty-six by sixteen cells of one metre, and the camera may not leave it. */const LEVEL_SIZE = { x: 56, y: 16 } as const;/** The design resolution the pixel-perfect camera fits a whole-number zoom to. */const REFERENCE_RESOLUTION = { x: 320, y: 180 } as const;/** Value-cell formats for the panel. */const degrees = (value: number): string => `${String(value)}°`;const hundredths = (value: number): string => `${value.toFixed(2)} m`;const metresPerSecond = (value: number): string => `${value.toFixed(1)} m/s`;const milliseconds = (value: number): string => `${String(Math.round(value * 1000))} ms`;/** The dusk sky behind the course, in sRGB: `#2B2F45`, a dark blue chosen for the pixel art. */const CLEAR_COLOR = { r: 0.169, g: 0.184, b: 0.271, a: 1 };bootExample({  title: "Platformer controller",  extensions: [twoD({ pixelsPerUnit: 16 }), physics2d()],  settings: {    rendering: {      clearColor: CLEAR_COLOR,      // Multisampling is off because it would soften exactly the edges pixel art exists to keep      // sharp.      msaaSamples: 1,    },    time: { fixedDeltaTime: 1 / 60 },    sortingLayers: { sortingLayers: ["Terrain", "Default"] },    layers: { layers: ["Default", "Player", "Terrain"] },    // Only rigid bodies fall under this. `CharacterController2D` is kinematic, so `Runner` owns the    // vertical velocity — which is what makes coyote time and a variable jump height possible.    physics2d: { gravity: { x: 0, y: -24 }, defaultMaterial: { friction: 0.4, restitution: 0 } },  },  async setup({ app, panel }) {    app.registerComponents([Runner]);    app.input.loadActions(RUN_ACTIONS);    const assets = await loadCourse(app);    app.twoD.registerTileObjectFactory("spawn", (context: TileObjectContext): Entity =>      spawnRunner(app, context, assets.atlas, assets.clips),    );    const level = app.world.createEntity("Course");    level.layer = app.world.layers.requireIndex("Terrain");    const map = level.addComponent(Tilemap, { map: assets.map, chunkSize: 16 });    level.addComponent(TilemapRenderer, { atlas: assets.tiles, cullChunks: true });    // Solid tiles become merged outlines; a `oneWay` tile contributes only its top edge, which is    // what `onOneWayPlatforms` collides against.    level.addComponent(TilemapCollider2D).collisionData = map.collisionData;    // Three low steps for `stepOffset` to climb, as colliders: a cell here is a whole metre.    buildSteps(app, assets.tiles);    const runner = spawnTilemapObjects(app, app.twoD, map)[0];    if (runner === undefined) {      throw new Error('course.tilemap.json has no object of type "spawn".');    }    const controller = runner.requireComponent(CharacterController2D);    const script = runner.requireComponent(Runner);    script.level = map;    const eye = app.world.createEntity("Main Camera");    eye.transform.position2D = new Vec2(runner.transform.position2D.x, runner.transform.position2D.y + 0.8);    eye.addComponent(Camera2D, {      pixelPerfect: true,      referenceResolution: REFERENCE_RESOLUTION,      follow: runner,      followDamping: 0.1,      followOffset: { x: 0, y: 0.8 },      deadZone: { x: 1.2, y: 1.5 },      boundsMin: { x: 0, y: 0 },      boundsMax: LEVEL_SIZE,    });    eye.addComponent(Camera2DFollow);    // On-screen controls only where there is a touch screen: on a desktop they would cover the course.    if (navigator.maxTouchPoints > 0) {      attachTouchControls(app);    }    // The controller's tuning is read when its body is built, not on every step, so a live edit has    // to ask for a rebuild — which happens at the start of the next fixed step.    const tuned = (key: "slopeLimit" | "snapToGround" | "stepOffset"): PanelBinding<number> => ({      value: controller[key],      change: (value: number): void => {        controller[key] = value;        controller.rebuild();      },      read: (): number => controller[key],    });    panel({      title: "Platformer controller",      groups: [        {          label: "Controller",          controls: [            slider("Slope limit", { min: 10, max: 80, step: 1, format: degrees }, tuned("slopeLimit")),            slider("Step offset", { min: 0, max: 1.1, step: 0.05, format: hundredths }, tuned("stepOffset")),            slider("Snap to ground", { min: 0, max: 0.6, step: 0.05, format: hundredths }, tuned("snapToGround")),            toggle("One-way planks", bind(controller, "onOneWayPlatforms")),          ],        },        {          label: "Feel",          controls: [            slider("Jump speed", { min: 8, max: 22, step: 0.5, format: metresPerSecond }, bind(script, "jumpSpeed")),            slider("Coyote time", { min: 0, max: 0.3, step: 0.01, format: milliseconds }, bind(script, "coyoteTime")),            // The rise a one-frame tap is guaranteed; a held button still climbs the whole arc.            slider("Short jump", { min: 0.3, max: 3, step: 0.1, format: metres }, bind(script, "minJumpHeight")),          ],        },        {          label: "State",          collapsed: true,          controls: [            readout("Grounded", (): string => (script.isGrounded ? "yes" : "no")),            readout("Position", (): string => {              const at = runner.transform.position2D;              return `${at.x.toFixed(1)}, ${at.y.toFixed(1)} m`;            }),            readout("Speed", (): string => metresPerSecond(script.speedNow)),            readout("Falls", (): string => String(script.falls)),          ],        },      ],    });  },});
runner.ts
import {  bool,  CharacterController2D,  defineInputActions,  f32,  Script,  SpriteAnimator,  SpriteRenderer,  Vec2,  VirtualButton,  VirtualJoystick,} from "ignifx";import type {  App,  AssetHandle,  CharacterCollision2D,  Entity,  InputAction,  MutableVec2,  ScriptCallbacks,  SpriteAnimationAsset,  SpriteAtlasAsset,  TileObjectContext,  Tilemap,  Vec2Like,} from "ignifx";/** * The runner: everything about how the character *feels*, in one file, so `main.ts` is only the * scene it stands in. * * `CharacterController2D` is a kinematic box that collides and slides through Rapier and applies no * gravity of its own. That is what makes this script possible: it owns the vertical velocity, and * with it coyote time, a jump buffer, a variable jump height, and dropping through a plank. *//** Below this height the runner has left the world through the pit, and is put back. */const FALL_LIMIT = -2;/** Degrees to radians, for reading the controller's `slopeLimit` as an angle. */const DEGREES_TO_RADIANS = Math.PI / 180;/** How sideways an unwalkable contact normal has to be to count as a wall. A brick's is 1. */const WALL_NORMAL_X = 0.5;/** How far down a contact normal has to point to count as a ceiling. */const CEILING_NORMAL_Y = -0.5;/** How far a ground normal has to lean before the run is rotated onto it; flat ground is exactly `(0, 1)`. */const SLOPE_NORMAL_X = 0.01;/** How much of the requested horizontal speed a step has to lose before a wall counts as stopping the runner. */const WALL_STALL_RATIO = 0.5;/** The actions the runner reads. Its own map, so the kit's camera actions are untouched. */export const RUN_ACTIONS = defineInputActions({  maps: [    {      name: "Course",      actions: [        {          name: "move",          type: "vector2",          bindings: [            {              composite: "2DVector",              up: "<Keyboard>/w",              down: "<Keyboard>/s",              left: "<Keyboard>/a",              right: "<Keyboard>/d",            },            {              composite: "2DVector",              up: "<Keyboard>/arrowUp",              down: "<Keyboard>/arrowDown",              left: "<Keyboard>/arrowLeft",              right: "<Keyboard>/arrowRight",            },            { path: "<Gamepad>/leftStick", processors: ["deadzone(0.2)"] },            { path: "<Gamepad>/dpad" },            // The on-screen stick applies its own dead zone.            { path: "<Virtual>/joystick" },          ],        },        {          name: "jump",          type: "button",          bindings: [            { path: "<Keyboard>/space" },            { path: "<Keyboard>/z" },            { path: "<Gamepad>/buttonSouth" },            { path: "<Virtual>/jump" },          ],        },      ],    },  ],});/** * Runs, jumps, cuts the jump short, remembers a press made just before landing, forgives one made * just after walking off a ledge, and drops through a plank on down-and-jump. * * Input is sampled in `update` and spent in `fixedUpdate`, because those are two different clocks. * `wasPressedThisFrame` is true for exactly one *frame*, and a frame carries zero, one or two fixed * steps: read it inside the step and a press is either missed or acted on twice. * * Two rules here are the ones every platformer gets wrong the first time. **Walls are read from * contact normals, not from a short move**: collide-and-slide on a 45-degree ramp legitimately * returns about half the requested horizontal motion, so "I moved less than I asked, I must have hit * a wall" zeroes the run on every ramp and the runner crawls up it. `CharacterController2D.onCollided` reports a normal per obstacle * instead; a normal that is too steep to stand on *and* sideways is a wall, and only if it also * stalled the move. **The jump cut is a clamp applied once**, on the release edge: multiplying the * rise by a factor on *every* step the button is up compounds, so the same launch reached 0.4 m or * 3.4 m depending on how many frames a tap covered. `minJumpHeight` is what a tap is guaranteed. */export class Runner  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(15),    /** 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 runner 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. Releasing the button early clamps     * the climb to this once; holding it gives the whole `jumpSpeed` arc.     */    minJumpHeight: f32(1.1),    /** Whether the sprite is mirrored when running left. */    flipSprite: bool(true),  })  implements ScriptCallbacks{  /** The namespaced registration id. */  static typeId = "platformer-controller/Runner";  /** Where the map's objects layer put the runner, and where a fall puts her back. */  spawn: MutableVec2 = new Vec2();  /** The level, so a drop-through can ask what the runner is standing on. Assigned on spawn. */  level: Tilemap | null = null;  /** How many times the runner has been caught by the pit, for the panel. */  falls = 0;  #controller: CharacterController2D | null = null;  #animator: SpriteAnimator | null = null;  #sprite: SpriteRenderer | null = null;  #move: InputAction | null = null;  #jump: InputAction | null = null;  /** The runner's velocity, in metres per second. The script owns it, not the controller. */  readonly #velocity: MutableVec2 = new Vec2();  /** The displacement handed to `move`, and the cell the drop-through reads. Both reused. */  readonly #step: MutableVec2 = new Vec2();  readonly #cell: MutableVec2 = new Vec2();  #wishX = 0;  #wishDown = false;  #buffered = 0;  #coyote = 0;  #jumpHeld = false;  #wasGrounded = true;  #clip = "";  /** Whether the runner is on the way up from a jump, and whether that rise has been clamped yet. */  #rising = false;  #cut = false;  /** What the previous fixed step touched: a wall on the right (`1`) or left (`-1`), a ceiling, the ground. */  #blocked = 0;  #ceiling = false;  readonly #surface: MutableVec2 = new Vec2(0, 1);  #surfaceY = -2;  awake(): void {    const controller = this.entity.requireComponent(CharacterController2D);    this.#controller = controller;    this.#animator = this.entity.getComponent(SpriteAnimator);    this.#sprite = this.entity.getComponent(SpriteRenderer);    this.#move = this.app.input.actions.find("move");    this.#jump = this.app.input.actions.find("jump");    // One call per obstacle the move touched, raised by the step system after this script's    // `fixedUpdate`, so each step reads the previous step's contacts — the same one-step lag    // `controller.velocity` has. The event is pooled: classify it here, keep nothing of it.    controller.onCollided.connect(      (hit: CharacterCollision2D): void => {        this.#noteContact(hit.normal);      },      { owner: this },    );  }  update(dt: number): void {    const vector = this.#move?.vector ?? null;    this.#wishX = vector?.x ?? 0;    this.#wishDown = vector !== null && vector.y < -0.5;    this.#jumpHeld = this.#jump?.isPressed ?? false;    if (this.#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;    }    if (this.transform.position2D.y < FALL_LIMIT) {      this.falls += 1;      this.#velocity.set(0, 0);      controller.teleport(this.spawn);      return;    }    const grounded = controller.isGrounded;    this.#wasGrounded = grounded;    this.#coyote = grounded ? this.coyoteTime : Math.max(0, this.#coyote - dt);    if (grounded) {      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 = -6;      } else {        this.#coyote = 0;        this.#velocity.y = this.jumpSpeed;        this.#rising = true;        this.#cut = false;      }    }    // 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. It can only shorten    // a jump — released near the apex, the rise is 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, Math.sqrt(2 * this.riseGravity * this.minJumpHeight));    }    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) {      // Parked at a small negative value rather than zero: a growing downward velocity while      // standing still would defeat `snapToGround` on the way down a ramp.      this.#velocity.y = -1;    }    this.#stepAlong(grounded, dt);    controller.move(this.#step);    // Whatever the contact handler hears from here on belongs to the step Rapier is about to run.    this.#blocked = 0;    this.#ceiling = false;    this.#surface.set(0, 1);    this.#surfaceY = -2;  }  /**   * Applies the previous step's contacts: a wall that stalled the move zeroes the run, a ceiling   * ends the rise.   *   * @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;    }  }  /**   * Turns the velocity into the displacement `move()` is given, rotated onto the ground the runner   * stands on so a run follows a ramp at full speed instead of cutting across it at its cosine. A   * jump is left alone: `jumpSpeed` goes straight up on a ramp as it does on the flat.   *   * @param grounded - Whether the runner is on the ground.   * @param dt - The fixed step, in seconds.   */  #stepAlong(grounded: boolean, dt: number): void {    const x = this.#velocity.x * dt;    const y = this.#velocity.y * dt;    const normal = this.#surface;    if (!grounded || this.#velocity.y > 0 || Math.abs(normal.x) <= SLOPE_NORMAL_X) {      this.#step.set(x, y);      return;    }    this.#step.set(normal.y * x + normal.x * y, normal.y * y - normal.x * x);  }  /**   * Files one contact normal as a ceiling, walkable ground, or a candidate wall.   *   * @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 ?? 45;    if (normal.y >= Math.cos(limit * DEGREES_TO_RADIANS)) {      // Walkable: the most upward-facing surface of the step is the one the run is rotated onto.      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) {      // A wall on the runner's right pushes left, so its normal's x is negative.      this.#blocked = normal.x < 0 ? 1 : -1;    }  }  /**   * Whether the runner is standing on something, for the panel.   *   * @returns `true` while the controller reported ground under the box on the last fixed step.   */  get isGrounded(): boolean {    return this.#wasGrounded;  }  /**   * The runner's current speed, for the panel.   *   * @returns The magnitude of the script's own velocity, in metres per second; on the ground, only   * the run, because the vertical speed a grounded runner carries is the parked `-1` that keeps   * `snapToGround` working, not motion, and counting it read 1.0 m/s standing still.   */  get speedNow(): number {    return this.#wasGrounded ? Math.abs(this.#velocity.x) : Math.hypot(this.#velocity.x, this.#velocity.y);  }  /**   * Steps down through a one-way plank, if that is what the runner is standing on.   *   * @remarks   * The runtime's rule is "solid only while the character is descending and its feet are at or   * above the plank's top", so the way down is to put the feet below that top — and the way to know   * it is safe is to ask the map. `Tilemap.worldToCell` is exact and free, and `collisionAt` answers   * with the cell's own `oneWay` flag, so a drop-through can never open a hole in solid ground.   *   * A shape query would be the obvious alternative and is the wrong tool: a tilemap's collision is   * a **merged outline**, so `overlapBox` with a small box entirely inside the ground crosses no   * edge and reports nothing.   *   * @param controller - The controller to move.   * @returns `true` when the runner was moved down through a plank.   */  #dropThrough(controller: CharacterController2D): boolean {    const level = this.level;    if (level === null) {      return false;    }    const feet = this.transform.position2D;    // A tenth of a metre below the feet is inside the cell that is holding them up.    const cell = level.worldToCell({ x: feet.x, y: feet.y - 0.1 }, this.#cell);    if (!level.collisionAt(cell.x, cell.y).oneWay) {      return false;    }    controller.teleport({ x: feet.x, y: feet.y - 0.45 });    return true;  }  /**   * Moves the horizontal velocity toward the wished-for speed at the right acceleration.   *   * @param dt - The fixed step, in seconds.   * @param grounded - Whether the runner is on the ground, which decides which rate is used.   */  #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 clip = this.#wasGrounded      ? Math.abs(this.#velocity.x) > 0.4        ? "run"        : "idle"      : this.#velocity.y > 0        ? "jump"        : "fall";    if (clip !== this.#clip) {      this.#clip = clip;      this.#animator?.play(clip);    }  }}/** * Builds the runner for the map's `spawn` object: the tile-object factory `main.ts` registers. * * @param app - The running app. * @param context - The object as the map describes it: its name, cell and size. * @param atlas - The runner's sprite sheet. * @param clips - The runner's clips. * @returns The new entity. */export function spawnRunner(  app: App,  context: TileObjectContext,  atlas: AssetHandle<SpriteAtlasAsset>,  clips: AssetHandle<SpriteAnimationAsset>,): Entity {  const entity = app.world.createEntity(context.name);  entity.layer = app.world.layers.requireIndex("Player");  const spawn = new Vec2(context.position.x + context.size.x / 2, context.position.y);  entity.transform.position2D = spawn;  entity.addComponent(SpriteRenderer, { sprite: atlas, sortingLayer: "Default" });  entity.addComponent(SpriteAnimator, { animations: clips, defaultClip: "idle", playOnAwake: true });  // A box, not the default capsule: autostep only works with one, and `stepOffset` is the field  // this example exists to show.  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,  });  entity.addComponent(Runner).spawn = spawn;  return entity;}/** * Mounts the on-screen stick and jump button, which write the `<Virtual>/…` controls * {@link RUN_ACTIONS} binds, and disposes them when the page is torn down. * * @param app - The running app; needs the `ui()` extension, which the kit always registers. */export function attachTouchControls(app: App): void {  const bottom = "calc(1.5rem + var(--ignifx-safe-bottom, 0px))";  const widgets = [    new VirtualJoystick(app, { control: "joystick", ariaLabel: "Move", style: { left: "1.5rem", bottom } }),    new VirtualButton(app, { control: "jump", label: "A", ariaLabel: "Jump", style: { right: "1.5rem", bottom } }),  ];  window.addEventListener("pagehide", (event: PageTransitionEvent): void => {    if (!event.persisted) {      for (const widget of widgets) {        widget.dispose();      }    }  });}
course.ts
import { BoxCollider2D, SpriteRenderer, Vec2 } from "ignifx";import type { App, AssetHandle, SpriteAnimationAsset, SpriteAtlasAsset, TilemapAsset } from "ignifx";/** * The course's documents and the one piece of it the map cannot describe: the three low steps that * `stepOffset` is shown against. *//** * The three steps `stepOffset` is shown against: `[x, height]` in metres, each a metre wide and * standing on the flat run east of the pit. * * @remarks * They are colliders rather than cells because a cell here is a whole metre and autostep is a * sub-metre feature. The sprite is the tileset's grass-topped ground frame, scaled: a sprite is * drawn at its frame size times the transform's scale, so one tile becomes a low kerb. */const STAIRS: readonly (readonly [number, number])[] = [  [25.5, 0.25],  [26.5, 0.5],  [27.5, 0.75],];/** The world height of the flat ground either side of the steps, in metres. */const GROUND_TOP = 5;/** What {@link loadCourse} answers with. */export interface CourseAssets {  /** The Tiled map, imported. */  readonly map: AssetHandle<TilemapAsset>;  /** The terrain tiles. */  readonly tiles: AssetHandle<SpriteAtlasAsset>;  /** The runner's sheet. */  readonly atlas: AssetHandle<SpriteAtlasAsset>;  /** The runner's clips. */  readonly clips: AssetHandle<SpriteAnimationAsset>;}/** * Loads the four documents the course is built from. * * @param app - The app being set up. * @returns The map, the tile atlas, and the runner's atlas and clips. */export async function loadCourse(app: App): Promise<CourseAssets> {  const [map, tiles, atlas, clips] = await Promise.all([    app.assets.loadAsync<TilemapAsset>("2d/course.tilemap.json"),    app.assets.loadAsync<SpriteAtlasAsset>("2d/terrain.atlas.json"),    app.assets.loadAsync<SpriteAtlasAsset>("2d/runner.atlas.json"),    app.assets.loadAsync<SpriteAnimationAsset>("2d/runner.spriteanim.json"),  ]);  return { map, tiles, atlas, clips };}/** * Builds the step staircase. A collider with no `Rigidbody2D` gets an implicit static body, placed * once at the next fixed step, which is exactly what a piece of level furniture wants. * * @param app - The running app. * @param tiles - The terrain tiles, whose grass-topped frame each step is drawn with. */export function buildSteps(app: App, tiles: AssetHandle<SpriteAtlasAsset>): void {  const ground = tiles.value.requireFrame("terrain_0");  for (const [x, height] of STAIRS) {    const step = app.world.createEntity(`Step ${height.toFixed(1)}m`);    step.layer = app.world.layers.requireIndex("Terrain");    step.transform.position2D = new Vec2(x, GROUND_TOP + height / 2);    step.transform.localScale2D = new Vec2(1, height);    step.addComponent(SpriteRenderer, { sprite: tiles, sortingLayer: "Terrain" }).frame = ground;    step.addComponent(BoxCollider2D, { size: { x: 1, y: height } });  }}
course.tmj.json
{  "compressionlevel": -1,  "infinite": false,  "orientation": "orthogonal",  "renderorder": "right-down",  "tiledversion": "1.11.2",  "type": "map",  "version": "1.10",  "tilewidth": 16,  "tileheight": 16,  "width": 56,  "height": 16,  "nextlayerid": 3,  "nextobjectid": 2,  "properties": [    {      "name": "title",      "type": "string",      "value": "Ramps and Planks"    }  ],  "tilesets": [    {      "name": "terrain",      "firstgid": 1,      "image": "terrain.png",      "imagewidth": 144,      "imageheight": 18,      "tilewidth": 16,      "tileheight": 16,      "spacing": 2,      "margin": 1,      "columns": 8,      "tilecount": 8,      "tiles": [        {          "id": 0,          "objectgroup": {            "draworder": "index",            "objects": [              {                "id": 1,                "x": 0,                "y": 0,                "width": 16,                "height": 16,                "rotation": 0,                "visible": true              }            ]          }        },        {          "id": 1,          "objectgroup": {            "draworder": "index",            "objects": [              {                "id": 1,                "x": 0,                "y": 0,                "width": 16,                "height": 16,                "rotation": 0,                "visible": true              }            ]          }        },        {          "id": 2,          "objectgroup": {            "draworder": "index",            "objects": [              {                "id": 1,                "x": 0,                "y": 0,                "rotation": 0,                "visible": true,                "polygon": [                  { "x": 16, "y": 0 },                  { "x": 16, "y": 16 },                  { "x": 0, "y": 16 }                ]              }            ]          }        },        {          "id": 3,          "objectgroup": {            "draworder": "index",            "objects": [              {                "id": 1,                "x": 0,                "y": 0,                "rotation": 0,                "visible": true,                "polygon": [                  { "x": 0, "y": 0 },                  { "x": 16, "y": 16 },                  { "x": 0, "y": 16 }                ]              }            ]          }        },        {          "id": 4,          "objectgroup": {            "draworder": "index",            "objects": [              {                "id": 1,                "x": 0,                "y": 0,                "width": 16,                "height": 5,                "rotation": 0,                "visible": true              }            ]          },          "properties": [            {              "name": "oneWay",              "type": "bool",              "value": true            }          ]        },        {          "id": 5,          "objectgroup": {            "draworder": "index",            "objects": [              {                "id": 1,                "x": 0,                "y": 0,                "width": 16,                "height": 16,                "rotation": 0,                "visible": true              }            ]          }        },        {          "id": 6,          "objectgroup": {            "draworder": "index",            "objects": [              {                "id": 1,                "x": 0,                "y": 0,                "width": 16,                "height": 16,                "rotation": 0,                "visible": true              }            ]          }        },        {          "id": 7,          "objectgroup": {            "draworder": "index",            "objects": [              {                "id": 1,                "x": 0,                "y": 0,                "width": 16,                "height": 16,                "rotation": 0,                "visible": true              }            ]          }        }      ]    }  ],  "layers": [    {      "id": 1,      "name": "Terrain",      "type": "tilelayer",      "visible": true,      "opacity": 1,      "x": 0,      "y": 0,      "width": 56,      "height": 16,      "properties": [        {          "name": "sortingLayer",          "type": "string",          "value": "Terrain"        },        {          "name": "collision",          "type": "bool",          "value": true        }      ],      "data": [        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 5, 5, 5, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 6, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 5, 5, 5, 5, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 6,        0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 3, 1, 1, 1, 1, 1, 1, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,        0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 4, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2,        2, 2, 1, 1, 1, 8, 0, 0, 0, 7, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1,        1, 1, 1, 1, 1, 1, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 0, 0, 0, 2, 2, 2, 2, 2, 2, 2, 2, 2,        2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2,        2, 2, 2, 2, 2, 2, 2, 0, 0, 0, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2,        2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 0, 0, 0, 2, 2, 2, 2, 2, 2, 2, 2,        2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2,        2, 2, 2, 2, 2, 2, 2, 2, 0, 0, 0, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2,        2, 2, 2, 2, 2, 2, 2, 2      ]    },    {      "id": 2,      "name": "Objects",      "type": "objectgroup",      "draworder": "topdown",      "visible": true,      "opacity": 1,      "x": 0,      "y": 0,      "objects": [        {          "id": 1,          "name": "Runner",          "type": "spawn",          "rotation": 0,          "visible": true,          "x": 384,          "y": 160,          "width": 16,          "height": 16,          "properties": []        }      ]    }  ]}
Uses
CharacterController2DTilemapCollider2DBoxCollider2DTilemap.collisionAtCamera2DCamera2DFollowSpriteAnimatorspawnTilemapObjectsVirtualJoystickVirtualButton
Assets
Side-on terrain and runner sheets — Apache-2.0, Astrum Forge Studios