ignifx0.x · unpublished
GitHub

Recipes·skills/ignifx/references/recipes/platformer-controller-2d.md

Move a 2D platformer character

<!-- Generated by `pnpm docs:recipes`. Do not edit by hand. -->

CharacterController2D is a kinematic capsule or box that collides and slides through Rapier. It applies no gravity, so the script owns the vertical velocity — and with it coyote time, variable jump height, and terminal velocity.

Two fields make stairs and slopes feel right. stepOffset climbs a ledge without a jump, but autostep needs shape: "box": with the default capsule of radius 0.2 it clears about 0.15 m and no more. snapToGround keeps the character glued going down a slope instead of launching off the crest.

Units are metres and seconds with +Y up, exactly as in 3D; pixelsPerUnit is a rendering setting and changes nothing here. Only the first sixteen layers of ignifx.config.ts filter collisions — Rapier packs membership into 16 bits, and a collider above that logs IGX-1152.

typescript
import { twoD } from "@ignifx/2d";import { Script, Vec2, createApp, f32 } from "@ignifx/core";import { defineInputActions, input } from "@ignifx/input";import { BoxCollider2D, CharacterController2D, physics2d } from "@ignifx/physics-2d";import type { ScriptCallbacks } from "@ignifx/core";/** Runs, jumps, and cuts the jump short when the button is released. */class Platformer  extends Script.define({ speed: f32(6), jumpSpeed: f32(9), gravity: f32(-24), cutoff: f32(0.45) })  implements ScriptCallbacks{  static typeId = "recipes/Platformer";  #controller: CharacterController2D | null = null;  readonly #velocity = new Vec2();  readonly #step = new Vec2();  awake(): void {    this.#controller = this.entity.requireComponent(CharacterController2D);  }  fixedUpdate(dt: number): void {    const controller = this.#controller;    if (controller === null) {      return;    }    const jump = this.app.input.actions.get("jump");    this.#velocity.x = this.app.input.actions.get("move").axis * this.speed;    if (controller.isGrounded) {      this.#velocity.y = jump.isPressed ? this.jumpSpeed : 0;    } else {      if (jump.wasReleasedThisFrame && this.#velocity.y > 0) {        // Releasing early cuts the rise short: the classic variable-height jump.        this.#velocity.y *= this.cutoff;      }      this.#velocity.y += this.gravity * dt;    }    this.#step.set(this.#velocity.x * dt, this.#velocity.y * dt);    controller.move(this.#step);  }}const app = await createApp({ headless: true, extensions: [twoD({ pixelsPerUnit: 16 }), physics2d(), input()] });app.registerComponents([Platformer]);const strafe = { composite: "1DAxis", negative: "<Keyboard>/a", positive: "<Keyboard>/d" };const player = {  name: "Player",  actions: [    { name: "move", type: "axis", bindings: [strafe] },    { name: "jump", bindings: [{ path: "<Keyboard>/space" }] },  ],} as const;app.input.loadActions(defineInputActions({ maps: [player] }));const ground = app.world.createEntity("Ground", { position: { x: 0, y: -0.5, z: 0 } });ground.addComponent(BoxCollider2D, { size: { x: 40, y: 1 } });const hero = app.world.createEntity("Hero", { position: { x: 0, y: 1, z: 0 } });// Autostep needs a box: the default capsule clears about 0.15 m however big `stepOffset` is.hero.addComponent(CharacterController2D, {  shape: "box",  radius: 0.3,  height: 1.2,  stepOffset: 0.3,  snapToGround: 0.2,});hero.addComponent(Platformer);await app.start();app.input.simulate({ "<Keyboard>/d": 1, "<Keyboard>/space": 1 });for (let step = 0; step < 60; step += 1) {  app.step(1 / 60);}app.log.info("hero x, y:", hero.transform.position2D.x, hero.transform.position2D.y);app.dispose();

Source: examples/recipes/platformer-controller-2d/main.ts