Collision, trigger, and contact payloads.
Physics events
Physics events report what the simulation just did: bodies meeting, touching, and separating. This page explains the enter/stay/exit lifecycle, the difference between trigger and collision streams, and every payload field you receive in Lua.
What a physics event is
Collisions happen at simulation speed, between steps of your script. Instead of polling every pair every frame, the simulation reports its contacts as named events your script can subscribe to.
That makes gameplay code small: subscribe once to physics_collision_enter, and every first touch anywhere in the scene reaches the same handler. No per-entity watcher, no manual overlap bookkeeping.
The enter, stay, and exit lifecycle
Every solid contact and every trigger overlap has a three-step life:
- Enter — the two shapes just started touching. Fire the one-shot reaction here: damage, sound, a spark.
- Stay — they were touching last step and still are. Use it for grinding damage, charging meters, sustained dialogue zones.
- Exit — they were touching and are no longer. Clear state here: end a damage-over-time, close a door.
Enter and exit always balance. A body that is destroyed while inside a trigger receives the matching exit so you never keep stale “inside” flags.
Trigger events versus collision events
Two parallel streams exist, chosen by the collider’s is_trigger flag.
| Stream | Emitted when | Blocks movement |
|---|---|---|
physics_collision_enter/stay/exit | Two solid 2D colliders touch | Yes |
physics_trigger_enter/stay/exit | A 2D trigger overlaps anything its filter accepts | No |
physics3d_collision_enter/stay/exit | Two solid 3D colliders touch | Yes |
physics3d_trigger_enter/stay/exit | A 3D trigger overlaps an accepted shape (character controllers report here too) | No |
physics_contact | A 2D body with report_contacts has contact points to share | Yes (contact of a solid pair) |
physics3d_contact | A 3D body with report_contacts has contact points to share | Yes (contact of a solid pair) |
Triggers are for regions you want noticed (checkpoints, pickup ranges, sensor fields). Collisions are for physical meetings that push back (landings, bumps, walls).
Both streams obey layer filters. If a trigger never fires, check mask_bits on both shapes before anything else.
Contact points and report_contacts
Enter/stay/exit tell you who met. Sometimes you also need where — to spawn a spark, dent a hull, or play a surface-specific thud.
Set report_contacts: true on the rigidbody that should share its contacts. The simulation then emits physics_contact (2D) or physics3d_contact (3D) with the extracted contact points. Collision events fire either way; only the point-level stream is opt-in, because collecting exact contact points is extra work you should only pay when you use it.
local Events = require("demi.events")
Events.subscribe("physics_contact", function(e)
-- e.self_id, e.other_id, e.point, e.normal
-- spawn a spark at e.point
end)
Events.subscribe(name, handler)registershandlerfor that event name and returns a subscription value you can keep.handlerreceives one payload table per event and returns nothing.- Without
report_contacts: trueon the body, this handler never runs for that body.
2D payload fields
2D payloads keep point and normal as nested 2-value tables.
| Field | Type | Present on | Meaning |
|---|---|---|---|
self_id | entity id | all | The body whose contact is being reported. |
other_id | entity id | all | The other body in the pair. |
point | { x, y } | collision, contact | World position of the touch. |
normal | { x, y } | collision, contact | Unit direction away from the surface. |
Trigger payloads carry self_id and other_id — enough to know who entered the region. Contact payloads add the exact point and normal.
local Events = require("demi.events")
Events.subscribe("physics_trigger_enter", function(e)
-- e.self_id the trigger entity
-- e.other_id the entity that walked in
if e.other_id == player_id then
print("player entered the zone")
end
end)
Events.subscribe("physics_collision_enter", function(e)
-- e.self_id, e.other_id, e.point {x, y}, e.normal {x, y}
end)
Events.subscribe("physics_collision_stay", function(e) end)
Events.subscribe("physics_collision_exit", function(e) end)
Events.subscribe("physics_trigger_stay", function(e) end)
Events.subscribe("physics_trigger_exit", function(e) end)
- Each handler receives exactly one payload table and returns nothing.
self_idandother_idalways arrive, so one handler can filter by the entity it cares about.pointandnormalarrive on collision and contact streams as{ x, y }tables.
3D payload fields
3D collision and contact payloads are flattened: instead of nested tables, each coordinate is its own top-level number. That keeps the payload easy to spread across logs and network messages.
| Field | Type | Present on | Meaning |
|---|---|---|---|
self_id | entity id | all | Body whose contact is reported. |
other_id | entity id | all | The other body in the pair. |
point_x, point_y, point_z | numbers | collision, contact | World contact position. |
normal_x, normal_y, normal_z | numbers | collision, contact | Outward surface direction. |
Trigger payloads (physics3d_trigger_*) carry self_id and other_id only.
local Events = require("demi.events")
Events.subscribe("physics3d_collision_enter", function(e)
-- e.self_id, e.other_id
-- e.point_x, e.point_y, e.point_z
-- e.normal_x, e.normal_y, e.normal_z
local impact = { x = e.point_x, y = e.point_y, z = e.point_z }
end)
Events.subscribe("physics3d_trigger_enter", function(e)
-- e.self_id, e.other_id
end)
Events.subscribe("physics3d_contact", function(e)
-- same flattened point_x..normal_z fields as collision
end)
- Flattened fields are plain numbers; assemble vectors only when a call needs them.
physics3d_contactuses the same flattened shape asphysics3d_collision_*.- Missing
report_contactsmeans nophysics3d_contactevents for that body, while collision events still arrive.
Subscribing in practice
A Skyforge landing zone that announces arrivals and tracks who is parked:
local Events = require("demi.events")
local parked = {}
Events.subscribe("physics_trigger_enter", function(e)
if e.self_id == zone_id then
parked[e.other_id] = true
end
end)
Events.subscribe("physics_trigger_exit", function(e)
if e.self_id == zone_id then
parked[e.other_id] = nil
end
end)
- Enter adds the ship to a lookup table keyed by
other_id; exit removes it. - Filtering on
self_id == zone_idkeeps other triggers from polluting the set. - Stay is unused here because the set already knows the current members.
A hull that sparks and dents only when hit hard:
local Events = require("demi.events")
local Deform = require("demi.mesh.deformation")
Events.subscribe("physics3d_contact", function(e)
if e.self_id ~= hull_id then return end
Deform.impact(hull_id, e.point_x, e.point_y, e.point_z, 0.4)
end)
- The handler reads the flattened contact point and applies a cosmetic dent at that position.
Deform.impactreturns nothing; visible damage is on the mesh.- Requires
report_contacts: trueon the hull’s rigidbody and a dentable mesh.
Choosing the right reaction
- enter for one-shot gameplay: first damage, pickup, unlock.
- stay for continuous pressure: healing beams, magnet pull, grinding.
- exit for cleanup that must always run.
- contact when the exact hit position matters and
report_contactsis enabled. - For “is anything there right now?” questions without waiting for a frame, prefer Queries.