All examples
2D physics
Build a stack of crates and knock it down with coins. Boxes stack and topple; circles roll. Change gravity to see how it affects the same objects, or reset the scene to try another throw.
Off airLivePaused
2D physics
Physics · 2D

WebGPU: checking…See browser support
Try this
- Click or tap anywhere: a coin is thrown from the left edge at the point you picked.
- Drop crates until the tower leans, then knock it down and press Reset the stack.
- Open World, pull gravity to zero and throw again: the coin drifts in a straight line until it hits something.
Show source code
Source
import { Camera2D, physics2d, twoD, Vec2 } from "ignifx";import { bootExample } from "../_kit/boot.ts";import { button, readout, slider } from "../_kit/panel.ts";import { AIM_ACTIONS, createArena, FitArena, Thrower } from "./arena.ts";import { Launch } from "./launch.ts";import { COLUMNS, MIDDLE_COLUMN, PIXELS_PER_UNIT, REFERENCE_RESOLUTION, SHOT } from "./scene.ts";import type { SpriteAtlasAsset } from "ignifx";/** * Simulate boxes and circles with the engine's fixed loop and interpolate their display poses. * Mass is in kilograms; zero derives it from collider area at 1 kg/m². * Colliders without a Rigidbody2D become static bodies at the next fixed step. *//** The dusk sky behind the arena, in sRGB: `#1F2333`, darker than the platformer's so the props read. */const CLEAR_COLOR = { r: 0.122, g: 0.137, b: 0.2, a: 1 };bootExample({ title: "2D physics", // One world metre is one 18-pixel tile, which is the size Kenney's pixel-platformer art is cut at. extensions: [twoD({ pixelsPerUnit: PIXELS_PER_UNIT }), 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"] }, // Real gravity here, unlike the platformer: every body in this scene is dynamic, and gravity is // what the solver applies to it. physics2d: { gravity: { x: 0, y: -18 }, defaultMaterial: { friction: 0.55, restitution: 0 } }, }, setup({ app, panel, random }) { app.registerComponents([Launch, Thrower, FitArena]); app.input.loadActions(AIM_ACTIONS); const handle = app.assets.load<SpriteAtlasAsset>("2d/props.atlas.json"); return handle.promise.then((): void => { const arena = createArena(app, handle, random); const thrower = app.world.createEntity("Thrower").addComponent(Thrower); thrower.arena = arena; const eye = app.world.createEntity("Main Camera"); eye.transform.position2D = new Vec2(SHOT.x, SHOT.y); eye.addComponent(Camera2D, { // The zoom is `viewportHeight / referenceResolution.y`, snapped to a whole number, so one // texel always covers an exact square of screen pixels. pixelPerfect: true, referenceResolution: REFERENCE_RESOLUTION, }); // On a frame narrower than 16:9 (a phone held upright) the zoom is fitted to the width too. eye.addComponent(FitArena); panel({ title: "2D physics", groups: [ { label: "Arena", controls: [ button("Throw a coin", (): void => { arena.throwAt(new Vec2(MIDDLE_COLUMN, 2.5)); }), button("Drop a crate", (): void => { arena.crate(COLUMNS[Math.floor(random() * COLUMNS.length)] ?? MIDDLE_COLUMN, 7); }), button("Reset the stack", arena.reset), readout("Bodies", (): string => String(arena.bodies.length)), ], }, { label: "World", collapsed: true, controls: [ // Bodies already asleep keep resting until something touches them; a throw is awake. slider( "Gravity", { min: -40, max: 0, step: 1, format: (value: number): string => `${value.toFixed(0)} m/s²` }, { value: app.physics2d.gravity.y, change: (value: number): void => { app.physics2d.gravity = { x: 0, y: value }; }, read: (): number => app.physics2d.gravity.y, }, ), ], }, { label: "Frame", collapsed: true, controls: [readout("Draw calls", (): string => String(app.renderer.drawCalls))], }, ], }); }); },});import { BoxCollider2D, Camera2D, CircleCollider2D, defineInputActions, Rigidbody2D, Script, SpriteRenderer, Vec2,} from "ignifx";import { Launch } from "./launch.ts";import { ARENA_WIDTH, COLUMNS, EARTH_ROWS, FRAMES, GROUND_OVERHANG, MAX_BODIES, NARROW_SPAN, PIXELS_PER_UNIT, REFERENCE_RESOLUTION, RESTING_COINS, SHOT, STACK_HEIGHT, THROW_FROM, THROW_SPEED,} from "./scene.ts";import type { App, AssetHandle, Entity, InputAction, MutableVec2, ScriptCallbacks } from "ignifx";import type { SpriteAtlasAsset, Vec2Like } from "ignifx";/** * The arena: the floor, the walls, the crates, the coins, and the click that throws one. * * Kept out of `main.ts` so that file is the app and the panel and nothing else. *//** The pointer actions. Their own map, so the kit's camera actions are untouched. */export const AIM_ACTIONS = defineInputActions({ maps: [ { name: "Arena", actions: [ // `<Pointer>` is the unified primary pointer: a mouse, a pen, or the first touch. One // binding is a click and a tap. { name: "fire", type: "button", bindings: [{ path: "<Pointer>/press" }] }, { name: "aim", type: "vector2", bindings: [{ path: "<Pointer>/position" }] }, ], }, ],});/** What the arena is holding, so the panel can count it and Reset can empty it. */export interface Arena { /** Every body the scene created, oldest first. */ readonly bodies: Entity[]; /** * Drops one crate at a column. * * @param column - Which of {@link COLUMNS} to drop it on. * @param height - Where its centre starts, in metres. */ readonly crate: (column: number, height: number) => void; /** * Throws one coin from the left edge. * * @param target - The world point to aim at. */ readonly throwAt: (target: Vec2Like) => void; /** Removes every body and rebuilds the opening stack. */ readonly reset: () => void;}/** * Turns a click into a throw. * * @remarks * The pointer's position arrives in **backing-store pixels**, which is what `screenToWorld` wants, * so there is no conversion here — a DOM listener would have needed one. Reading the press in * `update` rather than `fixedUpdate` is the same rule every input in ignifx follows: * `wasPressedThisFrame` is true for exactly one frame, and a frame carries zero, one or two steps. */export class Thrower extends Script implements ScriptCallbacks { /** The namespaced registration id. */ static typeId = "physics-2d/Thrower"; /** The arena to throw into. Assigned when the scene is built. */ arena: Arena | null = null; #fire: InputAction | null = null; #aim: InputAction | null = null; readonly #target: MutableVec2 = new Vec2(); awake(): void { this.#fire = this.app.input.actions.find("fire"); this.#aim = this.app.input.actions.find("aim"); } update(): void { if (this.#fire?.wasPressedThisFrame !== true) { return; } const at = this.#aim?.vector ?? null; if (at === null) { return; } this.app.twoD.screenToWorld(at.x, at.y, this.#target); this.arena?.throwAt(this.#target); }}/** * Keeps the arena in a frame narrower than 16:9. * * @remarks * A pixel-perfect `Camera2D` fits its whole-number zoom to `referenceResolution.y` alone, so a phone * held upright (390x844: zoom 9, about 2.4 m across) shows two columns of crates and neither coin. * Each frame this raises the reference height until the zoom also fits {@link NARROW_SPAN} across. * A frame wide enough for the authored shot keeps it untouched. The camera never drops far enough to * show the bottom of the earth. */export class FitArena extends Script implements ScriptCallbacks { /** The namespaced registration id. */ static typeId = "physics-2d/FitArena"; #camera: Camera2D | null = null; /** Finds the camera on this entity. */ awake(): void { this.#camera = this.entity.requireComponent(Camera2D); } /** Fits the zoom and the shot to the canvas as it is this frame. */ update(): void { const camera = this.#camera; const surface = this.app.renderer.surface; if (camera === null || surface === null || surface.width <= 0 || surface.height <= 0) { return; } const spanPx = (NARROW_SPAN.right - NARROW_SPAN.left) * PIXELS_PER_UNIT; const height = Math.max(REFERENCE_RESOLUTION.y, (spanPx * surface.height) / surface.width); if (camera.referenceResolution.y !== height) { camera.referenceResolution = { x: REFERENCE_RESOLUTION.x, y: height }; } // The zoom the camera will snap to, so the frame's bottom edge can be kept inside the earth. const zoom = Math.max(1, Math.floor(surface.height / height)); const halfHeight = surface.height / (2 * zoom * PIXELS_PER_UNIT); const y = Math.max(SHOT.y, -EARTH_ROWS + halfHeight); const at = this.transform.position2D; if (at.x !== SHOT.x || at.y !== y) { this.transform.position2D = new Vec2(SHOT.x, y); } }}/** * Builds the arena: the floor, the two walls, and the factories the panel and the pointer drive. * * @param app - The running app. * @param atlas - The props atlas every body draws from. * @param jitter - The kit's seeded generator, so two loads stack the crates the same way. * @returns The arena. */export function createArena(app: App, atlas: AssetHandle<SpriteAtlasAsset>, jitter: () => number): Arena { const sheet = atlas.value; const ground = sheet.requireFrame(FRAMES.ground); const coin = sheet.requireFrame(FRAMES.coin); const crates = FRAMES.crates.map((name: string) => sheet.requireFrame(name)); const earth = FRAMES.earth.map((name: string) => sheet.requireFrame(name)); for (let x = -GROUND_OVERHANG; x < ARENA_WIDTH + GROUND_OVERHANG; x += 1) { const tile = app.world.createEntity(`Floor ${String(x)}`); tile.transform.position2D = new Vec2(x + 0.5, 0.5); tile.addComponent(SpriteRenderer, { sprite: atlas, sortingLayer: "Terrain" }).frame = ground; // Earth under the floor, for a frame taller than the 16:9 one, where the floor would otherwise // be a strip over empty sky. The two faces alternate in a checker so the fill has no stripes. for (let row = 1; row <= EARTH_ROWS; row += 1) { const fill = app.world.createEntity(`Earth ${String(x)}.${String(row)}`); fill.transform.position2D = new Vec2(x + 0.5, 0.5 - row); fill.addComponent(SpriteRenderer, { sprite: atlas, sortingLayer: "Terrain" }).frame = earth[Math.abs(x + row) % earth.length] ?? ground; } } // One collider for the whole floor rather than nine: Rapier has less to test, and a body sliding // along it never catches on a seam between two boxes. const floor = app.world.createEntity("Floor"); floor.transform.position2D = new Vec2(ARENA_WIDTH / 2, 0.5); floor.addComponent(BoxCollider2D, { size: { x: ARENA_WIDTH, y: 1 } }); // The walls are off the edge of the 16:9 frame: they exist to keep a hard throw in the arena. for (const [name, x] of [ ["Wall left", -0.5], ["Wall right", ARENA_WIDTH + 0.5], ] as const) { const wall = app.world.createEntity(name); wall.transform.position2D = new Vec2(x, 4); wall.addComponent(BoxCollider2D, { size: { x: 1, y: 8 } }); } const bodies: Entity[] = []; const retire = (): void => { while (bodies.length > MAX_BODIES) { bodies.shift()?.destroy(); } }; const crate = (column: number, height: number): void => { const entity = app.world.createEntity(`Crate ${String(bodies.length)}`); // A hair of jitter from the kit's seeded generator, so a stack settles like a real one and two // loads of the same URL still settle identically. Examples never call `Math.random`. entity.transform.position2D = new Vec2(column + (jitter() - 0.5) * 0.04, height); entity.addComponent(SpriteRenderer, { sprite: atlas, sortingLayer: "Default" }).frame = crates[bodies.length % crates.length] ?? 0; entity.addComponent(BoxCollider2D, { size: { x: 0.94, y: 0.94 } }); entity.addComponent(Rigidbody2D, { mass: 4, angularDamping: 0.3 }); bodies.push(entity); retire(); }; const drop = (x: number, y: number): Entity => { const entity = app.world.createEntity(`Coin ${String(bodies.length)}`); entity.transform.position2D = new Vec2(x, y); entity.addComponent(SpriteRenderer, { sprite: atlas, sortingLayer: "Default" }).frame = coin; entity.addComponent(CircleCollider2D, { radius: 0.42, inlineMaterial: { friction: 0.4, restitution: 0.35 } }); entity.addComponent(Rigidbody2D, { mass: 1.2 }); bodies.push(entity); retire(); return entity; }; const throwAt = (target: Vec2Like): void => { const launch = drop(THROW_FROM.x, THROW_FROM.y).addComponent(Launch); const dx = target.x - THROW_FROM.x; const dy = target.y - THROW_FROM.y; const length = Math.hypot(dx, dy); // A throw is a velocity rather than an impulse: the coin should leave the hand at the same // speed whatever it weighs, which is what a player expects from a throw. if (length < 0.001) { launch.velocity.set(THROW_SPEED, 0); } else { launch.velocity.set((dx / length) * THROW_SPEED, (dy / length) * THROW_SPEED); } }; const reset = (): void => { for (const entity of bodies) { entity.destroy(); } bodies.length = 0; for (let row = 0; row < STACK_HEIGHT; row += 1) { for (const column of COLUMNS) { crate(column, 1.5 + row); } } for (const x of RESTING_COINS) { drop(x, 1.42); } }; reset(); return { bodies, crate, throwAt, reset };}import { Rigidbody2D, Script, Vec2 } from "ignifx";import type { MutableVec2, ScriptCallbacks } from "ignifx";/** * Gives a thrown body its opening velocity once 2D physics has built it. * * @remarks * A `Rigidbody2D` becomes a Rapier body at the start of the next fixed step, so a velocity written * in the frame the entity was created has no body to land on. This script waits for the body, writes * the velocity once, and is done; only thrown coins carry it, so a resting coin rolls when hit. */export class Launch extends Script implements ScriptCallbacks { /** The namespaced registration id. */ static typeId = "physics-2d/Launch"; /** The velocity to apply, in metres per second. Assigned when the body is thrown. */ readonly velocity: MutableVec2 = new Vec2(); #spent = false; fixedUpdate(): void { if (this.#spent) { return; } const body = this.entity.getComponent(Rigidbody2D); if (body === null || body.rapier.body === null) { return; } body.linearVelocity = this.velocity; this.#spent = true; }}/** * The numbers the arena and the panel both need: how big it is, where the crates stand, and which * frames of `props.atlas.json` the scene draws with. *//** * How wide the arena is, in metres. The camera shows a little under nine; with the panel open at * 1280x720 it covers everything right of about 7.1 m, so the props stand left of that. */export const ARENA_WIDTH = 9;/** Pixels per world metre: one 18-pixel tile is one metre. */export const PIXELS_PER_UNIT = 18;/** The design resolution the pixel-perfect camera fits a whole-number zoom to, in texels. */export const REFERENCE_RESOLUTION = { x: 160, y: 90 } as const;/** Where the camera looks, in metres: the arena's middle, with the floor along the frame's bottom. */export const SHOT = { x: ARENA_WIDTH / 2, y: 2.5 } as const;/** * What a frame narrower than 16:9 has to keep, in metres along X: both resting coins and every * crate. The throw starts just outside it, so a coin still arrives from the left edge. */export const NARROW_SPAN = { left: 0.9, right: 8.1 } as const;/** * How far the floor's grass and earth are drawn past each wall, in tiles. Only the art: a phone's * whole-number zoom can show a little more than the arena's width, and that should be more ground, * not a gap. */export const GROUND_OVERHANG = 2;/** How many rows of earth are drawn under the floor, so a tall frame ends in earth, not sky. */export const EARTH_ROWS = 8;/** Where the crates stand: the column centres, in metres. */export const COLUMNS = [3, 4, 5] as const;/** The middle column, which the panel's Throw button aims at. */export const MIDDLE_COLUMN = COLUMNS[1];/** How many crates high the opening stack is. */export const STACK_HEIGHT = 3;/** The atlas frames the scene draws with, by name in `props.atlas.json`. */export const FRAMES = { /** Grass-topped earth, the floor. */ ground: "props_0", /** Plain earth, two faces, under the floor. */ earth: ["props_4", "props_5"], /** Four crate faces, so a stack is not one texture repeated. */ crates: ["props_18", "props_21", "props_22", "props_23"], /** A gold coin: round, which is the whole reason it is here. */ coin: "props_95",} as const;/** How fast a thrown coin leaves the left edge, in metres per second. */export const THROW_SPEED = 15;/** Where a thrown coin starts, in metres. */export const THROW_FROM = { x: 0.5, y: 3.2 } as const;/** Two coins that start on the floor, so both collider shapes are in the opening frame. */export const RESTING_COINS = [1.4, 6.3] as const;/** How many bodies the arena keeps before the oldest is retired. */export const MAX_BODIES = 60;