Skip to main content

3D Physics and Character Controllers

In 3D rendering, you see Node3D objects that sit in a scene hierarchy and move where you tell them to move. Physics simulation is a separate world that steps forward under gravity and collision forces. Dora keeps these two trees in sync by making Body3D and Node3D a parent-child pair: the physics body moves according to simulation, and the visible node follows as its child.

This tutorial starts with a floor that never moves and a crate that falls under gravity. You'll see how the helper function connects the two trees, then add a character controller for a player who moves predictably rather than tumbling like a physics object.

Tutorial goal

By the end, you will have a physics world with gravity, a static floor, a falling dynamic crate, and a character controller for player movement.

How 3D physics is organized

Dora's 3D physics API mirrors its 2D physics API - the same concepts and the same workflow, just one extra dimension. If you've used the 2D PhysicsWorld, Body, and BodyDef classes, the 3D equivalents (PhysicsWorld3D, Body3D, BodyDef3D) will feel natural.

The one thing worth understanding before you write any code is how a physics body relates to a visible node. A Body3D is itself a Node3D, so any visible node you want to follow the simulation should be parented under its body. When the body moves under physics, the visible child moves with it. The helper function later in this tutorial does that reparenting for you.

PhysicsWorld3D steps itself automatically - you don't need to write an update loop. Just create the world, add bodies to it, and read their positions back each frame.

1. Create a physics world with a floor and a crate

Gravity defines how objects fall. A PhysicsWorld3D with downward gravity makes everything in it accelerate toward negative Y. Static bodies never move, so the floor stays in place while dynamic bodies like crates respond to forces and settle on top.

init.ts
import {Body3D, BodyDef3D, Director, FixtureDef3D, Node3D, PhysicsWorld3D, Vec3} from "Dora";

function makeBoxBody3D(
world: PhysicsWorld3D.Type, node: Node3D.Type,
halfExtent: Vec3.Type, type = PhysicsWorld3D.Dynamic,
) {
const position = node.position;
const angles = node.angles;
node.position = Vec3(0, 0, 0);
node.angles = Vec3(0, 0, 0);

const def = BodyDef3D();
def.type = type;
if (!def.attach(FixtureDef3D.box(halfExtent))) throw new Error("failed to attach fixture");
const body = Body3D(def, world, position, angles);
if (!body) throw new Error("failed to create body");
body.addChild(node);
return body;
}

const view = Director.entry;
Director.scheduler.fixedFPS = 60;

const world = PhysicsWorld3D();
world.gravity = Vec3(0, -9.81, 0);
view.addChild(world);

const floor = Node3D();
floor.position = Vec3(0, -0.5, 0);
view.addChild(makeBoxBody3D(world, floor, Vec3(8, 0.5, 4), PhysicsWorld3D.Static));

const crate = Node3D();
crate.position = Vec3(0, 2, 0);
view.addChild(makeBoxBody3D(world, crate, Vec3(0.5, 0.5, 0.5), PhysicsWorld3D.Dynamic));

What this code does:

  • Director.scheduler.fixedFPS = 60 sets the fixed update rate to 60 Hz, which keeps physics simulation stable at a consistent timestep.
  • PhysicsWorld3D() creates the physics world. It's itself a Node3D that you add to the scene like any other.
  • world.gravity = Vec3(0, -9.81, 0) sets Earth gravity: 9.81 m/s² downward along the Y axis.
  • view.addChild(world) adds the physics world to the scene. All bodies in this world will be simulated.
  • The floor is a Node3D positioned at y=-0.5, below the origin, so its top surface sits at y=0. makeBoxBody3D(world, floor, Vec3(8, 0.5, 4), PhysicsWorld3D.Static) creates a static body that never moves. The half-extent Vec3(8, 0.5, 4) defines the box dimensions as 16×1×8 units.
  • The crate starts at y=2, high enough to fall. makeBoxBody3D(world, crate, Vec3(0.5, 0.5, 0.5), PhysicsWorld3D.Dynamic) creates a dynamic body that responds to forces. The half-extent Vec3(0.5, 0.5, 0.5) makes a 1×1×1 unit cube.

