All examples
2D physics
- Mouse
- Touch
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.

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 and pull gravity towards zero: the same throw turns into a slow drift.
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, Thrower } from "./arena.ts";import { Launch } from "./launch.ts";import { ARENA_WIDTH, COLUMNS, MIDDLE_COLUMN } 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: presents as bytes `31, 35, 51` (`#1F2333`), darker than the// platformer course so the crates and coins read against it. `rendering.clearColor` is decoded from// sRGB and not re-encoded (`packages/2d/src/settings.ts`), so this is `linearToSrgb(target / 255)`.const CLEAR_COLOR = { r: 0.3835, g: 0.4062, b: 0.4845, 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: 18 }), 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]); 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(ARENA_WIDTH / 2, 2.5); 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: { x: 160, y: 90 }, }); 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: [ 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 }; }, }, ), readout("Draw calls", (): string => String(app.renderer.drawCalls)), ], }, ], }); }); },});import { BoxCollider2D, CircleCollider2D, defineInputActions, Rigidbody2D, Script, SpriteRenderer, Vec2 } from "ignifx";import { Launch } from "./launch.ts";import { ARENA_WIDTH, COLUMNS, FRAMES, MAX_BODIES, RESTING_COINS, STACK_HEIGHT, THROW_FROM, THROW_SPEED,} from "./scene.ts";import type { App, AssetHandle, Entity, InputAction, MutableVec2, ScriptCallbacks, 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); }}/** * 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)); for (let x = 0; x < ARENA_WIDTH; 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; } // 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 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): Launch => { 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 }); const launch = entity.addComponent(Launch); bodies.push(entity); retire(); return launch; }; const throwAt = (target: Vec2Like): void => { const launch = drop(THROW_FROM.x, THROW_FROM.y); 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 body its opening velocity on the first fixed step it lives through. * * @remarks * A `Rigidbody2D` is a *description* until 2D physics builds it, and it builds bodies at the start * of the next fixed step — never mid-frame, so that Rapier's internal order follows entity creation * order. Writing `linearVelocity` in the same frame the entity was created therefore writes to a * body that does not exist yet and is silently lost (measured 2026-09-08). One fixed step later it * lands, which is what this script is for. */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; /** Scratch for the read-back that tells the script the write landed. */ readonly #check: MutableVec2 = new Vec2(); fixedUpdate(): void { if (this.#spent) { return; } const body = this.entity.getComponent(Rigidbody2D); if (body === null) { return; } body.linearVelocity = this.velocity; // Reading it back is what says the body exists: a write to a body that has not been built yet // is dropped, so the script asks again on the next step rather than assuming. body.linearVelocityToRef(this.#check); this.#spent = Math.hypot(this.#check.x, this.#check.y) > 0.001; }}