DEMIENGINEDOCUMENTATION Everything is free
Physics · Game systems

Raycasts, overlaps, and contact checks.

Queries

A query asks the physics simulation a question without spawning anything: “what is along this line?”, “what sits inside this circle?”, “is anything under my feet?”. This page shows when to use each query type and every field a hit returns.

What a query is

Gameplay constantly needs to know about space before it acts. A turret must know whether a wall blocks its view. A hero must know whether it is standing on ground before allowing a jump. A pickup must know which loot lies within reach.

You could answer any of those with a trigger volume and an event, but that means extra entities and state to clean up. A query is a one-shot question answered immediately: pass positions and sizes, get back a hit (or nothing). Nothing is spawned, nothing persists, and it is safe to call every frame.

Queries respect the same layer filters as real contacts, so a ray that ignores pickups can be authored with mask_bits rather than extra checks in Lua.

Raycast, overlap, and shape cast

Three families of questions cover almost every need.

FamilyQuestionEveryday uses
RaycastWhat does this straight line hit first?Line of sight, laser sights, mouse picking, sniper traces.
OverlapWhat is already inside this region?Pickup radius, blast area, “is this tile occupied?”, crowd gathering.
Shape castWhat would this moving shape hit on the way?Ground check with a sphere, dash safety, projectile width sweeps.

Use a ray when you only need the first blocker. Use overlap when you need everyone inside an area. Use a cast when the moving object has real width and a thin ray would miss the wall it scrapes.

2D queries

The 2D module is demi.physics.query2d. All positions are world-space x, y. Raycasts take an origin plus a direction and a distance, not two end points.

local Q2 = require("demi.physics.query2d")

-- Cast 8 units straight down from (2, 5):
local hit = Q2.raycast(2, 5, 0, -1, 8)
if hit then
    print(hit.entity_id, hit.distance)
end

local touching_ids = Q2.overlap_circle(4, 0, 1.5)          -- array of entity IDs
local shard_hits   = Q2.overlap_circle_all(4, 0, 1.5)      -- array of hit tables
local box_occupied = Q2.overlap_box(0, 0, 2, 2)            -- boolean

local touching = Q2.has_contact("player")
local points   = Q2.contacts("player")
CallArgumentsReturns
raycastorigin x, y, direction dir_x, dir_y, distance, then optional layer and ignored_entity_idOne hit table, or nil when the ray hits nothing.
overlap_boxcenter x, y, size width, height, optional ignored_entity_idtrue when anything overlaps the box, otherwise false.
overlap_circlecenter x, y, radius, optional layer and ignored_entity_idArray of overlapping entity IDs (empty when none).
overlap_box_allcenter x, y, size width, height, optional layer and ignored_entity_idArray of hit tables. Empty when nothing is found — never nil.
overlap_circle_allSame optional arguments as overlap_circleArray of hit tables.
contactsone entity idArray of that entity’s current contacts (with phase enter/stay/exit), empty when apart.
has_contactone entity id, optional filter table (layer, normal ranges, include_triggers)true when the entity currently touches a matching collider.

Optional layer narrows a query to one physics layer; optional ignored_entity_id skips one entity (the caster itself, usually).

2D hit fields

FieldTypeMeaning
entity_identity idThe entity whose collider was hit.
layerstringLayer name of the hit collider.
point{ x, y }World position of the contact or intersection.
normal{ x, y }Unit vector pointing away from the surface — use it for bounce and alignment.
distancenumberDistance along the ray or cast to the hit, in world units.
fractionnumberHow far along the query the hit is, from 0 (start) to 1 (end).

3D hits additionally carry collider_part_id (stable part ID inside a compound collider, raycasts only) and is_trigger.

Everyday 2D patterns

Line of sight for a Skyforge patrol drone — does a wall sit between the drone and the intruder?

local Q2 = require("demi.physics.query2d")

local to_player_x, to_player_y = px - drone_x, py - drone_y
local length = math.sqrt(to_player_x * to_player_x + to_player_y * to_player_y)
local dir_x, dir_y = to_player_x / length, to_player_y / length

local hit = Q2.raycast(drone_x, drone_y, dir_x, dir_y, length, nil, drone_id)
if hit and hit.entity_id ~= player_id then
    -- a wall (or something else) blocks the view
end
  • Normalize the direction to the player and cast that far; nil means the path is clear.
  • The drone itself is skipped with ignored_entity_id, so it never blocks its own view.
  • Comparing hit.entity_id to the player tells you whether the player was the first thing in the way.

