DEMIENGINEDOCUMENTATION Everything is free
Game systems

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.

The Jolt 3D stress lab: a wooden Galton cabinet with pegs and bins.
A 3D physics demo scene: hundreds of dynamic bodies falling through a peg field into bins, simulated by Jolt 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: true reports overlaps without resolving collision.
  • layer names a bit from project physics.layers (maximum 16). category_bits / mask_bits refine which layers collide.
  • report_contacts: true extracts contact points into physics_contact events. Collision events fire either way.
  • Joints: Joint2D (revolute, prismatic, weld, rope, motor) and DistanceJoint2D (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 a Collider3D asset. 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

EventWhenPayload highlights
physics_collision_enter/stay/exit2D solid contactsself_id, other_id, point, normal
physics_trigger_enter/stay/exit2D triggersself_id, other_id
physics_contactExtracted 2D contact pointsContact manifold details
physics3d_collision_enter/stay/exit3D solid contactsFlattened point_x…normal_z
physics3d_trigger_*3D triggers (character controllers fire these too)self_id, other_id
physics3d_contactExtracted 3D contact pointsManifold 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.