When you run this, the crate falls under gravity, hits the floor, and comes to rest. You don't need any update loop; the physics world steps itself automatically.

Checkpoint

You should see the crate fall and settle on the floor. If it falls forever, double-check that the floor fixture exists and uses PhysicsWorld3D.Static. If nothing moves at all, confirm that the physics world was added to the view and that the crate body is PhysicsWorld3D.Dynamic.

2. A helper for box bodies

The helper function makeBoxBody3D does the bookkeeping to connect the two trees. It saves the node's original position and rotation, removes it from its current parent, creates a physics body at that saved position, then reparents the visible node under the body. The result is that the physics body becomes the new parent in the scene hierarchy, so the visible transform follows the physics transform.

init.ts (helper)
import {Body3D, BodyDef3D, FixtureDef3D, Node3D, PhysicsWorld3D, Vec3} from "Dora";

export function makeBoxBody3D(world: PhysicsWorld3D.Type, node: Node3D.Type,
halfExtent: Vec3.Type, type = PhysicsWorld3D.Dynamic) {
const parent = node.parent;
const position = node.position;
const angles = node.angles;
node.removeFromParent(false);
node.position = Vec3(0, 0, 0);
node.angles = Vec3(0, 0, 0);

const def = BodyDef3D();
def.type = type;
if (!def.attach(FixtureDef3D.box(halfExtent))) throw new Error("failed to attach fixture");
const body = Body3D(def, world, position, angles);
if (!body) throw new Error("failed to create body");
body.addChild(node);
parent?.addChild(body);
return body;
}

How this helper works:

  • It saves node.parent, node.position, and node.angles to remember where the visible node was.
  • node.removeFromParent(false) removes the node from its current parent without destroying it.
  • node.position = Vec3(0, 0, 0) and node.angles = Vec3(0, 0, 0) reset the node to the origin. This is important because the body will be at the original world position, so the child should be at local (0, 0, 0).
  • BodyDef3D() creates a body definition. def.type = type sets it to static or dynamic.
  • def.attach(FixtureDef3D.box(halfExtent)) attaches a box fixture. The half-extent defines the collision shape: Vec3(0.5, 0.5, 0.5) produces a box that extends 0.5 units in each direction from the center, making it a 1×1×1 cube.
  • Body3D(def, world, position, angles) creates the body at the saved position and angles.
  • body.addChild(node) reparents the visible node under the body. Now the body's transform drives the visible transform.
  • parent?.addChild(body) reattaches the body to the node's original parent, preserving the scene hierarchy.

The half-extent is half the box size. Vec3(0.5, 0.5, 0.5) makes a one-unit cube, Vec3(1, 1, 1) makes a two-unit cube, and so on. Keep the visual dimensions aligned with the fixture dimensions; mismatches between what you see and what collides are confusing to debug.

3. Add a character controller for the player

Rigid bodies like the crate are great for objects that should tumble, bounce, and react naturally to forces. A player character is different: you want predictable control over where they go, smooth movement up slopes, and the ability to step over small obstacles. That's what a character controller provides.

init.ts
const playerNode = Node3D();
playerNode.position = Vec3(0, 1.2, 0);
view.addChild(playerNode);

const player = world.createCharacter(playerNode, 0.55, 0.32, 50, 0.35);
player.desiredVelocity = Vec3(2, 0, 0);

What this code does:

  • playerNode is a visible node that holds the player's model. It starts at y=1.2, standing on the floor at y=0.
  • world.createCharacter(playerNode, 0.55, 0.32, 50, 0.35) creates a character controller attached to the node. The parameters are: radius (0.55 units), height (0.32 units), mass (50), and step offset (0.35 units, which determines the maximum step height the character can climb).
  • player.desiredVelocity = Vec3(2, 0, 0) sets the direction and speed you want the player to move. In real gameplay, you'd set this from input each frame. The physics world resolves the actual movement, handling collisions, slopes, and steps automatically.

