Physics
Back to the reference

Physics

The body format the engine defines and the seam an adapter registers through. The simulation lives in the adapter.

45 symbols

createPhysicsBody2dfunctionnacatamalon

createPhysicsBody2d(options: { id, name, body, collider } & Partial<TPhysicsSurface>) => TPhysicsBody2d

A complete flat collider from a partial description.

The factory exists so that "a rectangular collider" is one call instead of six fields somebody can forget: every record that reaches a document is complete by construction, which is what lets the format promise that a scene means the same thing whoever simulates it.

Parameters

options{ id, name, body, collider } & Partial<TPhysicsSurface>

What is known: at least its shape. Everything left out takes its default.

View source · physics/new_physics_body.ts:47

PHYSICS_LAYERSvariablenacatamalon

PHYSICS_LAYERS: 16

How many collision layers a scene has: the smaller of the two backends, not a number chosen for taste.

Rapier2D packs what a body is and what it hits into one 32-bit number, 16 bits each, so 16 is its hard ceiling; the 3D side could carry far more. The format takes the smaller, because a scene using layer 20 would simulate in three dimensions and collide wrongly in two without a word said, which is the backend-dependent meaning TPhysicsSurface exists to prevent.

Sixteen is also more than a game of this era uses: most projects name fewer than eight.

View source · physics/layers.ts:16

usePhysicsBody2dfunctionhooknacatamalon

usePhysicsBody2d(options: TPhysicsBody2dOptions) => TPhysicsBody2d

Gives this object a flat shape to collide with.

It does two things, and that is the whole design. It writes down what the object is made of, so a scene saved from code says it and comes back with it; and it hands that to whatever is installed to simulate, so it moves. One way in, whether the scene was typed by a person or opened from a file, which is what makes those two behave the same.

Where it is comes from the object, never from here: the object's own placement if it has one, and otherwise the placement of what it draws, which is where you put it with createSprite.

With nothing installed to simulate, this is still worth calling: the shape is recorded, the scene opens and saves without losing it, and it sits still. That is what a tool wants while somebody is building a level.

Parameters

optionsTPhysicsBody2dOptions

Its kind ('dynamic', 'static' or 'kinematic'), its shape, and what it is made of.

Example

const BROWN = getColor('#8b5a2b');

const Crate = (x: number, y: number) => {
    createSprite({ tint: BROWN, width: 24, height: 24, transform: { x, y } });
    usePhysicsBody2d({ body: 'dynamic', collider: { shape: 'rect', width: 24, height: 24 }, restitution: 0.2 });
};
View source · hooks/physics/use_physics_body.ts:72

usePhysicsBody3dfunctionhooknacatamalon

usePhysicsBody3d(options: TPhysicsBody3dOptions) => TPhysicsBody3d

Gives this object a shape in three dimensions to collide with. The sibling of usePhysicsBody2d; see it for why declaring and simulating are one call.

offset is where the shape sits inside the object, which is what a model imported standing on its own feet needs so that its collider is not buried half in the floor.

Parameters

optionsTPhysicsBody3dOptions

Its kind ('dynamic', 'static' or 'kinematic'), its shape, and what it is made of.

View source · hooks/physics/use_physics_body.ts:123

usePhysicsWorld2dfunctionhooknacatamalon

usePhysicsWorld2d(options: { id, gravity }) => TPhysicsWorld2d

Opens the flat simulation for this scene, and says how hard things fall.

Called in the scene's body, because gravity belongs to the scene and not to anything in it: a scene seen from above has none and a platformer does. Called anywhere else it is recorded on that object and nothing reads it, which is said out loud rather than ignored.

gravity is in pixels per second squared with +y pointing down, which is the way a sprite's y axis grows, so earth-ish is about 900 and positive.

Like a collider, this is worth calling with nothing installed to simulate: the scene records what it wants and comes back with it.

Parameters

options{ id, gravity }

How hard things fall (gravity, in pixels per second squared, +y down), and an id for a scene with more than one.

Example

export const Level: TSceneFn = () => {
    usePhysicsWorld2d({ gravity: { x: 0, y: 900 } });
    // ...then objects with usePhysicsBody2d, which fall in it.
    return createScene();
};
View source · hooks/physics/use_physics_world.ts:37

usePhysicsWorld3dfunctionhooknacatamalon

usePhysicsWorld3d(options: { id, gravity }) => TPhysicsWorld3d

Opens the simulation in three dimensions for this scene. See usePhysicsWorld2d.

gravity is in metres per second squared with +y up, so earth is -9.81: the opposite sign from the flat one, because there a placement's y grows downwards. The two disagree on purpose.

