In this guide
Box2D and Jolt bodies, colliders, joints, queries, contacts, and events.
Physics
Two deterministic backends: Box2D for 2D and Jolt for 3D. Colliders always match the authored shape — a capsule simulates as a capsule. Simulation runs at the project fixed timestep.
2D bodies and colliders
{
"Rigidbody2D": {
"body_type": "dynamic",
"continuous": true,
"linear_damping": 0.05,
"lock_rotation": true,
"report_contacts": true
},
"BoxCollider2D": {
"size": [1, 1],
"offset": [0, 0],
"is_trigger": false,
"layer": "player",
"friction": 0.4,
"restitution": 0.1,
"density": 1.0,
"category_bits": 1,
"mask_bits": 65535
}
}
Colliders: BoxCollider2D, CircleCollider2D, CapsuleCollider2D, PolygonCollider2D, EdgeCollider2D (open chain; set loop: true to close it).
body_type:dynamic(simulated),kinematic(moved by script, pushes dynamics),static(never moves).is_trigger: truereports overlaps without resolving collision.layernames a bit from projectphysics.layers(maximum 16).category_bits/mask_bitsrefine which layers collide.report_contacts: trueextracts contact points intophysics_contactevents. Collision events fire either way.- Joints:
Joint2D(revolute, prismatic, weld, rope, motor) andDistanceJoint2D(spring-distance).
local Body = require("demi.physics.rigidbody2d")
local vx, vy = Body.get_velocity(id) -- two scalars (nil per axis when missing)
Body.set_velocity(id, 2, 0)
Body.set_velocity_x(id, 0)
Body.add_impulse(id, 0, 8) -- instant kick
Body.add_force(id, 0, -20) -- continuous (apply each fixed step)
Body.add_torque(id, 1.5)
Body.set_angular_velocity(id, 0)
Body.set_awake(id, true) -- wake a sleeping body
Body.set_enabled(id, false) -- temporarily disable simulation
Body.set_continuous(id, true) -- CCD on/off
Body.set_report_contacts(id, true)
Body.move_kinematic(id, 3, 4, fixed_dt) -- target x, y and actual fixed step
local moved_x, moved_y = Body.move_and_slide(id, vx * fixed_dt, vy * fixed_dt)
get_velocity returns two scalars, nil per axis when the entity has no body. move_and_slide takes a requested displacement (not velocity or timestep), clips it against static collider bounds, and returns the applied displacement or nil, nil if unavailable. Call it in on_fixed_update. move_kinematic sets velocity toward a target over the supplied fixed step.
3D bodies and colliders
Rendering and physics are separate: MeshRenderer draws the object; a collider supplies its contact shape; Rigidbody3D supplies motion, gravity and mass. A collider alone is static geometry and is not visible without a renderer. MeshRenderer: {} draws a built-in light-grey unit cube without an external texture. Rigidbody3D: {} defaults to a dynamic body, so do not add one merely to make a fixed village prop visible.
Each entity carries exactly one 3D collider:
BoxCollider3D,SphereCollider3D,CapsuleCollider3D,ConvexCollider3D— movable shapes.ModelCollider3D— references aCollider3Dasset. Triangle meshes are static-only; convex and compound assets can move with dynamic or kinematic bodies. See Colliders.
{
"Rigidbody3D": {
"body_type": "dynamic",
"mass": 12.5,
"continuous": true,
"friction": 0.6,
"restitution": 0.05,
"report_contacts": true
},
"CapsuleCollider3D": { "radius": 0.35, "height": 1.7, "offset": [0, 0.85, 0] }
}
Validation rejects a second collider on one entity, a moving triangle mesh, invalid capsule dimensions, underspecified hulls, a moving body without a collider, and a moving body under a moving parent.
local Body = require("demi.physics.rigidbody3d")
local vx, vy, vz = Body.get_velocity(id) -- 3 scalars (nils when missing)
Body.set_velocity(id, 1, 0, 0)
Body.add_force(id, 0, 40, 0)
Body.add_impulse(id, 0, 12, 0)
Body.add_torque(id, 0, 2, 0)
local state = Body.state(id)
-- state.body_type "static" | "kinematic" | "dynamic"
-- state.mass number
-- state.use_gravity, state.enabled booleans
-- state.velocity {x, y, z}
-- state.angular_velocity {x, y, z}
Body.move_kinematic(id, x, y, z, rx, ry, rz, fixed_dt) -- Euler radians
Body.set_continuous(id, true)
Body.set_report_contacts(id, true)
Body.set_awake(id, true)
Body.set_enabled(id, true)
state returns a table of live simulation values (not the authored config) — use it for HUD readouts and gameplay checks. get_velocity is the lighter scalar form. solver_velocity_steps / solver_position_steps on the component accept 0–128; 0 means backend default.
Queries
local Q2 = require("demi.physics.query2d")
local hit = Q2.raycast(origin_x, origin_y, direction_x, direction_y, distance)
-- optional last arguments: layer, ignored_entity_id
if hit then
-- hit.entity_id, hit.layer, hit.point {x, y},
-- hit.normal {x, y}, hit.distance, hit.fraction
print(hit.entity_id, hit.fraction)
end
local overlaps = Q2.overlap_circle_all(x, y, radius) -- array of hits, may be empty
for _, h in ipairs(overlaps) do print(h.entity_id) end
local one = Q2.overlap_box(x, y, hx, hy) -- first hit or nil
local touching = Q2.has_contact(a, b) -- boolean
local points = Q2.contacts(a, b) -- contact-point array
A missed raycast returns nil — always guard before reading hit.*. Overlap queries return arrays so you can process every candidate; they are empty, not nil, when nothing is found.
local Q3 = require("demi.physics.query3d")
local hit3 = Q3.raycast(ox, oy, oz, dx, dy, dz, max_distance)
if hit3 then
-- hit3.point {x, y, z}, hit3.normal {x, y, z}
-- hit3.entity_id, hit3.distance, hit3.fraction
end
local nearest = Q3.overlap_sphere(x, y, z, radius) -- first or nil
local all = Q3.overlap_sphere_all(x, y, z, radius) -- array
local boxes = Q3.overlap_box_all(x, y, z, hx, hy, hz) -- array
local cast = Q3.sphere_cast(x1, y1, z1, x2, y2, z2, r) -- hit or nil
Pass { include_triggers = true } when triggers should count. 3D hit tables embed point and normal as 3-float arrays.
Events
| Event | When | Payload highlights |
|---|---|---|
physics_collision_enter/stay/exit | 2D solid contacts | self_id, other_id, point, normal |
physics_trigger_enter/stay/exit | 2D triggers | self_id, other_id |
physics_contact | Extracted 2D contact points | Contact manifold details |
physics3d_collision_enter/stay/exit | 3D solid contacts | Flattened point_x…normal_z |
physics3d_trigger_* | 3D triggers (character controllers fire these too) | self_id, other_id |
physics3d_contact | Extracted 3D contact points | Manifold details |
Subscribe with Events.subscribe("physics_collision_enter", handler). The handler receives one payload table.
Determinism
- Simulation runs at the project fixed timestep only — apply forces inside
on_fixed_update. - Jolt uses a capped worker pool in deterministic mode.
- Contacts dispatch in a stable entity-pair order.
- Replay comparisons are tolerance-based (floating-point contacts are not bit-identical across machines).
Debug drawing
demi run \
--debug-overlays colliders,contacts
Or set project debug.colliders / debug.contacts. Overlay geometry matches the authored simulation shapes.