Don't manually move a character controller's visible node after creation. The controller updates it as part of the physics step. If you set desiredVelocity every frame from input, the character will move smoothly and respond to obstacles without you writing collision logic yourself.

When to use a custom scheduler

The default scheduler is all you need for most games. Give world.scheduler a separate Scheduler only when physics needs its own pause, replay, or time-scale domain. In that advanced case, you must explicitly advance the custom scheduler yourself, because Director only advances schedulers it owns.

Complete examples

The complete scripts below follow this tutorial and have been verified by the Dora engine.

init.ts
import {Body3D, BodyDef3D, Director, FixtureDef3D, Node3D, PhysicsWorld3D, Vec3} from "Dora";

// Helper: wrap a visible Node3D in a box-shaped Body3D at the node's
// current transform, then reparent the visible node under the body.
// halfExtent is HALF of the box's full size on each axis.
function makeBoxBody3D(
world: PhysicsWorld3D.Type, node: Node3D.Type,
halfExtent: Vec3.Type, type = PhysicsWorld3D.Dynamic,
) {
const parent = node.parent;
const position = node.position;
const angles = node.angles;

// Reset the visible node to the origin; the body now owns the transform.
node.removeFromParent(false);
node.position = Vec3(0, 0, 0);
node.angles = Vec3(0, 0, 0);

const def = BodyDef3D();
def.type = type;
if (!def.attach(FixtureDef3D.box(halfExtent))) throw new Error("failed to attach fixture");

const body = Body3D(def, world, position, angles);
if (!body) throw new Error("failed to create body");
body.addChild(node);

parent?.addChild(body);
return body;
}

const view = Director.entry;
Director.scheduler.fixedFPS = 60;

const world = PhysicsWorld3D();
world.gravity = Vec3(0, -9.81, 0);
view.addChild(world);

// Static floor: never moves, but other bodies can rest on it.
const floor = Node3D();
floor.position = Vec3(0, -0.5, 0);
view.addChild(floor);
makeBoxBody3D(world, floor, Vec3(8, 0.5, 4), PhysicsWorld3D.Static);

// Dynamic crate: responds to gravity and collides with the floor.
const crate = Node3D();
crate.position = Vec3(0, 2, 0);
view.addChild(crate);
makeBoxBody3D(world, crate, Vec3(0.5, 0.5, 0.5), PhysicsWorld3D.Dynamic);

// Player: a character controller gives you slope, step, and
// desired-velocity handling that a raw rigid body can't.
const playerNode = Node3D();
playerNode.position = Vec3(0, 1.2, 0);
view.addChild(playerNode);

const player = world.createCharacter(playerNode, 0.55, 0.32, 50, 0.35);
player.desiredVelocity = Vec3(2, 0, 0);

Try it yourself

Attach a crate model under the dynamic crate node. First match its collision box to the visible model, then intentionally make the fixture too large. The mismatch is a useful way to learn why collision dimensions should be treated as part of level design.

Summary

You now have the three main tools for 3D physics in Dora:

  • PhysicsWorld3D: the simulation space with gravity. It steps automatically at a fixed frame rate, so you don't need a manual update loop.
  • Body3D: rigid bodies that simulate forces and collisions. Static bodies never move, while dynamic bodies respond to gravity and bounce off obstacles.
  • CharacterController: for player movement with predictable control. Set desiredVelocity from input, and let the physics world handle slopes, steps, and collisions.

The helper function makeBoxBody3D connects the physics body tree to the visible node tree by reparenting the node under the body. This separation lets physics do what it's good at while keeping your rendering code simple.

Next tutorial

Continue with 2D content in a 3D scene to place readable labels and panels into this physical world.