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.

Off airLivePaused

2D physics

Physics · 2D

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

WebGPU: checking…

Try this

  1. Click or tap anywhere: a coin is thrown from the left edge at the point you picked.
  2. Drop crates until the tower leans, then knock it down and press Reset the stack.
  3. Open World, pull gravity to zero and throw again: the coin drifts in a straight line until it hits something.
Show source code
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, 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))],          },        ],      });    });  },});
arena.ts
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 };}
launch.ts
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;  }}
scene.ts
/** * 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;
Uses
Rigidbody2DBoxCollider2DCircleCollider2Dapp.physics2d.gravityCamera2DSpriteRenderer
Assets
Pixel Platformer tileset — CC0 1.0, Kenney