ignifx
GitHubnpm · soon
All examples

2D physics

Physics2D

  • Mouse
  • Touch

A collider makes an entity solid, a `Rigidbody2D` makes it move, and the pose comes back on `entity.transform.position2D` like any other. The only difference between a crate and a coin here is the collider shape — a box stacks and topples, a circle rolls — and the mass, which is exact kilograms. The floor and the two off-screen walls carry colliders and no body, which is what makes them static. Rapier is stepped by ignifx's own fixed loop at 60 Hz whatever the frame rate does, and dynamic bodies interpolate between steps so the motion stays smooth above it.

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

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";/** * Rapier 2D through `Rigidbody2D` and two collider shapes: a stack of crates to knock over and * coins to roll into them. * * A collider makes an entity solid; a `Rigidbody2D` makes it move; the pose comes back on * `entity.transform.position2D` like any other. The simulation is stepped by ignifx's own fixed * loop — no Babylon scene is involved — so it advances at a fixed rate whatever the frame rate * does, and dynamic bodies interpolate between steps so a 60 Hz simulation still looks smooth at * 144 Hz. * * Three things in here are worth reading for. * * - The **shape** is the difference between a crate and a coin: a `BoxCollider2D` stacks and *   topples, a `CircleCollider2D` rolls. Nothing else about the two bodies differs. * - **Mass is exact kilograms.** `mass: 0` would mean "weigh the colliders at 1 kg/m²"; the numbers *   below are chosen so a thrown coin can move a crate but not a wall of them. * - The floor and the two walls carry **colliders and no `Rigidbody2D`**, which gives them an *   implicit static body — placed once, at the start of 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