DEMIENGINEDOCUMENTATION Everything is free
Physics · Game systems

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:

  1. Enter — the two shapes just started touching. Fire the one-shot reaction here: damage, sound, a spark.
  2. Stay — they were touching last step and still are. Use it for grinding damage, charging meters, sustained dialogue zones.
  3. 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.

StreamEmitted whenBlocks movement
physics_collision_enter/stay/exitTwo solid 2D colliders touchYes
physics_trigger_enter/stay/exitA 2D trigger overlaps anything its filter acceptsNo
physics3d_collision_enter/stay/exitTwo solid 3D colliders touchYes
physics3d_trigger_enter/stay/exitA 3D trigger overlaps an accepted shape (character controllers report here too)No
physics_contactA 2D body with report_contacts has contact points to shareYes (contact of a solid pair)
physics3d_contactA 3D body with report_contacts has contact points to shareYes (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) registers handler for that event name and returns a subscription value you can keep.
  • handler receives one payload table per event and returns nothing.
  • Without report_contacts: true on the body, this handler never runs for that body.

2D payload fields

2D payloads keep point and normal as nested 2-value tables.

FieldTypePresent onMeaning
self_identity idallThe body whose contact is being reported.
other_identity idallThe other body in the pair.
point{ x, y }collision, contactWorld position of the touch.
normal{ x, y }collision, contactUnit 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_id and other_id always arrive, so one handler can filter by the entity it cares about.
  • point and normal arrive 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.

FieldTypePresent onMeaning
self_identity idallBody whose contact is reported.
other_identity idallThe other body in the pair.
point_x, point_y, point_znumberscollision, contactWorld contact position.
normal_x, normal_y, normal_znumberscollision, contactOutward 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_contact uses the same flattened shape as physics3d_collision_*.
  • Missing report_contacts means no physics3d_contact events 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_id keeps 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.impact returns nothing; visible damage is on the mesh.
  • Requires report_contacts: true on 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_contacts is enabled.
  • For “is anything there right now?” questions without waiting for a frame, prefer Queries.