All examples
Character controller
- Keyboard
- Gamepad
- Touch
- Mouse
A `CharacterController` is a kinematic capsule that collides and slides. It applies no gravity of its own, so the script owns the vertical speed — and therefore owns jump feel, the coyote window, and the ground snap that keeps the capsule on the surface instead of skipping down it. Movement is simulation, so it runs in `fixedUpdate`, and it is camera-relative, so the same code reads a keyboard, a gamepad stick and an on-screen thumbstick. Both ramps are built from their angles rather than eyeballed, so 30° and 60° are measured by construction.

WebGPU: checking…See browser support
Try this
- Walk up the pale ramp, then try the dark one: 30° is inside the slope limit and 60° is not.
- Drop the slope limit to 20° and the ramp you just climbed becomes a wall.
- Turn on the on-screen controls: the thumbstick drives the same action a keyboard does.
Show source
Source
import { Camera, CharacterController, createMaterialAsset, MeshAsset, MeshRenderer, pbrMaterialDefinition, physics, VirtualButton, VirtualJoystick,} from "ignifx";import { bootExample } from "../_kit/boot.ts";import { attachOrbit } from "../_kit/orbit.ts";import { bind, button, readout, select, slider, toggle } from "../_kit/panel.ts";import { createLightRig } from "../_kit/stage.ts";import { buildCourse, gentleRampPoint, GENTLE_DEGREES, SPAWN, STEEP_DEGREES } from "./course.ts";import { CHARACTER_ACTIONS, CharacterMotor, describeSlope, EYE_OFFSET } from "./motor.ts";import type { ColorLike } from "ignifx";/** * A `CharacterController`: a kinematic capsule that collides and slides, walks up a 30° ramp, * refuses a 60° one, stays on the surface on the way back down, and jumps. * * This file is the world and the panel. The lesson — the script that turns two actions into one * displacement per fixed step, and the slope rule it applies itself — is `motor.ts` beside it, and * the course whose angles are exact by construction is `course.ts`. * * The capsule is a `CharacterController` and nothing else: no `Rigidbody`, no collider component. * The controller *is* the body, it is kinematic, and it applies no gravity of its own. *//** The clear colour: the site's dark `--bg`. */const CLEAR: ColorLike = { r: 0.051, g: 0.063, b: 0.082, a: 1 };/** The capsule's total height, in metres. */const HEIGHT = 1.8;/** The capsule's radius, in metres. */const RADIUS = 0.35;/** * Where Havok's WebAssembly binary is served from. * * @remarks * `@ignifx/physics` declares the file in `ignifx.assets.public`, so the Vite plugin copies it * **unhashed, by base name** into the public asset path. It is named here rather than left at * `havokWasm: "auto"`, which resolves the bare file name through the asset manifest, whose `root` is * the *relative* path `assets`: on a page served from `/examples/<slug>/run/` that resolves against * the document and asks for `…/run/assets/HavokPhysics.wasm`, which is a 404 and then `IGX-0903`. * Vite's own `BASE_URL` is the base the plugin wrote its URLs from, so this is right in `dev` and in * `build`. */const HAVOK_WASM = `${import.meta.env.BASE_URL}assets/HavokPhysics.wasm`;/** * The slope limits the panel offers, as the labels it shows them under. * * @remarks * Three limits, none of them equal to a ramp's angle. `20°` puts the gentle ramp out of reach and * is the one worth trying: the surface the capsule walked up a moment ago becomes a wall. A limit * exactly equal to a ramp's angle — 30° or 60° — lands on the comparison's own boundary, where * whether the capsule climbs comes down to the last bit of a float, so neither is offered. */const SLOPE_LIMITS: readonly string[] = ["20°", "45°", "65°"];/** The slope limit the panel opens on: between the two ramps, which is the point of the course. */const START_SLOPE_LIMIT = "45°";/** The capsule's colour. */const HERO_COLOR: ColorLike = { r: 0.878, g: 0.412, b: 0.169, a: 1 };/** The plateau's and the gentle ramp's colour: the surfaces the capsule can walk. */const SLAB_COLOR: ColorLike = { r: 0.38, g: 0.41, b: 0.48, a: 1 };/** The steep ramp's colour, darker so the frame says which ramp is refused before you try it. */const STEEP_COLOR: ColorLike = { r: 0.2, g: 0.22, b: 0.29, a: 1 };/** The grid floor's tint. */const FLOOR_COLOR: ColorLike = { r: 0.15, g: 0.17, b: 0.21, a: 1 };/** How far the camera sits from what it is looking at, in metres. */const CAMERA_DISTANCE = 8;/** The camera's opening yaw, in degrees: straight behind the capsule, so forward is up the course. */const CAMERA_YAW = 0;/** The camera's opening pitch, in degrees: a third-person look over the capsule's shoulder. */const CAMERA_PITCH = 26;/** The character the panel writes an angle with, and strips before reading one back. */const DEGREE_SIGN = "°";/** The capture camera's yaw, in degrees: three-quarters on, so both ramps show their slope. */const STATIC_YAW = 26;/** The capture camera's pitch, in degrees. Steep enough to keep the horizon out of the frame. */const STATIC_PITCH = 29;/** How far up the gentle ramp the capsule is posed for a capture. */const STATIC_FRACTION = 0.55;/** Where the camera looks under `?static=1`: the middle of the course rather than the capsule. */const STATIC_FOCUS = { x: -0.2, y: 1.05, z: 1.9 } as const;/** How far the camera sits from the course under `?static=1`, in metres. */const STATIC_DISTANCE = 10.5;bootExample({ title: "Character controller", extensions: [physics()], settings: { rendering: { clearColor: CLEAR, msaaSamples: 4, features: { shadows: true } }, time: { fixedDeltaTime: 1 / 60 }, physics: { havokWasm: HAVOK_WASM }, }, async setup({ app, panel, flags }) { app.registerComponents([CharacterMotor]); app.input.loadActions(CHARACTER_ACTIONS); const slab = createMaterialAsset( app, pbrMaterialDefinition({ name: "slab", baseColor: SLAB_COLOR, metallic: 0, roughness: 0.82 }), [], ); const steep = createMaterialAsset( app, pbrMaterialDefinition({ name: "steep", baseColor: STEEP_COLOR, metallic: 0, roughness: 0.86 }), [], ); await buildCourse(app, { slab, steep, floor: FLOOR_COLOR }); // `?static=1` never runs a fixed step, so the capsule has to be *authored* where the poster // wants it: half way up the gentle ramp, feet on the surface. const stand = flags.isStatic ? gentleRampPoint(STATIC_FRACTION) : SPAWN; const hero = app.world.createEntity("Hero"); hero.transform.localPosition.set(stand.x, stand.y + HEIGHT / 2, stand.z); const heroMaterial = createMaterialAsset( app, pbrMaterialDefinition({ name: "hero", baseColor: HERO_COLOR, metallic: 0.1, roughness: 0.4 }), [], ); hero.addComponent(MeshRenderer, { mesh: MeshAsset.capsule(app, { height: HEIGHT, radius: RADIUS }), materials: [heroMaterial], castShadows: true, }); const controller = hero.addComponent(CharacterController, { height: HEIGHT, radius: RADIUS, slopeLimit: 45 }); const eye = app.world.createEntity("Main Camera"); eye.addComponent(Camera, { near: 0.1, far: 300, fov: 46 }); const orbit = attachOrbit(app, eye, { yaw: flags.isStatic ? STATIC_YAW : CAMERA_YAW, pitch: flags.isStatic ? STATIC_PITCH : CAMERA_PITCH, distance: flags.isStatic ? STATIC_DISTANCE : CAMERA_DISTANCE, target: flags.isStatic ? STATIC_FOCUS : { x: stand.x, y: stand.y + HEIGHT / 2 + EYE_OFFSET, z: stand.z }, minDistance: 3, maxDistance: 30, }); const motor = hero.addComponent(CharacterMotor); // Under `?static=1` the motor keeps no camera, so the shot holds the authored pose above and // frames the whole course instead of following a capsule that will never move. motor.orbit = flags.isStatic ? null : orbit; // The key comes from in front and above, so both ramps' walkable faces are lit rather than // turned away from the only lamp in the scene. createLightRig(app, { focus: { x: 0, y: 0.9, z: 0 }, keyPosition: { x: -4.5, y: 6.5, z: -6 }, rimPosition: { x: 5, y: 3, z: 6 }, keyIntensity: 2.8, shadowMapSize: 2048, }); // The on-screen stick and button write `<Virtual>/joystick` and `<Virtual>/leap`, which the // action document already binds — so nothing else in this file knows a finger from a keyboard. let touch: { dispose(): void }[] = []; const setTouchControls = (on: boolean): void => { for (const widget of touch) { widget.dispose(); } touch = on ? [ new VirtualJoystick(app, { control: "joystick", style: { left: "1.5rem", bottom: "1.5rem" } }), new VirtualButton(app, { control: "leap", label: "▲", style: { right: "1.5rem", bottom: "1.5rem" } }), ] : []; }; setTouchControls(app.platform.isMobile); panel({ title: "Character controller", groups: [ { label: "Move", controls: [ slider( "Speed", { min: 1, max: 9, step: 0.2, format: (v: number): string => `${v.toFixed(1)} m/s` }, bind(motor, "speed"), ), slider( "Jump", { min: 0.2, max: 2.4, step: 0.1, format: (v: number): string => `${v.toFixed(1)} m` }, bind(motor, "jumpHeight"), ), // A `select` rather than a slider: `slopeLimit` is handed to Havok when the capsule is // built, so changing it rebuilds the controller — three choices means three rebuilds, // not one per pixel of drag. select("Slope limit", SLOPE_LIMITS, { value: START_SLOPE_LIMIT, change: (value: string): void => { controller.slopeLimit = Number(value.replace(DEGREE_SIGN, "")); controller.rebuild(); }, }), toggle("On-screen controls", { value: app.platform.isMobile, change: setTouchControls }), button("Respawn", (): void => { motor.respawn(); }), ], }, { label: "Ground", controls: [ // Two answers, because they disagree and the difference is the lesson: what Havok's // own probe says, and what the script decides from the surface normal. readout("Standing", (): string => (motor.onGround ? "yes" : "no")), readout("Havok probe", (): string => controller.supportState), readout("Slope under foot", (): string => describeSlope(controller.groundNormal.y)), readout("Speed", (): string => `${controller.velocity.length().toFixed(1)} m/s`), readout("Air under foot", (): string => `${motor.gap.toFixed(2)} m`), readout("Height", (): string => `${hero.transform.position.y.toFixed(2)} m`), ], }, { label: "Course", collapsed: true, controls: [ readout("Gentle ramp", (): string => `${String(GENTLE_DEGREES)}°`), readout("Steep ramp", (): string => `${String(STEEP_DEGREES)}°`), readout("Last device", (): string => (app.input.currentScheme === "" ? "none" : app.input.currentScheme)), readout("Draw calls", (): string => String(app.renderer.drawCalls)), ], }, ], }); },});/** * The script that walks the capsule, and the actions it reads. * * Three things are worth knowing before reading it. * * **The controller applies no gravity.** A bare `CharacterController` is purely kinematic: * `move(displacement)` is the whole input, and this script owns the vertical speed — which is why * it also owns jump feel, including the coyote window that keeps a jump alive for a moment after * walking off a ledge. * * **Movement is simulation, so it runs in `fixedUpdate`.** The input captured at frame start reads * the same in every fixed step of that frame, so 30 fps and 240 fps produce the same trajectory. * `move` accumulates within a step, so calling it twice is one displacement. * * **The slope rule is applied here, not by `isGrounded`.** See {@link isWalkable}; it is the one * thing about `CharacterController` a reader should take away from this example. */import { CharacterController, defineInputActions, degToRad, f32, radToDeg, Script, Vec3 } from "ignifx";import { SPAWN } from "./course.ts";import type { OrbitCamera } from "../_kit/orbit.ts";import type { ScriptCallbacks, Vec3Like } from "ignifx";/** How fast the capsule walks, in metres per second. */const START_SPEED = 4.2;/** How high a jump reaches, in metres. */const START_JUMP = 1.1;/** Downward acceleration the script applies itself, in metres per second squared. */const GRAVITY = -19;/** How long after leaving the ground a jump still fires, in seconds. */const COYOTE_SECONDS = 0.12;/** * How far below its feet the capsule will still snap down to, in metres. * * @remarks * Bigger than any lip on the course and smaller than a fall. Inside it the capsule is on the * ground and the gap is closed in one step; outside it the capsule is in the air and gravity has * it. */const SNAP_DISTANCE = 0.4;/** * How hard a grounded capsule is pushed downwards, in metres per second. * * @remarks * This is the ground snap, and it is the number that makes the difference between a controller that * walks and one that hovers. `CharacterController.isGrounded` is Havok's `checkSupport` probe, whose * reach is a step of gravity — about 0.16 m at 60 Hz — so a capsule that has just walked off a * 0.28 m kerb is *still reported grounded* while a hand's width of air is under its feet. Zeroing * the fall speed there leaves it hovering: measured on 2026-09-08, the capsule crossed the kerb and * then walked the rest of the course 0.143 m above the floor, "supported" the whole way, because * the only thing pulling it down was one step of gravity per step. * * A constant downward push closes that gap in about a tenth of a second and costs nothing on flat * ground, where collide-and-slide absorbs it. It also *is* the feature: walking down the ramp keeps * the capsule on the surface instead of launching it off every lip. Small on purpose — on a slope * the push resolves along the surface, so a big one would drag a climbing capsule backwards. */const GROUND_STICK = 2.5;/** Where the camera looks relative to the capsule's centre, in metres. */export const EYE_OFFSET = 0.4;/** Straight down, for the snap ray. A module constant, so the per-step path allocates nothing. */const DOWN: Vec3Like = { x: 0, y: -1, z: 0 };/** The flattest a surface is treated as, when the resting height is worked out from its normal. */const MIN_NORMAL_Y = 0.3;/** * The actions the capsule is driven by, on every device the browser offers. * * @remarks * Its own map, so the kit orbit camera's `KitOrbit` map is untouched — `loadActions` merges by map * name. The control schemes are declared so `app.input.currentScheme` follows whichever device last * produced input, which is what the panel's "Last device" readout shows; scheme tags do not filter * bindings unless `input: { strictSchemes: true }`, so a gamepad works whatever the scheme says. * * `<Virtual>/…` is what `@ignifx/ui`'s `VirtualJoystick` and `VirtualButton` write, so the * on-screen controls need nothing here that a thumbstick does not. */export const CHARACTER_ACTIONS = defineInputActions({ controlSchemes: [ { name: "KeyboardMouse", devices: ["Keyboard", "Mouse"] }, { name: "Gamepad", devices: ["Gamepad"] }, { name: "Touch", devices: ["Touch", "Virtual"] }, ], maps: [ { name: "Character", actions: [ { name: "walk", type: "vector2", bindings: [ { composite: "2DVector", up: "<Keyboard>/w", down: "<Keyboard>/s", left: "<Keyboard>/a", right: "<Keyboard>/d", }, { composite: "2DVector", up: "<Keyboard>/arrowUp", down: "<Keyboard>/arrowDown", left: "<Keyboard>/arrowLeft", right: "<Keyboard>/arrowRight", }, { path: "<Gamepad>/leftStick", processors: ["deadzone(0.15)"] }, { path: "<Gamepad>/dpad" }, { path: "<Virtual>/joystick", processors: ["deadzone(0.15)"] }, ], }, { name: "leap", type: "button", bindings: [{ path: "<Keyboard>/space" }, { path: "<Gamepad>/buttonSouth" }, { path: "<Virtual>/leap" }], }, ], }, ],});/** * Walks and jumps a `CharacterController` from the `walk` and `leap` actions, relative to where the * camera is looking, and drags the camera's target along behind it. * * @remarks * Camera-relative movement is two lines rather than a matrix: the orbit camera's yaw is the only * rotation in the shot, so forward is `(−sin yaw, 0, cos yaw)` and right is `(cos yaw, 0, sin yaw)` * in ignifx's left-handed, `+Z`-forward space. Reading the field rather than the transform is * deliberate — the transform carries the *damped* yaw, and a control that lags its own camera feels * broken. */export class CharacterMotor extends Script.define({ speed: f32(START_SPEED, { min: 0, tooltip: "Walking speed, in metres per second." }), jumpHeight: f32(START_JUMP, { min: 0, tooltip: "How high a jump reaches, in metres." }), gravity: f32(GRAVITY, { tooltip: "Downward acceleration the script applies, in m/s²." }), coyoteSeconds: f32(COYOTE_SECONDS, { min: 0, tooltip: "Grace period after a ledge in which a jump still fires." }), }) implements ScriptCallbacks{ /** The namespaced registration id. */ static typeId = "character-controller/CharacterMotor"; /** The camera this walks relative to, and whose target it drags along. Assigned after attach. */ orbit: OrbitCamera | null = null; /** The controller, found once. */ #controller: CharacterController | null = null; /** Whether the surface under the capsule was walkable, and within reach, at the last step. */ #onGround = false; /** The gap between the capsule's feet and the surface under them, in metres. */ #gap = 0; /** The vertical speed the script owns, in metres per second. */ #fall = 0; /** How long the capsule has been off the ground, in seconds. */ #airborne = Number.POSITIVE_INFINITY; /** Reused so the per-step path allocates nothing (coding standards §7). */ readonly #step = new Vec3(); /** Finds the controller. */ awake(): void { this.#controller = this.entity.requireComponent(CharacterController); } /** * Reads the frame's input, integrates gravity, and hands the controller one displacement. * * @param dt - The fixed step, in seconds. */ fixedUpdate(dt: number): void { const controller = this.#controller; if (controller === null) { return; } this.#gap = this.#gapBelow(controller); this.#onGround = this.#gap <= SNAP_DISTANCE && isWalkable(controller); this.#airborne = this.#onGround ? 0 : this.#airborne + dt; if (this.#onGround && this.#fall < 0) { // The ground snap. A gap the probe is willing to tolerate is closed in one step; with none // left, a small constant push keeps the capsule on the surface. See {@link GROUND_STICK}. this.#fall = this.#gap > 0 ? -this.#gap / dt : -GROUND_STICK; } const jump = this.app.input.actions.find("leap"); if (jump?.wasPressedThisFrame === true && this.#airborne <= this.coyoteSeconds) { // From a height, not a speed: `v = sqrt(2·g·h)` is the jump a designer can actually author. this.#fall = Math.sqrt(2 * Math.abs(this.gravity) * this.jumpHeight); // Spend the window, so one press is one jump. this.#airborne = Number.POSITIVE_INFINITY; } this.#fall += this.gravity * dt; const walk = this.app.input.actions.find("walk")?.vector ?? { x: 0, y: 0 }; const yaw = degToRad(this.orbit?.yaw ?? 0); const forwardX = -Math.sin(yaw); const forwardZ = Math.cos(yaw); this.#step.set( (walk.x * forwardZ + walk.y * forwardX) * this.speed * dt, this.#fall * dt, (walk.y * forwardZ - walk.x * forwardX) * this.speed * dt, ); controller.move(this.#step); } /** Drags the camera's target onto the capsule, so the shot follows without a second component. */ update(): void { const orbit = this.orbit; if (orbit === null) { return; } const at = this.transform.position; orbit.target = { x: at.x, y: at.y + EYE_OFFSET, z: at.z }; } /** * Whether the surface under the capsule is one it can stand on. Read by the panel. * * @returns `true` when the last step found a walkable surface within reach. */ get onGround(): boolean { return this.#onGround; } /** * How much air is under the capsule's feet. Read by the panel. * * @returns The gap in metres; `0` when the capsule is resting. */ get gap(): number { return Math.max(0, this.#gap); } /** * How far the surface under the capsule is below its feet. * * @remarks * One downward ray, and it is what makes the snap exact rather than approximate. It is also the * only reliable answer available: Havok's support probe reaches about a step of gravity — 0.16 m * at 60 Hz — and reports a capsule that far off the ground as *supported*, so a controller that * trusted it hovered a hand's width above the floor for the rest of the level (measured * 2026-09-08: 0.143 m, indefinitely, after one small drop). * * A query answers only after one completed fixed step, so the first step of a run — and every * frame under `?static=1`, where no fixed step ever runs — reports no gap rather than `IGX-0902`. * * @param controller - The capsule, for its height and its skin width. * @returns The gap in metres; `0` when the capsule is resting or nothing is under it. */ #gapBelow(controller: CharacterController): number { if (!this.app.physics.hasStepped) { return 0; } // How far the surface *should* be, straight down, when the capsule is resting on it. On a // slope that is more than half the capsule's height: the bottom cap touches the surface at a // point offset along the normal, so the vertical drop from the centre is the cap's radius // divided by the cosine of the slope. Without that term a capsule standing perfectly still on // the 30° ramp measures a 0.06 m "gap" and the snap below drags it back down the hill. const lean = Math.max(controller.groundNormal.y, MIN_NORMAL_Y); const feet = controller.height / 2 - controller.radius + (controller.radius + controller.skinWidth) / lean; const hit = this.app.physics.raycast(this.transform.position, DOWN, feet + SNAP_DISTANCE); return hit === null ? Number.POSITIVE_INFINITY : Math.max(0, hit.distance - feet); } /** * Puts the capsule back at the start line. * * @remarks * `teleport` rather than an assignment to the transform: it moves the controller without * integrating, and clears the swept motion Havok was carrying. */ respawn(): void { const controller = this.#controller; if (controller === null) { return; } this.#fall = 0; this.#airborne = Number.POSITIVE_INFINITY; controller.teleport({ x: SPAWN.x, y: SPAWN.y + controller.height / 2, z: SPAWN.z }); }}/** * Whether the surface under the capsule is one it can stand on. * * @remarks * **`CharacterController.isGrounded` is not that question.** It reports Havok's own `checkSupport` * classification, and Babylon Lite 1.27.0 builds its character with `staticFriction = 0` * (`index.d.ts`, `PhysicsCharacterController`) — so a capsule on *any* incline is a capsule sliding * down it, and `isGrounded` reads `false` the moment the floor tilts. Measured on 2026-09-08: on the * 30° ramp below, `supportState` reported `"sliding"` and the capsule crept up 0.16 m and slid back. * * So the slope rule is applied here instead, against the surface normal and the controller's own * `slopeLimit` — which is exactly what `ThirdPersonController` in `@ignifx/3d` does, and what * `skills/ignifx/references/recipes/character-controller-3d.md` says a real game reaches for. The * `slopeLimit` field still does its other job: Havok stops the capsule dead on anything steeper, * which is why the 60° ramp is refused rather than merely slippery. * * @param controller - The capsule. * @returns `true` when the surface under it is within `slopeLimit`. */function isWalkable(controller: CharacterController): boolean { return ( controller.supportState !== "unsupported" && controller.groundNormal.y >= Math.cos(degToRad(controller.slopeLimit)) );}/** * Writes the angle of the surface under the capsule. * * @param normalY - The ground normal's `y`, which is the cosine of the slope. * @returns The angle in degrees, or a dash when the capsule is in the air. */export function describeSlope(normalY: number): string { if (normalY <= 0) { return "—"; } return `${radToDeg(Math.acos(Math.min(1, normalY))).toFixed(0)}°`;}/** * The course the capsule is asked to walk: two ramps whose angles are exact by construction, and * the plateau one of them reaches. * * It is next to `main.ts` for the reason `pbr-model`'s `shot.ts` is: the character controller is the * lesson and the level is composition. But one thing in here *is* load-bearing, and it is why this * file exists at all: **the site says the ramps are 30° and 60°, so they have to be 30° and 60°.** * * ## How a ramp is placed * * A ramp is a box turned about X. Rather than guess a rotation and a centre, {@link addRamp} is * given the angle, the height to climb, and where the *high* edge of the walkable surface should * sit, and it solves for the rest: * * - the horizontal run is `rise / tan(angle)`, so the surface really does rise at `angle`; * - the box's length along the slope is `rise / sin(angle)`; * - the top surface's normal after a rotation of `-angle` about X is `(0, cos a, -sin a)`, so the * box's centre is the surface's midpoint pushed half a thickness *down* that normal. * * A rotation of `-angle` — not `+angle` — because ignifx is left-handed with `+Z` forward: about X, * `y' = y·cos a - z·sin a`, so a positive angle tips `+Z` **down**. * * ## There is no kerb, and that is a finding rather than an omission * * A `CharacterController` cannot climb a step. Babylon Lite 1.27.0's * `PhysicsCharacterControllerOptions` is two numbers — `capsuleHeight` and `capsuleRadius` — and its * `PhysicsCharacterController` carries no step height and no auto-step at all (read off the pinned * `index.d.ts`, 2026-09-08). Collide-and-slide against a vertical face gives a capsule nothing to * climb with, and a 0.28 m kerb stopped this one dead: measured the same day, it walked to * `z = -3.99` — the kerb's face plus its radius and its skin width — and stayed there. * * A kerb built as a 20° hump instead of a step does not work either, at this capsule's size: the * crest is a ridge two 0.4 m slabs meet at, the 0.35 m capsule touches the far slab before its * lowest point crosses the ridge, and Havok resolves that contact as a wall. Measured the same day: * `Standing yes, slope 20°`, and the capsule sat on the ridge indefinitely. * * So the course has no kerb. The site's copy says so too. */import { BoxCollider, degToRad, MeshAsset, MeshRenderer } from "ignifx";import { createGridGround } from "../_kit/stage.ts";import type { App, AssetHandle, ColorLike, MaterialAsset } from "ignifx";/** The visible ground plane's edge length, in metres. Wide enough to run out of every frame. */const GROUND_SIZE = 140;/** How deep the floor's collision box is, in metres. Its top face sits at `y = 0`. */const FLOOR_DEPTH = 1;/** How thick a ramp is, in metres. */const RAMP_THICKNESS = 0.4;/** The gentle ramp's angle, in degrees. Inside the controller's opening 45° slope limit. */export const GENTLE_DEGREES = 30;/** The steep ramp's angle, in degrees. Outside it. */export const STEEP_DEGREES = 60;/** How high both ramps climb, in metres. */export const RISE = 1.6;/** How wide each ramp lane is, in metres. The two lanes very nearly touch. */const LANE_WIDTH = 3;/** Where the gentle ramp's centre line runs, in metres along X. */export const GENTLE_X = -1.6;/** Where the steep ramp's centre line runs, in metres along X. */export const STEEP_X = 1.6;/** Where the walkable surface of the plateau starts, in metres along Z. */const PLATEAU_Z = 4.2;/** How deep the plateau is, in metres. */const PLATEAU_DEPTH = 5;/** * How far each ramp's crest stands above the plateau it delivers the capsule onto, in metres. * * @remarks * Three centimetres, and it is not cosmetic. A ramp whose crest is exactly flush with the plateau * leaves the plateau's own vertical face buried under it with its top corner in the same place as * the walkable surface — and a capsule climbing the last few centimetres touches that corner, whose * contact normal is almost horizontal. Havok resolves that as a wall and the capsule stops a hand's * width from the top (measured 2026-09-08, before this lip existed). Carrying the ramp a little * **past** the surface it meets puts the corner below the ramp's own face. The slope is unchanged: * the run is derived from the rise, so 30° is still 30°. */const CREST_LIP = 0.03;/** Where the character starts, in metres: on the floor, in front of both ramps. */export const SPAWN = { x: 0, y: 0, z: -4.6 } as const;/** The materials the course is built from. */export interface CourseLook { /** The plateau and the gentle ramp: the surfaces the capsule walks up. */ readonly slab: AssetHandle<MaterialAsset>; /** The steep ramp, darker so the frame says which one is refused before you try it. */ readonly steep: AssetHandle<MaterialAsset>; /** The grid floor's tint. */ readonly floor: ColorLike;}/** What {@link buildCourse} produced. */export interface Course { /** Releases every handle the course holds. */ release(): void;}/** A point on a ramp's walkable surface, in metres. */export interface RampPoint { /** Metres along X. */ readonly x: number; /** Metres above the floor. */ readonly y: number; /** Metres along Z. */ readonly z: number;}/** * Where the gentle ramp's walkable surface is, a fraction of the way up. * * @remarks * `main.ts` uses it to pose the capsule for `?static=1`: the poster has to show the capsule *on* the * ramp, and no fixed step ever runs to put it there. * * @param fraction - `0` at the low edge, `1` at the crest. * @returns The point on the surface. */export function gentleRampPoint(fraction: number): RampPoint { const rise = RISE + CREST_LIP; const run = rise / Math.tan(degToRad(GENTLE_DEGREES)); return { x: GENTLE_X, y: rise * fraction, z: PLATEAU_Z - run * (1 - fraction) };}/** Where one ramp goes and how steep it is. */interface RampPlacement { /** The angle of the walkable surface, in degrees. */ readonly degrees: number; /** How high it climbs, in metres. */ readonly rise: number; /** Where its centre line runs, in metres along X. */ readonly x: number; /** Where the *high* edge of its walkable surface sits, in metres along Z. */ readonly topZ: number;}/** * Adds one static ramp: a box turned about X, sized and placed from its angle and its climb. * * @param app - The running app. * @param material - The ramp's material. * @param place - The angle, the climb, and where the high edge sits. * @returns The mesh handle, so the caller can release it. */function addRamp(app: App, material: AssetHandle<MaterialAsset>, place: RampPlacement): AssetHandle<MeshAsset> { const angle = degToRad(place.degrees); const run = place.rise / Math.tan(angle); const length = place.rise / Math.sin(angle); const mesh = MeshAsset.box(app, { width: LANE_WIDTH, height: RAMP_THICKNESS, depth: length }); const entity = app.world.createEntity(`Ramp ${String(place.degrees)}`); entity.transform.localPosition.set( place.x, place.rise / 2 - (RAMP_THICKNESS / 2) * Math.cos(angle), place.topZ - run / 2 + (RAMP_THICKNESS / 2) * Math.sin(angle), ); entity.transform.localEulerAngles = { x: -place.degrees, y: 0, z: 0 }; entity.addComponent(MeshRenderer, { mesh, materials: [material], castShadows: true }); entity.addComponent(BoxCollider, { size: { x: LANE_WIDTH, y: RAMP_THICKNESS, z: length } }); return mesh;}/** Where one block goes and how big it is. */interface BlockPlacement { /** Where its centre runs, in metres along X. */ readonly x: number; /** Where its centre runs, in metres along Z. */ readonly z: number; /** How wide it is, in metres. */ readonly width: number; /** How deep it is, in metres. */ readonly depth: number; /** How high its top face sits above the floor, in metres. */ readonly top: number;}/** * Adds one static block that stands on the floor, with its top face at `top`. * * @remarks * A solid block rather than a floating slab, so the plateau reads as something you step onto rather * than a sheet hanging in the air — and so a capsule that walks into its side meets a wall. * * @param app - The running app. * @param name - The entity's name. * @param material - The block's material. * @param box - Where it is, how big, and how high its top face sits. * @returns The mesh handle, so the caller can release it. */function addBlock( app: App, name: string, material: AssetHandle<MaterialAsset>, box: BlockPlacement,): AssetHandle<MeshAsset> { const mesh = MeshAsset.box(app, { width: box.width, height: box.top, depth: box.depth }); const entity = app.world.createEntity(name); entity.transform.localPosition.set(box.x, box.top / 2, box.z); entity.addComponent(MeshRenderer, { mesh, materials: [material], castShadows: true }); entity.addComponent(BoxCollider, { size: { x: box.width, y: box.top, z: box.depth } }); return mesh;}/** * Builds the course: the floor, the two ramps, and the plateau they lead to. * * @remarks * Every piece is a collider with **no `Rigidbody`**, which is placed once as a static body. Moving * one afterwards would log `IGX-0901`; scenery never moves, so that is exactly what it wants. * * @param app - The running app. * @param look - The three materials. * @returns The release for the handles this created. */export async function buildCourse(app: App, look: CourseLook): Promise<Course> { const ground = await createGridGround(app, { size: GROUND_SIZE, color: look.floor }); ground.entity.addComponent(BoxCollider, { size: { x: GROUND_SIZE, y: FLOOR_DEPTH, z: GROUND_SIZE }, center: { x: 0, y: -FLOOR_DEPTH / 2, z: 0 }, }); const meshes = [ addBlock(app, "Plateau", look.slab, { x: 0, z: PLATEAU_Z + PLATEAU_DEPTH / 2, width: LANE_WIDTH * 2, depth: PLATEAU_DEPTH, top: RISE, }), addRamp(app, look.slab, { degrees: GENTLE_DEGREES, rise: RISE + CREST_LIP, x: GENTLE_X, topZ: PLATEAU_Z }), addRamp(app, look.steep, { degrees: STEEP_DEGREES, rise: RISE + CREST_LIP, x: STEEP_X, topZ: PLATEAU_Z }), ]; return { release(): void { for (const mesh of meshes) { mesh.release(); } ground.release(); }, };}