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

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
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"), ), 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)), ], }, ], }); },});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); } }}{ "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": [] } ] } ]}