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.
| Family | Question | Everyday uses |
|---|---|---|
| Raycast | What does this straight line hit first? | Line of sight, laser sights, mouse picking, sniper traces. |
| Overlap | What is already inside this region? | Pickup radius, blast area, “is this tile occupied?”, crowd gathering. |
| Shape cast | What 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")
| Call | Arguments | Returns |
|---|---|---|
raycast | origin x, y, direction dir_x, dir_y, distance, then optional layer and ignored_entity_id | One hit table, or nil when the ray hits nothing. |
overlap_box | center x, y, size width, height, optional ignored_entity_id | true when anything overlaps the box, otherwise false. |
overlap_circle | center x, y, radius, optional layer and ignored_entity_id | Array of overlapping entity IDs (empty when none). |
overlap_box_all | center x, y, size width, height, optional layer and ignored_entity_id | Array of hit tables. Empty when nothing is found — never nil. |
overlap_circle_all | Same optional arguments as overlap_circle | Array of hit tables. |
contacts | one entity id | Array of that entity’s current contacts (with phase enter/stay/exit), empty when apart. |
has_contact | one 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
| Field | Type | Meaning |
|---|---|---|
entity_id | entity id | The entity whose collider was hit. |
layer | string | Layer 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. |
distance | number | Distance along the ray or cast to the hit, in world units. |
fraction | number | How 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;
nilmeans the path is clear. - The drone itself is skipped with
ignored_entity_id, so it never blocks its own view. - Comparing
hit.entity_idto 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_allreturns every overlapping hit as an array of hit tables (fields in the table above).- An empty array means nothing was in range;
ipairssimply 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_jumpbecomestruewhen something was hit,falsewhen the result wasnil.
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)
| Call | Arguments | Returns |
|---|---|---|
raycast | origin ox, oy, oz, direction dx, dy, dz, distance, optional ignored_entity_id | Hit table or nil. |
overlap_sphere | center x, y, z, radius, optional ignored_entity_id | Array of overlapping entity IDs. |
overlap_sphere_all | center, radius, optional layer and ignored_entity_id | Array of hit tables, empty when none. |
overlap_box_all | center x, y, z, size width, height, depth, optional layer and ignored_entity_id | Array of hit tables, empty when none. |
sphere_cast | origin, radius, direction dir_x, dir_y, dir_z, distance, then optional layer, ignored_entity_id, include_triggers | Hit 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
| Field | Type | Meaning |
|---|---|---|
entity_id | entity id | Entity whose collider was hit. |
layer | string | Layer name of the hit collider. |
point | { x, y, z } | World position of the hit. |
normal | { x, y, z } | Outward surface direction at the hit. |
distance | number | World units from the query origin to the hit. |
fraction | number | Progress 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_rayonly builds a world-space origin and normalized direction.Q3.raycastperforms 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.
| Result | When | How to check |
|---|---|---|
nil | raycast or sphere_cast found nothing | if hit then ... end before reading hit.* |
false | overlap_box found nothing, or has_contact reports no touch | Plain boolean test |
| Empty array | overlap_*_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_triggersonly exists on 3Dsphere_cast(defaulttrue). 2D raycasts and 3D raycasts always report whatever is first; filter byhit.is_triggerorlayerwhen triggers should not count.- Use
fractionto lerp effects along a laser path andnormalto 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.