Physics
Step 4 of the 3D track. So far everything in the yard has stayed exactly where you put it and passed straight through everything else. Now we bring in Jolt: crates start falling and colliding, the fox starts walking on her own legs and bumping into things, and a green zone starts counting what got pushed into it.
Physics in LeCodes is aspects on nodes, and there are only four:
Shape— collision geometry (and finger geometry: it's the one you already attached for taps);Physics— a rigid body:static,dynamicorkinematic;Trigger— a sensor zone: never blocks movement, just reports who entered;CharacterController— a player capsule: walks, slides along walls, steps up stairs.
Shape goes first: the other three throw without it.
First bodies
A rigid body is two aspects in a row: a shape and Physics. The floor gets motion: "static" (never moves), the crates get the default dynamic (gravity, collisions, mass):
// The world is configured once, before the first body (the default already is -9.81 — shown for clarity)
Physics.configure({ gravity: [0, -9.81, 0] })
const scene = new Scene({ skybox: "#1b2233" })
scene.add(Light.sun({ direction: [-1, -2, 0.7], intensity: 90000, shadowsQuality: 2 }))
// The floor is a body too: Shape (a box, HALF-extents) + static Physics. Without it the crates fall forever
const floor = Mesh.plane({ material: Material.lit({ color: "#2f4a35", roughness: 1 }), normal: [0, 1, 0], scale: 24, receiveShadows: true })
.aspect(Shape, { box: [12, 0.05, 12] })
.aspect(Physics, { motion: "static" })
scene.add(floor)
// The stack: Shape {} fits a box to the mesh, Physics makes it dynamic. Order matters: Shape first
const wood = Material.lit({ color: "#b5854b", roughness: 0.8 })
for (let i = 0; i < 5; i++) {
scene.add(
Mesh.box({ material: wood, size: 0.6, position: [0, 0.3 + i * 0.62, 0], eulerAngles: [0, i * 12, 0], castShadows: true })
.aspect(Shape, {})
.aspect(Physics, { mass: 1 }),
)
}
scene.camera.position = [0, 3, 6]
scene.camera.lookAt([0, 1, 0])
const label = UIText(Physics.supported ? "Tap a crate to shove it, tap the ground for a new one" : "This host has no physics")
.style({ fontSize: 13, color: "#c7ccd6" })
const hud = UIWidget(label).style({ top: "max(safe-top, 16px)", left: 16, right: 16 })
hud.attachTo(scene)
scene.open()
hud.show()
const ground = new Plane([0, 1, 0], [0, 0, 0])
scene.addEventListener("click", ev => {
const ray = scene.camera.getRay(ev.clientX, ev.clientY)
const hit = Physics.raycast(ray.origin, ray.dir, 100)
if (hit?.node && hit.node !== floor) {
// A dynamic body is moved by impulse, not by position: physics owns the transform
hit.node.get(Physics)?.applyImpulse(ray.dir.normalize().scale(5))
return
}
const t = ground.intersectRay(ray)
if (t === null) return
const p = ray.getPoint(t)
scene.add(
Mesh.box({ material: wood, size: 0.6, position: [p.x, 4, p.z], eulerAngles: [20, 40, 0], castShadows: true })
.aspect(Shape, {}).aspect(Physics, { mass: 1 }),
)
})- Aspect order:
Shape→Physics. The other way round throws "Physics requires a Shape aspect". It's the most common typo in physics code. boxtakes HALF-extents, in world units, and the node'sscaledoesn't touch them. A 1×1×1Mesh.box()is covered bybox: [0.5, 0.5, 0.5]— or just{}to fit the shape to the mesh automatically.- Physics owns a dynamic body's transform. Assigning
positionto one every frame means fighting the simulation: the body jitters and tunnels through walls. Move it withapplyImpulse(an instant shove) orphysics.velocity— readingpositionis always fine, the engine keeps it current. Physics.configure()runs once, before the first body. Later it's ignored.maxBodieslives there too, for worlds with more than four thousand bodies.Physics.supported— a host may ship without physics, and then everything on this page silently does nothing (aspects attach, velocities read zero, no events fire). If gameplay depends on physics, check the flag and tell the user.- Mass only means anything for
dynamic. A heavier body moves less under the same impulse — impulses are in kg·m/s.
Bodies aren't destroyed along with the mesh: scene.remove(node) takes the node out of the draw set, but the body lives until node.destroy(). If you spawn a lot, remember to delete.
Kinematic bodies and triggers
Not everything that moves should fall. A kinematic body is driven by you: gravity ignores it, nothing can shove it, but it shoves everything else — platforms, doors, lifts, pistons. A trigger doesn't obstruct movement at all: it only reports who entered and who left:
const scene = new Scene({ skybox: "#1b2233" })
scene.add(Light.sun({ direction: [-1, -2, 0.7], intensity: 90000, shadowsQuality: 2 }))
scene.add(Mesh.plane({ material: Material.lit({ color: "#2f4a35", roughness: 1 }), normal: [0, 1, 0], scale: 24, receiveShadows: true })
.aspect(Shape, { box: [12, 0.05, 12] }).aspect(Physics, { motion: "static" }))
scene.camera.position = [0, 4.5, 6.5]
scene.camera.lookAt([0, 0.3, 0])
// A kinematic body: it doesn't fall and can't be pushed, but it pushes others. Drive it with moveTo
const sweeper = Mesh.box({ material: Material.lit({ color: "#5e9eff", roughness: 0.6 }), size: [0.3, 0.6, 2.4], position: [-3, 0.3, 0], castShadows: true })
.aspect(Shape, {}).aspect(Physics, { motion: "kinematic" })
const crate = Mesh.box({ material: Material.lit({ color: "#b5854b", roughness: 0.8 }), size: 0.6, position: [-1, 0.3, 0], castShadows: true, name: "crate" })
.aspect(Shape, {}).aspect(Physics, {})
// A trigger: a sensor zone. Bodies pass through it, but enter/exit arrive
const zone = Mesh.box({ material: Material.unlit({ color: "#34c759" }), size: [2, 0.02, 2], position: [2, 0.01, 0], name: "zone" })
.aspect(Shape, { box: [1, 0.5, 1] }).aspect(Trigger)
scene.add(sweeper, crate, zone)
const inside = new Set<Node>()
const label = UIText("in the zone: 0 · tap the ground to drop a ball").style({ fontSize: 13, color: "#c7ccd6" })
const refresh = () => { label.text = `in the zone: ${inside.size} · tap the ground to drop a ball` }
zone.addEventListener("enter", other => { inside.add(other); refresh() })
zone.addEventListener("exit", other => { inside.delete(other); refresh() })
const hud = UIWidget(label).style({ top: "max(safe-top, 16px)", left: 16, right: 16 })
hud.attachTo(scene)
scene.open()
hud.show()
let t = 0
setLoop(dt => {
t += dt
sweeper.physics.moveTo([-3 + (Math.sin(t * 0.6) + 1) * 2, 0.3, 0])
})
const rubber = Material.lit({ color: "#FF4032", roughness: 0.6 })
const ground = new Plane([0, 1, 0], [0, 0, 0])
scene.addEventListener("click", ev => {
const ray = scene.camera.getRay(ev.clientX, ev.clientY)
const t = ground.intersectRay(ray)
if (t === null) return
const p = ray.getPoint(t)
scene.add(Mesh.sphere({ material: rubber, radius: 0.25, position: [p.x, 3, p.z], castShadows: true, name: "ball" })
.aspect(Shape, { sphere: 0.25 }).aspect(Physics, { mass: 0.5 }))
})Three motion types — and different ways to drive them:
motion | Who moves it | How |
|---|---|---|
static | nobody | nothing — floors, walls, tree trunks |
dynamic | the simulation | applyImpulse, velocity |
kinematic | you | moveTo — every frame or once |
moveToisn't a teleport, it's "have the body here by the next step". That's exactly why a kinematic platform pushes crates: the engine sees the sweep and resolves the collisions. Assigningpositiongives you none of that.enter/exitfire on both nodes of the contact, with the other participant as the argument. A trigger needs aShape(and is static); a body needsShape+Physics; two triggers never see each other.- Track who's inside with a set, not a counter. A
Setsurvives repeatedenterevents (a body can bounce on the boundary) and always knows the exact membership — a counter drifts eventually. - A trigger can move:
zone.trigger.moveTo(point)— that's how moving pickup zones are built. - Contacts carry no impact force in the event: if you need collision "loudness", compare
velocitybefore and after — or just read the speed at the moment ofenter.
The character
The fox could be a dynamic body — and she'd topple over, bounce off crates and wedge herself into corners. Characters get their own aspect: CharacterController, a capsule that slides along walls, climbs steps and never falls on its side. All you give it is intent — "walking that way":
const scene = new Scene({ skybox: "#1b2233" })
scene.add(Light.sun({ direction: [-1, -2, 0.7], intensity: 90000, shadowsQuality: 2 }))
scene.add(Mesh.plane({ material: Material.lit({ color: "#2f4a35", roughness: 1 }), normal: [0, 1, 0], scale: 30, receiveShadows: true })
.aspect(Shape, { box: [15, 0.05, 15] }).aspect(Physics, { motion: "static" }))
const wood = Material.lit({ color: "#b5854b", roughness: 0.8 })
for (const [x, z] of [[1.5, -1], [-1.5, -1.5], [0, -3], [2.5, 1]]) {
scene.add(Mesh.box({ material: wood, size: 0.45, position: [x, 0.23, z], castShadows: true }).aspect(Shape, {}).aspect(Physics, { mass: 0.3 }))
}
scene.camera.position = [0, 4, 6]
scene.camera.lookAt([0, 0.5, 0])
// An on-screen d-pad: while the finger is on a button the direction holds, on release it's zero.
// track() is what gives us onEnd/onCancel: onClick would only fire on release
let mx = 0, mz = 0
const key = (label: string, x: number, z: number) =>
UIButton(UIText(label).style({ color: "#fff", fontSize: 18 }))
.style({ bgColor: "#ffffff33", width: 48, height: 48, borderRadius: 12, alignItems: "center", justifyContent: "center", $pressed: { bgColor: "#ffffff66" } })
.onTouchStart(ev => {
mx = x; mz = z
ev.track({ claim: true, onEnd: () => { mx = 0; mz = 0 }, onCancel: () => { mx = 0; mz = 0 } })
})
const pad = UIWidget(
UIColumn(UIRow(key("↑", 0, -1)), UIRow(key("←", -1, 0), key("↓", 0, 1), key("→", 1, 0)).style({ gap: 6 })).style({ gap: 6, alignItems: "center" }),
).style({ bottom: "max(safe-bottom, 24px)", left: 0, right: 0, alignItems: "center" })
pad.attachTo(scene)
scene.open()
pad.show()
async function main() {
const fox = await Model.load("https://cdn.le.codes/guide-assets/fox-anim.glb")
fox.castShadows = true
// The controller lives on an empty capsule node; the fox is its child, shifted down so her paws touch the ground.
// Set the position BEFORE the aspects: the controller starts wherever the node was at attach time
const hero = new Node()
hero.position = [0, 0.26, 1.5]
hero.aspect(Shape, { capsule: { halfHeight: 0.06, radius: 0.2 } })
.aspect(CharacterController, { speed: 2.5 })
fox.position = [0, -0.26, 0]
hero.add(fox)
scene.add(hero)
fox.anim.play("walk", { loop: true })
let camPos = scene.camera.position
setLoop(dt => {
hero.controller.move(mx, mz) // sticky: it keeps going until you send 0
const moving = mx !== 0 || mz !== 0
fox.anim.playing = moving
if (moving) fox.eulerAngles = [0, Math.atan2(mx, mz) * RAD2DEG, 0]
// The camera chases the hero — the trick from the previous step, or he walks out of frame
camPos = camPos.lerp(hero.position.add([0, 3.4, 4.5]), 1 - Math.pow(0.02, dt))
scene.camera.position = camPos
scene.camera.lookAt(hero.position)
})
}
main()move(x, z)is intent, not a velocity and not a displacement. Both numbers live in −1…1, the speed comes fromspeed. The value is sticky: the controller keeps walking that way until it getsmove(0, 0)— henceonEndandonCancelon the buttons.- The controller is not a rigid body. It neither needs nor takes
Physics; it has its own gravity (gravity, −20 by default), its own jump (jump(), take-off speed injumpSpeed), its own slope limit (maxSlope). It collides with solid bodies but passes through triggers, and isn't pointer-pickable itself. - A separate node for the controller, the model as a child. That keeps the capsule and the visual out of each other's way: you tune the capsule for collisions, offset the model down so the paws touch the ground, and turning the model never turns the capsule.
- Position before aspects. The body and the controller are created wherever the node stands at attach time; to move it later use
controller.teleport(x, y, z). - Useful reads:
controller.grounded(are we standing on ground — for "jump only from the floor" and for picking a falling animation) andcontroller.velocity. - The controller does push crates, but gently — it's not a battering ram. For a real hit, give the character a separate dynamic "fist" body or apply the impulse to the target yourself.
The whole screen
A small game: the fox walks the yard on a d-pad, shoves crates, the green zone counts the ones rolled in, and a tap fires a ball from the camera. Tree trunks are obstacles now too:
// The yard with physics: the fox on a controller pushes crates into the green zone, a tap fires a ball.
const scene = new Scene({ skybox: "#1b2233" })
scene.add(Light.sun({ direction: [-1, -2, 0.7], intensity: 90000, shadowsQuality: 2 }))
const lit = (color: string, roughness = 1) => Material.lit({ color, roughness })
scene.add(Mesh.plane({ material: lit("#2f4a35"), normal: [0, 1, 0], scale: 30, receiveShadows: true })
.aspect(Shape, { box: [15, 0.05, 15] }).aspect(Physics, { motion: "static" }))
function Tree(x: number, z: number, height = 1.6) {
const tree = new Node()
tree.position = [x, 0, z]
const crown = Geometry.cylinder({ radiusTop: 0, radiusBottom: 0.55, edges: 8, smooth: false }).scale(1, height, 1).translate(0, height / 2 + 0.6, 0)
tree.add(
Mesh.cylinder({ material: lit("#6b4a2b"), radius: 0.12, scale: [1, 0.7, 1], position: [0, 0.35, 0], castShadows: true }),
Mesh.from(crown, { material: lit("#3d8b4f"), castShadows: true }),
)
return tree.aspect(Shape, { cylinder: { halfHeight: 1, radius: 0.15 } }).aspect(Physics, { motion: "static" }) // the trunk is an obstacle
}
const wood = lit("#b5854b", 0.8)
const Crate = (x: number, y: number, z: number) =>
Mesh.box({ material: wood, size: 0.45, position: [x, y, z], castShadows: true, name: "crate" }).aspect(Shape, {}).aspect(Physics, { mass: 0.3 })
scene.add(Tree(-3.5, -1.5, 2), Tree(3.2, -2.5, 1.6), Tree(-3, 2.8, 1.3), Tree(3.5, 2, 2.2))
scene.add(Crate(-0.5, 0.23, -1.5), Crate(0.5, 0.23, -1.5), Crate(0, 0.7, -1.5), Crate(1.8, 0.23, 0.5), Crate(-1.8, 0.23, 0.8))
// The trigger zone counts the crates inside it
const zone = Mesh.box({ material: Material.unlit({ color: "#34c759" }), size: [2, 0.02, 2], position: [0, 0.01, 3], name: "zone" })
.aspect(Shape, { box: [1, 0.5, 1] }).aspect(Trigger)
const inside = new Set<Node>()
const score = UIText("Loading the fox…").style({ fontSize: 14, color: "#ffffff", fontWeight: 700 })
const refresh = () => { score.text = `Crates in the zone: ${inside.size} of 5` }
zone.addEventListener("enter", o => { if (o.name === "crate") { inside.add(o); refresh() } })
zone.addEventListener("exit", o => { inside.delete(o); refresh() })
scene.add(zone)
// d-pad
let mx = 0, mz = 0
const key = (label: string, x: number, z: number) =>
UIButton(UIText(label).style({ color: "#fff", fontSize: 18 }))
.style({ bgColor: "#ffffff33", width: 48, height: 48, borderRadius: 12, alignItems: "center", justifyContent: "center", $pressed: { bgColor: "#ffffff66" } })
.onTouchStart(ev => { mx = x; mz = z; ev.track({ claim: true, onEnd: () => { mx = 0; mz = 0 }, onCancel: () => { mx = 0; mz = 0 } }) })
const hud = UIWidget(score, UIText("Tap the screen to fire a ball").style({ fontSize: 12, color: "#c7ccd6" })).style({ top: "max(safe-top, 16px)", left: 16, right: 16, gap: 2 })
const pad = UIWidget(UIColumn(UIRow(key("↑", 0, -1)), UIRow(key("←", -1, 0), key("↓", 0, 1), key("→", 1, 0)).style({ gap: 6 })).style({ gap: 6, alignItems: "center" }))
.style({ bottom: "max(safe-bottom, 24px)", left: 0, right: 0, alignItems: "center" })
hud.attachTo(scene); pad.attachTo(scene)
scene.camera.position = [0, 4.2, 6.5]
scene.camera.lookAt([0, 0.4, 0.3])
scene.open(); hud.show(); pad.show()
// The cannon: a ball is born at the camera and gets an impulse along the tap ray
const rubber = lit("#FF4032", 0.6)
scene.addEventListener("click", ev => {
const ray = scene.camera.getRay(ev.clientX, ev.clientY)
const ball = Mesh.sphere({ material: rubber, radius: 0.2, position: ray.origin, castShadows: true, name: "ball" })
.aspect(Shape, { sphere: 0.2 }).aspect(Physics, { mass: 2 })
scene.add(ball)
ball.physics.applyImpulse(ray.dir.normalize().scale(24))
})
async function main() {
const fox = await Model.load("https://cdn.le.codes/guide-assets/fox-anim.glb")
fox.castShadows = true; fox.position = [0, -0.26, 0]
const hero = new Node()
hero.position = [0, 0.26, 1]
hero.aspect(Shape, { capsule: { halfHeight: 0.06, radius: 0.2 } }).aspect(CharacterController, { speed: 2.5 })
hero.add(fox)
scene.add(hero)
fox.anim.play("walk", { loop: true })
refresh()
let camPos = scene.camera.position
setLoop(dt => {
hero.controller.move(mx, mz)
fox.anim.playing = mx !== 0 || mz !== 0
if (fox.anim.playing) fox.eulerAngles = [0, Math.atan2(mx, mz) * RAD2DEG, 0]
// The camera softly keeps the fox in frame (the "Camera & picking" step)
camPos = camPos.lerp(hero.position.add([0, 4, 5.5]), 1 - Math.pow(0.02, dt))
scene.camera.position = camPos
scene.camera.lookAt(hero.position.add([0, 0.2, 0]))
})
}
main()That's a game already: a goal, the means, and feedback. And almost none of its code is about physics — it's about what surrounds it: buttons, score, spawning. Physics takes four lines of aspects in this example, and that's how it should be.
The balls pile up: every tap leaves a new body in the world. A real game would recycle them — a pool of a dozen balls whose position is reset and velocity zeroed in turn, instead of an endless new.
What you learned
- Physics is aspects:
Shape(collision + picking) →Physics/Trigger/CharacterController.Shapealways comes first. motion:staticfor floors and walls,dynamicfor the simulation (applyImpulse,velocity),kinematicfor bodies you drive withmoveTo.- Never assign
positionto a dynamic body — physics owns its transform; reading is always fine. Triggeris a sensor zone;enter/exitfire on both nodes, and aSetis the right place to keep the membership.CharacterControlleris the player capsule: stickymove(x, z),jump(),grounded, its own gravity; a separate node for the capsule, the model as a child.Physics.supported— a host may ship without physics, and then all of this silently does nothing.Physics.configure()runs once before the first body;boxinShapetakes half-extents in world units.
Reference: 3D physics & shapes · Aspects · Node · Ray, Plane & Noise.
Next: Taking it to AR — the yard moves onto a real table.