Parameters

options{ id, gravity }

How hard things fall (gravity, per second squared), and an id for a scene with more than one.

View source · hooks/physics/use_physics_world.ts:82

TCollider2dtypenacatamalon

TCollider2d: { shape, width, height } | { shape, radius } | { shape, vertices }

The shape a flat body collides with, centred on where the object is.

Centred and not cornered, because that is where this engine puts a picture too: a sprite's anchor is its middle unless somebody says otherwise, so a shape the same size as the art lines up with it without anybody working out an offset. An object drawn from its corner (anchor at zero, which is how a floor is usually written) is the case where the two differ, and there the placement is the corner and the shape is still around it.

The set is small and closed on purpose. It is what any 2D physics engine has in common, not a copy of one library's list: the document has to outlive whatever is simulating it, and a shape no backend can build would be a field that does nothing.

View source · physics/types/t_physics.ts:32

TCollider3dtypenacatamalon

TCollider3d: { shape, size } | { shape, radius } | { shape, radius, height } | { shape, geometry } | { shape, geometry }

The shape a body in three dimensions collides with, around the object's origin unless it carries an offset.

View source · physics/types/t_physics.ts:48

TPhysicsBody2dtypenacatamalon

TPhysicsBody2d: { _type, id, name, body, collider } & TPhysicsSurface

A flat collider on an object: what it is made of, and how it is driven.

Where it is is not here. The object owns that, by the standing rule that a component carries no placement of its own, which is also why moving the object moves its collider.

View source · physics/types/t_physics.ts:137

TPhysicsBodyTypetypenacatamalon

TPhysicsBodyType: 'static' | 'dynamic' | 'kinematic'

How a body is driven, and what it lets happen to it.

'static' never moves and nothing can push it: walls, the ground, the level. 'dynamic' is driven by gravity, forces and contacts, and is what "a physics object" usually means. 'kinematic' is driven by you and pushes others without being pushed back: a moving platform, a lift, a boss on rails.

View source · physics/types/t_physics.ts:13

TPhysicsSurfacetypenacatamalon

What a collider is made of: how a contact settles, and whether it settles at all.

Every field is required, and that is the point. This is the written-down truth of a scene, and an absent value would mean "whatever the adapter does", which would make the meaning of a document depend on who reads it. A scene that behaves differently under a different backend is exactly the coupling this format exists to prevent.

Properties

restitutionnumber

Bounciness: how much of the speed it arrived with it leaves with, 0 to 1.

It belongs to the pair and not to one body: an engine mixes the two colliders' values (usually by averaging), so a ball asking for 0.8 landing on a floor left at 0 bounces at 0.4. Set both sides.

frictionnumber
densitynumber

Weight comes from this and the shape's size, and is never set directly.

sensorboolean

Notices overlaps without settling them, so things pass straight through: a checkpoint, a pickup, a place that hurts.

layernumber

Which layer this body is on, 0 to 15.

One layer, and the restraint is deliberate: "what is this thing" has one answer in every game that has ever needed layers, and the moment something is on two, a table of what hits what stops being readable by eye. What varies is what it collides with.

collidesWithnumber

A mask of the layers this body collides with: 1 << n per layer, so ALL_LAYERS is everything and (1 << 0) | (1 << 2) is layers 0 and 2.

It is mutual, and that is the rule that surprises people. Two bodies meet only if each one's mask includes the other's layer, so a bullet that lists walls still passes through them unless walls also list bullets. Both backends work that way, so the format says so instead of papering over it: a one-sided rule would have to be faked on both sides and still would not mean the same thing in each.

View source · physics/types/t_physics.ts:87

TPhysicsWorldtypenacatamalon

TPhysicsWorld: TPhysicsWorld2d | TPhysicsWorld3d

Either simulation's settings. A scene has at most one, and it lives on the scene's root: gravity is a property of the scene and not of anything in it (a scene seen from above has none, a platformer does), and the root is an object like any other, so it needs no new home.

View source · physics/types/t_physics.ts:230

TPhysicsWorld2dtypenacatamalon

The flat simulation's settings for one scene.

gravity is in pixels per second squared with +y pointing DOWN, because that is the way a sprite's y axis grows and the simulation writes straight onto a placement. So earth-ish gravity is about 900, positive: the opposite sign from three dimensions. The disagreement is deliberate, because turning 2D into metres would put a pixels-per-metre factor into every flat game and every push, and buy a tidiness no author benefits from, since no scene mixes the two.

Properties

_type'physics-world-2d'
idstring

Its own id, like every other component. A world has no name (neither does a camera), and it still has to be addressable: a tool selects and removes it by id.

