EngineIntermediate

Physics, collisions, and triggers

Apply forces, receive collision callbacks, configure triggers, and query the world.

Rigid-body control

  • addForce(x, y, z) applies a force for the next physics step. Call it every frame from update to keep pushing.
  • addImpulse(x, y, z) applies an instantaneous impulse.
  • addTorque(x, y, z) applies a rotational force for the next physics step, the same way.

All three also work on soft bodies, which need much larger numbers than their size suggests — see Soft bodies.

To read or overwrite motion directly, use getLinearVelocity(), setLinearVelocity(x, y, z), getAngularVelocity(), and setAngularVelocity(x, y, z).

Respawning. setPosition moves a body but leaves its momentum alone, so a respawned ball or car arrives still travelling and leaves again. Use resetPhysicsState(x, y, z) instead. It places the object, zeroes its linear and angular velocity, clears a vehicle's driver input, and wakes the body, all in one step. Pass a quaternion as four more arguments, resetPhysicsState(x, y, z, qx, qy, qz, qw), to set the rotation at the same time.

None of the force or velocity calls affect a character, which has no rigid body. Push a character by changing its velocity with setCharacterVelocity — see Characters.

Mass

The Mass field on a dynamic body is blank by default and reads Auto. An object with no mass set is weighed from its collider at a density of 1000 kg/m³ — the density of water — so a 1×1×1 m cube comes out at 1000 kg, and a sphere of radius 1 at about 4189 kg. Because the weight follows the collider, resizing an object re-weighs it.

Enter a number to override that and pin the mass in kilograms; clear the field to go back to the derived weight. Mass applies only to dynamic bodies, and it is what makes addForce, addImpulse, and addTorque feel heavy or light — an impulse that sends a 10 kg crate flying will barely disturb a default-weight cube. Characters have their own Mass field, used by the character controller, and vehicles use chassis mass instead. A soft body ignores the field entirely — its mass is its vertex count, which is usually far higher than you would guess; see Soft bodies.

Other rigid-body settings

The same inspector section as Mass carries the rest of a dynamic body's settings. Like Mass, it appears only on a dynamic cube, sphere, cylinder, capsule, or mesh that is not a trigger or a soft body.

  • Friction — blank uses the default of 0.2.
  • Restitution — bounciness from 0 to 1; blank is 0, no bounce.
  • Gravity Factor — multiplies the scene's gravity for this body. 1 is normal, 0 floats, and a negative value makes it rise.
  • Center of Mass Offset — moves the balance point in the object's local space, scaled with the object. A lower Y makes an object harder to tip over.
  • Lock to 2D Plane (XY) — the body can only move in X and Y and only rotate about Z, for side-scrollers and pinball tables.

Where two bodies touch, their frictions are combined as the square root of the product and their restitutions by taking the larger, so a bouncy ball still bounces off a floor with no restitution, and either surface at zero friction makes the contact frictionless. A triangle-mesh model's per-surface friction and restitution replace the body's own on that surface — see Assets, materials, and shaders.

Collision callbacks

Call registerForCollisions() from init() before using collision or trigger callbacks. Call deregisterForCollisions() to stop receiving them again, for example when a script disables itself.

function init()
    gameObject.registerForCollisions()
end

function onCollisionEnter(other)
    print("Hit " .. other.id)
    print("Normal Y: " .. other.normal.y)
end

function onCollisionExit(other)
    print("Left " .. other.id)
end

In onCollisionEnter and onTriggerEnter, other carries id, the other object's position and rotation, normal, contactPoint, contactDepth, and isTrigger. normal points away from the other object, toward this one. onCollisionExit and onTriggerExit get only id, position, and rotation, because there is no contact left to describe. Treat it as callback-local data; copy plain numbers if you need them later.

Characters report their contacts. When a character touches a body — a ball thrown at it, a crate it walks into, the floor it lands on — both sides get onCollisionEnter, and onCollisionExit once Jolt stops listing the pair, provided each side has called registerForCollisions(). A ball resting against a character stays entered rather than entering and leaving every frame, and two characters touching count as one contact. other.normal follows the same rule as any other contact. Until early October 2026 neither side heard about a character contact, so a script that compares a ball's position against each character's capsule to detect a hit can use onCollisionEnter instead.

other is a plain data table, not a gameObject. It carries the fields above and nothing else — it has no methods on it. other.setPosition(...), other.setVar(...) and the rest are all nil, and calling one raises an error inside the callback. The handler stops at that point, so from the outside the collision looks exactly like one that never fired: the other object simply does not move. To act on what you hit, resolve it to a real object first with gameObject.getObject(other.id).

function onTriggerEnter(other)
    local entered = gameObject.getObject(other.id)
    if entered then
        entered.setPosition(0, 5, 0)
    end
end

Errors raised inside a callback are reported to the browser console, and physics_run_simulation returns them in script_errors. Check that list first whenever a contact is recorded but nothing moved.

Trigger volumes

