ignifx
GitHubnpm · soon
All examples

Platformer controller

Physics2D

  • Keyboard
  • Gamepad
  • Touch

`CharacterController2D` is a kinematic box that collides and slides through Rapier, and it applies no gravity at all. That is the point: the script owns the vertical velocity, which is what makes coyote time, a jump buffer and a variable jump height possible. The course is built for the four fields on the panel — two 45-degree ramps for `slopeLimit`, three quarter-metre steps for `stepOffset`, two tiers of one-way planks for `onOneWayPlatforms`, and a pit to fall into. Autostep needs `shape: "box"`; the default capsule clears about 0.15 m however large the number is.

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

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"),            ),            slider(              "Jump cut",              { min: 0.1, max: 1, step: 0.05, format: (value: number): string => value.toFixed(2) },              bind(script, "jumpCut"),            ),          ],        },        {          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 { InputAction, MutableVec2, ScriptCallbacks, Tilemap } 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;/** 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. */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),    /** What the rising velocity is multiplied by when the button is released early. */    jumpCut: f32(0.45),    /** 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 = "";  awake(): void {    this.#controller = this.entity.requireComponent(CharacterController2D);    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");  }  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);    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;      }    }    // Releasing the button on the way up cuts the rise short. That is the whole of "variable jump    // height": holding it gives the full arc, tapping gives a hop.    if (!this.#jumpHeld && this.#velocity.y > 0) {      this.#velocity.y *= this.jumpCut;    }    const gravity = this.#velocity.y > 0 ? this.riseGravity : this.fallGravity;    this.#velocity.y = Math.max(-this.terminalVelocity, this.#velocity.y - gravity * dt);    if (controller.isGrounded && this.#velocity.y < 0) {      // 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.#step.set(this.#velocity.x * dt, this.#velocity.y * dt);    controller.move(this.#step);    // The controller reports what it *actually* did. Walking into a wall has to zero the stored    // horizontal speed, or the runner keeps pressing into it and never accelerates away.    const actual = controller.velocity;    if (Math.abs(actual.x) < Math.abs(this.#velocity.x) * 0.5) {      this.#velocity.x = actual.x;    }    if (this.#velocity.y > 0 && actual.y <= 0) {      this.#velocity.y = 0;    }  }  /**   * 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