gravity{ x, y }
View source · physics/types/t_physics.ts:196

TPhysicsWorld3dtypenacatamalon

The simulation's settings in three dimensions. gravity is in metres per second squared with +y UP, so earth is { x: 0, y: -9.81, z: 0 }. See TPhysicsWorld2d for why the two disagree on purpose.

Properties

_type'physics-world-3d'
idstring
gravity{ x, y, z }
View source · physics/types/t_physics.ts:215

nacatamalon/physics2d

getPhysicsWorld2dfunctionnacatamalon/physics2d

getPhysicsWorld2d() => TPhysicsWorldHandle2d | null

The 2D physics world the running scene's document built, or null if it has none.

The seam between authored data and authored behaviour, and the 2D twin of the 3D adapter's accessor of the same name. A collider is data: the document declares it and this provider simulates it, no code on either side. Steering is not: a script attached to a box has no way to name the world the scene root declared, because that world never passed through the document. Without this the only reachable world is one the script opens itself, which is a second world with its own gravity and its own bodies, colliding with nothing in the scene.

Pair it with world.bodyOf(box) to reach the box's own collider: the document built the body, so the handle addRect returned went to this provider and never to the game.

null is the ordinary answer, not an error, and scripts must handle it: the editor's edit viewport deserializes the same document with no provider registered (see play.ts), so a script runs there with no world and must simply do nothing. That is what keeps the workspace from simulating while you author in it.

registerScript('walker', (self) => {
    const world = getPhysicsWorld2d();
    if (!world) return;                          // editing, not playing
    useUpdate(() => {
        // Looked up per frame: the body is registered as the box is built, so at init it may
        // not exist yet.
        world.bodyOf(self)?.setLinearVelocity(120, 0);
    });
});

Call it during scene init, which is when a script runs, so this is the ordinary case. Outside init there is no active game to compare against and the answer is always null.

View source · physics/rapier2d/provider.ts:67

installPhysics2dfunctionnacatamalon/physics2d

installPhysics2d() => void

Installs this as what simulates the flat half of a scene's physics. The one line a game writes, and the only one: everything else about physics is the engine's vocabulary, so a scene says what it is made of and this makes it move.

It is a call and not something that happens on import, for three separate reasons, any one of which would be enough. The engine declares itself free of side effects, so a bundler is allowed to drop an import nothing is used from, and a game that worked in development would sit still once built. A tool must be able to open a scene and NOT simulate it, which is what lets you build a level in a workspace that holds still. And a game may install the solid half as well, which it does the same way, by saying so.

import { installPhysics2d } from 'nacatamalon/physics2d';

installPhysics2d();

Installing twice does nothing the second time.

View source · physics/rapier2d/provider.ts:177

loadRapier2Dfunctionnacatamalon/physics2d

loadRapier2D() => Promise<__module>

Load and initialize the Rapier2D WebAssembly module.

Memoizes the promise globally, so multiple calls return the same promise. The WASM is loaded once per page, shared across all scenes and worlds.

-compat and not the plain package, and this is a portability decision rather than a preference. @dimforge/rapier2d reaches its WebAssembly through an ESM import of the .wasm file itself (import * as wasm from './rapier_wasm2d_bg.wasm'), which only works under a bundler that implements WASM-ESM integration. Vite does, with vite-plugin-wasm, and that is why this worked for as long as the editor was the only thing that ran it. An exported build is bundled by something else, and there the import yields a namespace with no functions on it: the failure is rawintegrationparameters_new is not a function, thrown from generated glue, at which point nothing points back at how the module was loaded.

-compat is the same bindings with the WebAssembly carried inline and instantiated by an explicit init(). It costs a larger JavaScript payload and buys one loading path that holds under every bundler (including whatever a desktop shell uses), which matters more, because the alternative is physics that works in the editor and is inert in the shipped game.

The await this adds is free: every caller already went through this promise.

View source · physics/rapier2d/load_rapier.ts:40

rapier2dProvidervariablenacatamalon/physics2d

rapier2dProvider: TPhysicsProvider

Moves the physics a scene declares, using Rapier2D.

This is the adapter's half of the split: the engine keeps colliders, writes them down and reads them back while deliberately being unable to move them, and registering this is what makes a scene actually fall. Nothing here is needed to author physics: a tool opens, edits and saves a scene with none of it installed.

It is handed everything a scene declares, whether that came from a file or from a line somebody typed, because the engine records it and then asks. There is nothing here that only one of those two paths goes through.

import { registerPhysicsProvider } from 'nacatamalon/extend';
import { rapier2dProvider } from 'nacatamalon/physics2d';

