ignifx
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.

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

` devtools

WebGPU: checking…

Try this

  • Pull Slope limit under 45 and walk at a ramp: the controller refuses the climb and slides you back.
  • 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.
  • 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

Source

main.ts
import {  BoxCollider2D,  Camera2D,  Camera2DFollow,  CharacterController2D,  physics2d,  spawnTilemapObjects,  SpriteAnimator,  SpriteRenderer,  Tilemap,  TilemapCollider2D,  TilemapRenderer,  twoD,  Vec2,  VirtualButton,  VirtualJoystick,} from "ignifx";import { bootExample } from "../_kit/boot.ts";import { bind, readout, slider, toggle } from "../_kit/panel.ts";import { RUN_ACTIONS, Runner } from "./runner.ts";import type {  App,  AssetHandle,  Entity,  SpriteAnimationAsset,  SpriteAtlasAsset,  TileObjectContext,  TilemapAsset,} 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: measured here on 2026-09-08, 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;/** * 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;// The dusk sky behind the course: presents as bytes `43, 47, 69` (`#2B2F45`), a dark dusk blue// chosen for the pixel art. `rendering.clearColor` is decoded from sRGB and not re-encoded// (`packages/2d/src/settings.ts`), so this is `linearToSrgb(target / 255)` per channel, not the byte.const CLEAR_COLOR = { r: 0.4475, g: 0.4665, b: 0.5569, a: 1 };/** * 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. */async function loadCourse(app: App): Promise<{  readonly map: AssetHandle<TilemapAsset>;  readonly tiles: AssetHandle<SpriteAtlasAsset>;  readonly atlas: AssetHandle<SpriteAtlasAsset>;  readonly clips: AssetHandle<SpriteAnimationAsset>;}> {  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 };}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 => {      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: assets.atlas, sortingLayer: "Default" });      entity.addComponent(SpriteAnimator, { animations: assets.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;    });    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;    // 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.    const ground = assets.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: assets.tiles, sortingLayer: "Terrain" }).frame = ground;      step.addComponent(BoxCollider2D, { size: { x: 1, y: height } });    }    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);    if (navigator.maxTouchPoints > 0) {      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", (): void => {        for (const widget of widgets) {          widget.dispose();        }      });    }    // 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 retune = (write: (value: number) => void): ((value: number) => void) => {      return (value: number): void => {        write(value);        controller.rebuild();      };    };    panel({      title: "Platformer controller",      groups: [        {          label: "Controller",          controls: [            slider(              "Slope limit",              { min: 10, max: 80, step: 1, format: (value: number): string => `${String(value)}°` },              {                value: controller.slopeLimit,                change: retune((value: number): void => {                  controller.slopeLimit = value;                }),              },            ),            slider(              "Step offset",              { min: 0, max: 1.1, step: 0.05, format: (value: number): string => `${value.toFixed(2)} m` },              {                value: controller.stepOffset,                change: retune((value: number): void => {                  controller.stepOffset = value;                }),              },            ),            slider(              "Snap to ground",              { min: 0, max: 0.6, step: 0.05, format: (value: number): string => `${value.toFixed(2)} m` },              {                value: controller.snapToGround,                change: retune((value: number): void => {                  controller.snapToGround = value;                }),              },            ),            toggle("One-way planks", bind(controller, "onOneWayPlatforms")),          ],        },        {          label: "Feel",          controls: [            slider(              "Jump speed",              { min: 8, max: 22, step: 0.5, format: (value: number): string => `${value.toFixed(1)} m/s` },              bind(script, "jumpSpeed"),            ),            slider(              "Coyote time",              {                min: 0,                max: 0.3,                step: 0.01,                format: (value: number): string => `${String(Math.round(value * 1000))} ms`,              },              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: (value: number): string => `${value.toFixed(1)} m` },              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 => `${script.speedNow.toFixed(1)} m/s`),            readout("Falls", (): string => String(script.falls)),          ],        },      ],    });  },});
runner.ts
import {  bool,  CharacterController2D,  defineInputActions,  f32,  Script,  SpriteAnimator,  SpriteRenderer,  Vec2,} from "ignifx";import type { CharacterCollision2D, InputAction, MutableVec2, ScriptCallbacks, 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" },            { path: "<Virtual>/joystick", processors: ["deadzone(0.15)"] },          ],        },        {          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 (measured 2026-09-08 at a * fraction of the run speed). `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.   */  get speedNow(): number {    return 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 (measured 2026-09-08).   *   * @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);    }  }}
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.collisionAtCamera2DCamera2DFollowSpriteAnimator

Assets:Side-on terrain and runner sheets — Apache-2.0, Astrum Forge Studios