Pickup radius — grab every shard in range:

local shards = Q2.overlap_circle_all(x, y, 1.25)
for _, h in ipairs(shards) do
    -- h.entity_id, h.point, h.distance
end
  • overlap_circle_all returns every overlapping hit as an array of hit tables (fields in the table above).
  • An empty array means nothing was in range; ipairs simply does nothing.

Ground check before a jump:

local ground = Q2.raycast(x, y, 0, -1, 0.2)
local can_jump = ground ~= nil
  • The ray starts at the feet, points straight down (direction 0, -1), and reaches 0.2 units.
  • can_jump becomes true when something was hit, false when the result was nil.

3D queries

The 3D module is demi.physics.query3d. Rays are defined by an origin and a direction, not two points.

local Q3 = require("demi.physics.query3d")

local hit = Q3.raycast(ox, oy, oz, dx, dy, dz, max_distance)
if hit then
    print(hit.entity_id, hit.distance)
end

local ids  = Q3.overlap_sphere(x, y, z, radius)          -- array of entity IDs
local hits = Q3.overlap_sphere_all(x, y, z, radius)      -- array of hit tables
local boxes = Q3.overlap_box_all(x, y, z, width, height, depth)
local cast = Q3.sphere_cast(ox, oy, oz, radius, dir_x, dir_y, dir_z, distance)
CallArgumentsReturns
raycastorigin ox, oy, oz, direction dx, dy, dz, distance, optional ignored_entity_idHit table or nil.
overlap_spherecenter x, y, z, radius, optional ignored_entity_idArray of overlapping entity IDs.
overlap_sphere_allcenter, radius, optional layer and ignored_entity_idArray of hit tables, empty when none.
overlap_box_allcenter x, y, z, size width, height, depth, optional layer and ignored_entity_idArray of hit tables, empty when none.
sphere_castorigin, radius, direction dir_x, dir_y, dir_z, distance, then optional layer, ignored_entity_id, include_triggersHit table or nil. Sweeps the sphere along the direction.

Only sphere_cast accepts include_triggers (default true; pass false to find the first solid collider beyond triggers). The 3D raycast’s optional final argument is an ignored_entity_id, not a layer filter.

3D hit fields

FieldTypeMeaning
entity_identity idEntity whose collider was hit.
layerstringLayer name of the hit collider.
point{ x, y, z }World position of the hit.
normal{ x, y, z }Outward surface direction at the hit.
distancenumberWorld units from the query origin to the hit.
fractionnumberProgress along the query, 0 to 1.

Unlike 2D, point and normal are 3-component tables. A camera-driven pick in Skyforge looks like this:

local Q3 = require("demi.physics.query3d")
local Camera3D = require("demi.camera3d")

local ray = Camera3D.screen_ray(camera_id, mx, my, view_w, view_h)
if ray then
    local o, d = ray.origin, ray.direction
    local hit = Q3.raycast(o[1], o[2], o[3], d[1], d[2], d[3], 100)
    if hit then
        -- hit.entity_id, hit.point {x, y, z}, hit.distance
    end
end
  • screen_ray only builds a world-space origin and normalized direction.
  • Q3.raycast performs the physics query and returns a hit or nil. The example limits it to 100 world units.

Nil versus empty array

This distinction saves a class of bugs. Single-result queries and nil go together; list queries and empty arrays go together.

ResultWhenHow to check
nilraycast or sphere_cast found nothingif hit then ... end before reading hit.*
falseoverlap_box found nothing, or has_contact reports no touchPlain boolean test
Empty arrayoverlap_*_all or contacts found nothing#list == 0 or simply loop with ipairs

Always guard single-hit results before reading fields: hit.entity_id on a nil hit raises an error, while iterating an empty array is harmless.

Practical tips

  • Queries are cheap enough for per-frame checks like ground tests, but do not cast hundreds of long rays every step if one area query answers the same question.
  • include_triggers only exists on 3D sphere_cast (default true). 2D raycasts and 3D raycasts always report whatever is first; filter by hit.is_trigger or layer when triggers should not count.
  • Use fraction to lerp effects along a laser path and normal to orient decals and bounce particles.
  • Layer filters decide who is visible to a query — tune them in Physics layers instead of filtering results by hand.

Queries are immediate; lasting contacts are better served by Physics events. Shapes being tested are described in Colliders.