registerPhysicsProvider(rapier2dProvider);
View source · physics/rapier2d/provider.ts:108

TColliderSurface2dtypenacatamalon/physics2d

The non-geometric properties of a collider: how it responds to a contact, and whether it resolves the contact at all.

Properties

restitutionoptionalnumber

How much approach speed survives as separation speed, 0..1.

Careful: restitution belongs to the pair, not to this body. Rapier combines the two colliders' values, by default by averaging them, so a ball asking for 0.8 landing on a floor that never set one (and is therefore 0) bounces at 0.4. Set both sides.

frictionoptionalnumber
densityoptionalnumber
sensoroptionalboolean

Make this a sensor: it detects overlaps but never resolves them, so things pass straight through. This is the trigger volume (a checkpoint, a pickup, a damage zone) and it is the case onEnter/onExit exist for.

A sensor enables collision events on creation without waiting to be asked, unlike a solid body: a sensor that reports nothing does nothing at all, so there is no configuration in which the opt-in would be the right default.

layeroptionalnumber

Which collision layer this body is on (0..15) and which layers it collides with (a bitmask): see the engine's TPhysicsSurface. The relationship is mutual: a bullet listing "walls" passes straight through unless walls also list "bullets". Omitted means layer 0 against everything, which is how every body behaved before layers existed.

collidesWithoptionalnumber
View source · physics/rapier2d/types.ts:15

TPhysicsBodyHandle2dtypenacatamalon/physics2d

Handle to a physics body registered in the world.

Properties

onEnterTGameSignal<TGameObject>

Fires when another body starts touching this one, carrying the other body's box. The engine's own signal, so connect it with useSignal and the disconnect rides the box's teardown:

const bullet = usePhysicsCircle(sprite, { world, type: 'dynamic', radius: 3 });
useSignal(bullet.onEnter, (other) => { if (other.name.startsWith('tank')) destroy(other); });

Reading this property is the opt-in. Rapier reports contacts only for colliders that asked for them, and asking has a cost the rest of a scene should not pay, so the flag is set the first time you touch onEnter/onExit, not when the body is created. The upshot is that you never configure anything, and a pile of a thousand event-less crates stays as cheap as it was before this existed.

Only one of the two bodies needs to have opted in for the contact to be reported, which is why a bullet can listen for hits against walls that know nothing about it.

onExitTGameSignal<TGameObject>

Fires when another body stops touching this one, carrying the other body's box. Same opt-in-by-reading rule as onEnter.

Note that a body coming to rest on another never emits this: it is still touching.

applyImpulse(x: number, y: number) => void

Apply an instantaneous impulse (momentum change) at the body's center.

applyForce(x: number, y: number) => void

Apply a continuous force (acceleration) each frame.

setLinearVelocity(vx: number, vy: number) => void

Set the linear velocity directly.

setAngularVelocity(w: number) => void

Set the angular velocity (rotation speed).

setLayers(layer: number, collidesWith?: number) => void