Enable Trigger Volume on a cube, sphere, cylinder, or capsule. Triggers do not block motion and invoke onTriggerEnter(other) and onTriggerExit(other). They are simulated as kinematic moving-layer sensors so they can detect static bodies and characters. A soft body cannot be a trigger volume — see below.

If a script registered for collisions defines no onTriggerEnter or onTriggerExit, trigger contacts are delivered to onCollisionEnter and onCollisionExit instead, with other.isTrigger set to true. Define the trigger handlers whenever you want to tell the two apart.

Visual-only objects

A cube, sphere, cylinder or capsule normally has a collider. Turn on Visual Only in its properties, or set collisionType: "none" through MCP, and it is drawn with no collider at all: players, objects, raycasts, shots and triggers pass through it, and it is left out of the navigation mesh. A mesh does the same with its collision set to None. A visual-only object still moves with its parent and with its own script.

Use it for decoration attached to something that moves, above all to a player. A first-person weapon built from primitives and attached to the camera sits inside the player's capsule, and solid parts there push the player sideways every frame, with no input. Do not use Trigger Volume (isSensor) for decoration: a trigger still fires onTriggerEnter and still stops raycasts. On a primitive, "none" is the only collisionType accepted, and a soft body cannot be visual-only.

Compound bodies

An object whose children include a collider gets no collider of its own; each of those children gets its own body instead. For a chair built from a seat and four legs, that means five separate bodies that fall apart.

Children that collide with nothing are decoration and leave their parent's body alone: a visual-only object (a mesh with collision set to None, or a primitive with Visual Only on), a light, a camera, a constraint, or a GUI element. A ball with a seams mesh under it still falls, bounces and takes impulses, and the seams ride along with it. An empty counts by what is under it. Before 3 October 2026 any child at all took the parent's collider away, so a decorated dynamic object hung where it was placed and ignored addImpulse without an error. A script that pushes an object whose children provide its collision now logs a warning naming the object.

Turn on Compound Children on a cube, sphere, cylinder, or capsule parent to combine its direct cube, sphere, cylinder, and capsule children into one rigid body. The children stay where you placed them and keep rendering normally, but they no longer get bodies of their own, and the parent's own shape is no longer part of the collider — the children are the collider. Scaling the parent scales the whole compound. If the parent has no eligible children it falls back to its own shape. Soft bodies cannot be a compound parent or a compound child.

Collision groups

Sometimes two objects should pass through each other while still colliding with everything else — a vehicle's wheels against its own chassis, or the neighbouring limbs of a ragdoll, which would otherwise jitter against each other at every joint.

By default an object has no collision group and collides with everything on ordinary layer rules. Give two or more objects the same Collision Group ID and they stop consulting the layers against each other and consult that group's filter table instead.

The table lives on the scene, not the object. Edit it under Collision Groups in Scene Settings: + Group adds a group, and + Pair adds a disabled pair to it. Each group in the scene's collision-group configuration carries:

  • groupId — the number objects refer to.
  • subGroupCount — how many subgroups the group has (Sub-Group Capacity in Scene Settings, 16 by default, at most 256).
  • disabledPairs — the pairs of subgroups that do not collide, as [a, b].

Each object then picks its own Sub-Group ID within the group, defaulting to 0. A pair of subgroups listed in disabledPairs passes through; any pair not listed still collides normally, so the table is a list of exceptions rather than a whitelist. Giving every part its own subgroup and listing no pairs changes nothing.

A body with a group still collides normally with every body that has no group at all — filtering only applies between two bodies sharing a groupId. Set the per-object ids in the inspector or through scene_objects_update (collisionGroupId, collisionSubGroupId), and the group table in Scene Settings or through scenes_update, whose collision_groups entries take group_id, sub_group_count, and disabled_pairs.

Conveyor surfaces

Conveyor Velocity gives a body's surface a tangential velocity in units per second, so anything resting on it is carried along while the body itself stays exactly where it is. It drives conveyor belts, moving walkways, and treadmills without moving or duplicating any geometry.

The velocity is in the body's local space and is rotated into world space by the body's current orientation at each contact. Rotating the belt therefore turns the direction it carries things, and a belt on a slope pushes along the slope rather than horizontally. The default is zero, which has no effect.

Because this is a surface property applied at the contact, nothing is actually in motion: a static body works as a conveyor, and the carried object keeps its own mass, friction, and gravity. Friction is what transfers the motion, so a very low-friction material on either side will slip rather than ride.

Soft bodies

Soft Body is a switch on an ordinary shape, not a separate kind of object. Turn it on for a cube, sphere, cylinder, capsule, or mesh and that object is simulated with Jolt's soft-body solver: it deforms under collision and gravity instead of moving as one rigid shape. Cloth is the exception and remains its own object type, because it is an open pinned sheet with no rigid equivalent to switch.

