All examples
Platformer controller
- 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.

WebGPU: checking…See browser support
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.
Show source code
Source
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)), ], }, ], }); },});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); } }}{ "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": [] } ] } ]}