Moves the body to another collision layer, and changes what it collides with, while the game runs: a player who passes through enemies for a moment after being hit, a ball that stops meeting the pegs once it has bounced. The same two values the body was created with (see the engine's TPhysicsSurface), and the same mutual rule: two bodies meet only if each one's mask includes the other's layer.

collidesWith left out keeps the mask the body already had. The object's physics record is updated too, so a scene saved afterwards keeps the layers it is simulating with.

destroy() => void

Takes the body out of the simulation and leaves the object where it is, still drawn and still running its code: a coin that stops colliding while it plays its pick-up animation. Destroying the object does this on its own. Safe to call twice, and a push through this handle afterwards does nothing.

View source · physics/rapier2d/types.ts:119

TPhysicsWorldHandle2dtypenacatamalon/physics2d

The physics world interface returned by usePhysicsWorld.

Properties

addCircle(box: TGameObject, options: { type } & TColliderSurface2d & { radius }) => TPhysicsBodyHandle2d

Register a circular rigid body on an object.

addRect(box: TGameObject, options: { type } & TColliderSurface2d & { width, height }) => TPhysicsBodyHandle2d

Register a rectangular rigid body on an object.

addPolygon(box: TGameObject, options: { type } & TColliderSurface2d & { vertices }) => TPhysicsBodyHandle2d

Register a polygonal rigid body on an object.

bodyOf(box: TGameObject) => TPhysicsBodyHandle2d | null

The body already registered for box, or null if it has none.

This is how a game reaches a body, whichever way the scene was written, and the reason it exists at all: a collider is declared with the engine's usePhysicsBody2d, which hands back the record and never the simulated handle, because the record is what the scene keeps and the handle is this adapter's. Something holding the object and nothing else gets from there to setLinearVelocity or onEnter through here.

Returns the same handle the body was created with, so a listener connected here and one connected at creation share the one signal (see TBodyEntry.handle).

null is a normal answer during the first frames of a scene: bodies are registered as their objects are built, so a behaviour running before its own collider would ask too early. Look it up in useUpdate rather than caching it at init.

View source · physics/rapier2d/types.ts:197

nacatamalon/physics3d

box3dProvidervariablenacatamalon/physics3d

box3dProvider: TPhysicsProvider

Moves the physics a scene declares in three dimensions, using box3d.

This is the adapter's half of the split: the engine keeps colliders, writes them down and reads them back while deliberately being unable to move them, and registering this is what makes a scene actually fall. Nothing here is needed to author physics: a tool opens, edits and saves a scene with none of it installed.

It is handed everything a scene declares, whether that came from a file or from a line somebody typed, because the engine records it and then asks. There is nothing here that only one of those two paths goes through.

import { registerPhysicsProvider } from 'nacatamalon/extend';
import { box3dProvider } from 'nacatamalon/physics3d';

registerPhysicsProvider(box3dProvider);
View source · physics/box3d/provider.ts:124

DEFAULT_RAY_DISTANCEvariablenacatamalon/physics3d

DEFAULT_RAY_DISTANCE: 1000

How far a ray reaches when nothing says otherwise.

Finite rather than infinite because the backend takes a translation vector, not a direction: there is no "infinity" to hand it. A kilometre is far beyond any level this engine's retro scope builds, so it reads as unlimited while staying a number the solver can use.

View source · physics/box3d/types.ts:268

getPhysicsWorld3dfunctionnacatamalon/physics3d

getPhysicsWorld3d() => TPhysicsWorldHandle3d | null

The 3D physics world the running scene built, or null if it has none.

The seam between authored data and authored behaviour, and the twin of the 2D adapter's accessor of the same name. A collider is data: the scene declares it and this provider simulates it, with no code on either side. Steering is not, and neither is a character, a ray or a trigger's answer, so this is the way back out.

Pair it with world.bodyOf(box) to reach an object's own collider: the engine built the body and handed it here, so the handle addBox returned never went to the game.

null is an ordinary answer, not an error, and scripts must handle it: a tool that opens a scene to edit it deserializes the same objects with no provider registered, so a script runs there with no world and must simply do nothing. That is what keeps a workspace from simulating while somebody is building in it.

registerScript('walker', (self) => {
    const world = getPhysicsWorld3d();
    if (!world) return;                          // editing, not playing
    useUpdate(() => {
        // Looked up per frame: the body is registered as the object is built, so at init it
        // may not exist yet.
        world.bodyOf(self)?.setLinearVelocity(0, 0, -4);
    });
});

Call it while the scene is being built, which is when a script runs, so this is the ordinary case. Outside that there is no active game to compare against and the answer is always null.

View source · physics/box3d/provider.ts:66

installPhysics3dfunctionnacatamalon/physics3d

installPhysics3d() => void

Installs this as what simulates the solid half of a scene's physics. The one line a game writes, and the only one: everything else about physics is the engine's vocabulary, so a scene says what it is made of and this makes it move.

It is a call and not something that happens on import, for three separate reasons, any one of which would be enough. The engine declares itself free of side effects, so a bundler is allowed to drop an import nothing is used from, and a game that worked in development would sit still once built. A tool must be able to open a scene and NOT simulate it, which is what lets you build a level in a workspace that holds still. And a game may install the flat half as well, which it does the same way, by saying so.

import { installPhysics3d } from 'nacatamalon/physics3d';

installPhysics3d();

Installing twice does nothing the second time.

View source · physics/box3d/provider.ts:192

loadBox3Dfunctionnacatamalon/physics3d

loadBox3D() => Promise<Box3DModule>

Loads (once) the Box3D WASM runtime and memoizes the promise, so every world in a game shares a single instantiation instead of re-compiling the module per scene. Uses the inline build, so the .wasm is embedded in the bundle as base64, so it works in the browser with no Vite/loader config and in a headless Node/Bun test with no separate file to resolve. The trade is a slightly larger bundle, paid only by games that actually import the physics adapter.

View source · physics/box3d/load_box3d.ts:52

useCharacterBodyfunctionhooknacatamalon/physics3d

useCharacterBody(options: TCharacterOptions) => TCharacterBody | null

Makes this object a walking character: a capsule moved by asking the world what is in the way, and the thing that makes a room walkable.

It is the one part of physics that a scene cannot write down, and that is not an oversight. A crate is a shape with a weight, and a file can say so. A character is a decision taken sixty times a second about where a body is allowed to be: it climbs a step without jumping, walks up a ramp and not up a wall, and stops dead against a crate instead of tipping over it. None of that is a property of the object, so there is nothing for a scene to keep, and steering one with forces gives you something that slides on slopes and falls over on its face.

Set velocity and it walks. Falling is applied for you, and isGrounded is the answer to whether it may jump.

null when the scene has no physics world, which is what a tool opening a scene to edit it looks like. Handle it the way you would handle getPhysicsWorld3d() coming back empty: do nothing, because nothing is running.

const Player = () => {
    useTransform({ x: 0, y: 1, z: 0 });
    createMesh({ geometry: useUvSphereGeometry({ radius: 0.35 }), tint: BLUE });

    const player = useCharacterBody({ radius: 0.35, height: 1.8 });
    const keys = useKeyboard();
    useUpdate(() => {
        if (!player) return;
        player.velocity.x = keys.isDown('d') ? 4 : keys.isDown('a') ? -4 : 0;
        if (keys.isDown('Space') && player.isGrounded) player.velocity.y = 5;
    });
};

Parameters

View source · physics/box3d/use_character_body.ts:42

TBox3dModuletypenacatamalon/physics3d

TBox3dModule: Box3DModule

The Box3D WebAssembly runtime, or null: the opaque handle every physics call goes through. Excluded from any serialization exactly like a GPU handle: it is pure runtime, reconstructed on load, never part of a save. Re-exported so the world wrapper can type body/world ids off it without importing Embind internals.

View source · physics/box3d/load_box3d.ts:14

TCharacterBodytypenacatamalon/physics3d

A kinematic character: the thing that separates "boxes that fall" from "somebody walks here".

A character is deliberately not a dynamic body. A dynamic capsule steered by forces fights its own momentum and friction: it slides on slopes, tips over, skates after you let go of the stick, and catches on the seam between two floor tiles. Every engine solves this the same way and so does this one: the character is a capsule moved by queries against the world (b3World_CollideMover + b3SolvePlanes, Box2D v3's mover primitives), resolved to a position that does not overlap anything, then written onto the box's transform.

Set TCharacterBody.velocity, and the world advances it on its own fixed step. Gravity is applied for you (set gravity: 0 for a flyer), because a controller that falls is what "character" means to nearly everyone asking for one.

Properties

velocity{ x, y, z }

Metres per second, mutable in place: write into it from a useUpdate and the next step uses it. This is the whole steering surface: horizontal components walk, a positive y jumps, and gravity pulls y down between steps.

Re-assert it every frame, not once. The controller clips this vector against whatever the character is touching, which is what makes a wall stop you and what keeps gravity from accumulating while you stand still. The same clipping eats a walk that was set once: on a slope, the part of the velocity pointing into the surface is removed every step, so a "set and forget" walk decays within a second and the character slides back down.

isGroundedboolean

Whether a surface flat enough to stand on is underfoot, decided from the contact planes of the step just taken (normal.y >= cos(slopeLimit)), so a wall never counts and a ramp steeper than the limit slides you off instead of letting you climb it.

setPosition(x: number, y: number, z: number) => void

Places the character without consulting the world: spawning, respawning, a cutscene. The ordinary way to move is TCharacterBody.velocity; this is the escape hatch, and it can put you inside a wall, which is why it is not the ordinary way.

destroy() => void

Stops driving the object, which keeps whatever placement it last had.

View source · physics/box3d/character_body.ts:46

TCharacterOptionstypenacatamalon/physics3d

Everything a character needs beyond its world: the capsule it occupies, how it treats slopes, and where that capsule sits inside its box.

height is the total height including the caps, the same convention Collider3D's capsule uses, so a 1.8 m person is { radius: 0.3, height: 1.8 }.

Properties

radiusnumber
heightnumber
slopeLimitoptionalnumber

The steepest surface that still counts as ground, in radians. Default ~50°. Anything steeper is a wall as far as TCharacterBody.isGrounded is concerned, so you slide down it rather than stand on it.

gravityoptionalnumber

Downward acceleration in m/s², applied every step. Defaults to the world's own gravity magnitude so a character falls at the same rate as everything around it. 0 for a flyer or a top-down game.

offsetoptional[number, number, number]

Where the capsule's centre sits relative to the box's origin, in local units before scale which is the same field a collider carries, and needed for the same reason: a character model from a glTF almost always has its origin at its feet, so a capsule centred on the origin would bury the character's legs in the floor.

layeroptionalnumber

The collision layer the character's presence proxy is on, and which layers may see it read exactly like a collider's (see the engine's TPhysicsSurface), because it is one.

This is what a trigger volume filters on. A door that should open for the player and not for a wandering crate puts the player on its own layer and collides with that alone, rather than checking a name at runtime.

The proxy is excluded from every mover query by its MASK (see MOVER_CATEGORY) rather than by its category, so layer stays free to narrow who may notice it. Omitted means layer 0 against everything, the same default a collider has.

collidesWithoptionalnumber
View source · physics/box3d/character_body.ts:88

TColliderOptions3dtypenacatamalon/physics3d

TColliderOptions3d: TColliderSurface3d & { offset }

What every add* takes beyond its own dimensions: the surface, plus where the shape sits inside the object.

offset is in the box's local units before its scale, matching TPhysicsBody3d.offset, and it exists for glTF, which puts a model's origin at its feet while a collider is centred on that origin. Omitted means centred.

View source · physics/box3d/types.ts:282

TColliderSurface3dtypenacatamalon/physics3d

Optional surface tuning for a collider: how a contact resolves, and whether it resolves at all. restitution is bounciness (0 = dead, 1 = elastic), friction resists sliding, density (kg/m³) sets the body's mass from its volume, and sensor detects overlaps without resolving them, which is a trigger volume. All optional; omitted fields keep Box3D's defaults.

The grouping mirrors the engine's TPhysicsSurface, which puts the same four together for the same reason: they are the non-geometric half of a collider.

Properties

restitutionoptionalnumber
frictionoptionalnumber
densityoptionalnumber
sensoroptionalboolean
layeroptionalnumber

Which collision layer this body is on (0..15) and which layers it collides with (a bitmask): see the engine's TPhysicsSurface. Omitted means layer 0 against everything, which is how every body behaved before layers existed.

collidesWithoptionalnumber
View source · physics/box3d/types.ts:102

TPhysicsBodyHandle3dtypenacatamalon/physics3d

A runtime handle to one simulated body. Runtime only: the body lives in WASM memory, so nothing here is serializable (the serializable form is the mesh's own transform, which this body writes into).

Properties

onEnterTGameSignal<TGameObject>

Fires when another body starts touching this one, receiving the other object.

Reading this property is the opt-in: box3d reports contacts only for shapes that asked, so a body nobody listens to costs nothing and allocates no signal. Verified against box3d: for contacts one side asking is enough, so listening on the player alone still reports the wall.

onExitTGameSignal<TGameObject>

Fires when a body stops touching this one, the exit half of TPhysicsBodyHandle3d.onEnter, same opt-in-on-read rule.

applyImpulse(x: number, y: number, z: number) => void

Applies an instantaneous impulse (mass·velocity) at the body's center, waking it. Queued if the WASM world is still loading, then flushed on materialization.

applyForce(x: number, y: number, z: number) => void

Applies a continuous force (mass·acceleration) at the body's center. Unlike an impulse this is meant to be applied every frame: one call is one frame's worth of push.

applyTorque(x: number, y: number, z: number) => void

Applies a continuous torque about the body's center, the rotational sibling of TPhysicsBodyHandle3d.applyForce.

setLinearVelocity(x: number, y: number, z: number) => void

Sets the body's velocity outright, overriding whatever the solver had. This is how a character walks: forces fight momentum and friction, a velocity set does not.

setAngularVelocity(x: number, y: number, z: number) => void

Sets the body's spin outright, the rotational sibling of setLinearVelocity.

setLayers(layer: number, collidesWith?: number) => void

Moves the body to another collision layer, and changes what it collides with, while the game runs: a player who passes through enemies for a moment after being hit. The same two values the body was created with (see the engine's TPhysicsSurface), and the same mutual rule: two bodies meet only if each one's mask includes the other's layer.

collidesWith left out keeps the mask the body already had. The object's physics record is updated too, so a scene saved afterwards keeps the layers it is simulating with.

destroy() => void

Removes this body from the simulation, freeing its WASM memory. The object stops being driven and keeps whatever placement it last had. Safe to call twice; the world's own teardown skips bodies already destroyed this way.

View source · physics/box3d/types.ts:31

TPhysicsWorldHandle3dtypenacatamalon/physics3d

The world a scene declared, and the factory for bodies in it. Deliberately narrow (box + sphere for now); more shapes and queries land when a concrete case needs them, not before.

Properties

addBox(box: TGameObject, opts: { type, size } & TColliderOptions3d) => TPhysicsBodyHandle3d

Adds a box collider whose full extents are size (metres, halved internally to Box3D's half-extents), positioned and turned from where the object is.

addSphere(box: TGameObject, opts: { type, radius } & TColliderOptions3d) => TPhysicsBodyHandle3d

Adds a sphere collider of radius (metres), positioned from where the object is.

addCapsule(box: TGameObject, opts: { type, radius, height } & TColliderOptions3d) => TPhysicsBodyHandle3d

Adds a capsule collider standing on Y: a cylinder with hemispherical caps, and what a character should use: a box catches on floor seams and step lips, a capsule rides over them.

height is the total height including the caps (see TCollider3d), which is not how Box3D states it, because it wants two cap centres. The conversion happens here, and a height below radius * 2 clamps to a sphere rather than inverting the shape.

addHull(box: TGameObject, opts: { type, geometry } & TColliderOptions3d) => TPhysicsBodyHandle3d

Adds the convex hull of a geometry's vertices: the collider for a prop whose silhouette matters but whose hollows do not. Cheap to collide against and valid for a dynamic body, which is what separates it from addMesh.

Reads the shape's corners, so it works on an imported glTF as well as on a primitive. A model still loading has no positions yet and produces an empty body rather than throwing, which is the same deferral every other shape already survives.

addMesh(box: TGameObject, opts: { type, geometry } & TColliderOptions3d) => TPhysicsBodyHandle3d

Adds the geometry's actual triangles, concave and all. This is level geometry: terrain, a room, a track.

Static or kinematic only. A triangle mesh is a surface with no interior, so a dynamic body made of one has nothing to be pushed out of; 'dynamic' is clamped to 'static' with a warning rather than silently falling through the world.

addCharacter(box: TGameObject, opts: TCharacterOptions) => TCharacterBody

Adds a kinematic character: a capsule moved by queries rather than by forces, and the thing that makes a world walkable. See TCharacterBody for why it is not a body.

It is advanced by this world's own fixed step, like everything else here, so nothing has to be called per frame: write into velocity whenever, and the character walks.

raycast(origin: { x, y, z }, direction: { x, y, z }, opts?: TRaycastOptions) => TRaycastHit | null

Fires a ray and returns the closest thing it hits, or null for a clean miss, and the first query on this surface that is not about creating something.

A ray is how a game asks a question it cannot answer from its own state: what is under the cursor, is there ground beneath these feet, can the guard see the player, what did this gun shoot. All four are the same call.

direction need not be normalized: its length is ignored and maxDistance is what decides how far the ray reaches. That is Unity's shape rather than Box3D's (which takes a single translation vector meaning both at once), because "which way" and "how far" are separate thoughts and combining them is how a ray silently ends up 0.3 m long.

Returns null while the WASM runtime is still loading, exactly as every other call here defers: a ray cast on the first frame of a scene is not an error, it is early.

bodyOf(box: TGameObject) => TPhysicsBodyHandle3d | null

The body already registered for the box with this id, or null if it has none.

The lookup a document-driven scene needs, and the reason it exists: when a collider comes from a SceneDoc the provider builds it, so the handle addBox returned went there and the game never saw it. A script attached to that same box has the box and nothing else and this is how it gets from there to onEnter. Pass the script's self straight in; a code-authored scene keeps its handle and never needs this.

null is a normal answer during a scene's first frames: bodies are registered as their boxes are built, so a script listed above its own collider asks too early. Look it up in useUpdate rather than caching it at init.

View source · physics/box3d/types.ts:124

TPhysicsWorldOptions3dtypenacatamalon/physics3d

Options for the world. gravity is metres/second² (the engine's 3D unit is one metre); it defaults to Earth on -Y.

Properties

gravityoptional{ x, y, z }
View source · physics/box3d/types.ts:292

TRaycastHittypenacatamalon/physics3d

What a ray found: which object, where, and how far along.

box is the very object handed to addBox/addSphere and the rest, so a hit is immediately usable ("move this", "damage that") without a lookup table of your own.

Properties

point{ x, y, z }

Where the ray met the surface, in world metres.

normal{ x, y, z }

The surface normal there, unit length and pointing out of the shape.

distancenumber

Metres from origin to point.

View source · physics/box3d/types.ts:241

TRaycastOptionstypenacatamalon/physics3d

How far a ray reaches and what it is allowed to hit.

collidesWith is a layer bitmask read exactly like a body's own (see the engine's TPhysicsSurface) so a ground check that should ignore enemies, or a cursor pick that should ignore scenery, is the same vocabulary the colliders are already authored in rather than a second one.

Properties

maxDistanceoptionalnumber

Metres. Defaults to DEFAULT_RAY_DISTANCE.

collidesWithoptionalnumber

Which layers the ray can hit. Defaults to all of them.

View source · physics/box3d/types.ts:220