The switch carries no dimensions of its own. A soft cylinder deforms the cylinder's own Radius and Half Height, so resizing the shape resizes the body, and turning the switch back off leaves the same rigid shape at the same size. Turning it on forces the object dynamic and clears Trigger Volume and Compound Children: Jolt has no static or kinematic soft body, no soft sensor, and no soft compound shape.

Cost. A soft body costs its simulated vertex count multiplied by its Solver Iterations, every step. Raise the segment counts and iterations only as far as the deformation actually needs.

Pressure and stiffness. Sphere, cylinder, capsule, and mesh are hollow shells that keep their form because Pressure inflates them — with too little, they fold flat under anything resting on them. The cube is different: it is a solid lattice held up by tetrahedral volume constraints, so it wants very little pressure and is tuned with Volume Compliance instead. Smaller compliance values are stiffer.

Vertex Radius is a collision margin around every simulated vertex, which makes a body slightly larger than its authored dimensions. Objects stacked at exactly their own height will therefore overlap and shove each other apart; leave a small gap, or reduce the radius.

Soft meshes. A soft mesh is rebuilt as a simplified shell of the model — its triangles are welded and decimated down to the Vertex Budget, and that shell is both the simulated body and what you see. It loses the source asset's materials, UVs, and any animation, and renders in the object's own material. A model that is not watertight has no interior for pressure to act on, so it is simulated double-sided with pressure ignored, like cloth.

Soft bodies are created and configured in the editor, or through the MCP soft_bodies_create and soft_bodies_update tools.

Pushing one from Lua. addForce, addImpulse and addTorque all work on a soft body, exactly as they do on a rigid one. (There is no applyImpulse, on any kind of object — the method is addImpulse.) What changes is the magnitude you need.

A soft body's mass is its vertex count. Every simulated vertex weighs 1 kg, so the default soft sphere is 162 vertices and therefore 162 kg — and raising Vertical segments or Radial segments for a smoother ball makes it heavier, not just denser-looking. The Mass field above does not apply to soft bodies at all; it is ignored. An impulse tuned against a rigid sphere will look like it did nothing. Multiply by the vertex count, or start around 500 and work down.

function onTriggerEnter(other)
    local ball = gameObject.getObjectByName("Jelly Ball")
    if ball then
        ball.addImpulse(800, 0, 0)  -- about 5 m/s on a 162-vertex sphere
    end
end

Use addImpulse for a one-off shove from a trigger, as above. addForce and addTorque are per-frame: like Jolt's own accumulators they are consumed by the next physics step and then cleared, so a single call from onTriggerEnter lasts one frame. To push for longer, call them from update(dt) while a flag you set in the callback is still true.

A pinned cloth's pinned vertices are anchors: they do not move, and they carry none of the push, so the free part of the sheet takes all of it.

Water

A water object is a box of fluid. Its top face is the surface and the whole box is the volume. Every dynamic body inside it is pushed up by buoyancy, slowed by drag, and carried by the current, every step. The water itself never moves and blocks nothing.

  • Buoyancy (default 1.1) — 1 is roughly neutral when fully submerged; above 1 floats, below 1 sinks.
  • Linear drag (default 0.3) — slows movement relative to the current.
  • Angular drag (default 0.05) — slows rotation.
  • Current velocity (default zero) — a world-space velocity the water carries things along at.

How much of a body is under the surface decides how hard it is pushed, so a heavy object still sinks until enough of it is submerged. Characters float and swim in water too — see Characters.

Spatial queries

  • raycast(direction, maxDistance, options)
  • sphereQuery(radius)
  • boxQuery(halfExtents)

All three start at the calling object's position. raycast's direction is a world-space vector; it is not rotated by the object, and it is normalized for you. It returns a list of hits sorted nearest first, empty on a miss, and each hit is a table {shape, point, distance, fraction}: shape is the id of the object hit, and fraction is distance / maxDistance. There is no surface normal. sphereQuery and boxQuery return a list of object ids. boxQuery's box is aligned to the world axes, not to the object.

A ray stops at the nearest thing in its way. The list can hold more than one hit, but never anything behind that first object, so skipping a hit in the script does not reveal what is behind it. Say what the ray should pass through in the optional third argument instead:

  • ignore: objects or ids to pass through, as a list or a single one.
  • ignoreSensors = true: pass through trigger volumes, which are hit by default.
  • ignoreSelf = false: hit the calling object too. It is ignored by default, because a ray that starts inside an object always hits it at distance 0.

Characters are found by their capsule. A shot from a first-person camera, which sits inside the player's capsule, passes the player in ignore:

local player = gameObject.getObjectByName("Player 1")
local hits = gameObject.raycast(gameObject.getForward(), 100, {
    ignore = { player },
    ignoreSensors = true,
})
if #hits > 0 then
    print("Hit " .. hits[1].shape .. " at " .. hits[1].distance)
end

Before 9 October 2026 there were no options and the calling object was included. sphereQuery and boxQuery take no options and still include the calling object and trigger volumes, so skip those by id.

See Cross-object interactions for a complete example that finds nearby rigid bodies, resolves their object APIs, and applies radial impulses.