ignifx
All examples

2D physics

Physics2D

  • 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.

Nine wooden crates stacked three by three on a strip of grassy earth against a dusk-blue background, with a gold coin resting on the ground either side of them.
Open standalone

` devtools

WebGPU: checking…

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

main.ts
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)),            ],          },        ],      });    });  },});
arena.ts
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 };}
launch.ts
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;  }}

Uses:Rigidbody2DBoxCollider2DCircleCollider2Dapp.physics2d.gravityCamera2DSpriteRenderer

Assets:Pixel Platformer tileset — CC0 1.0, Kenney