Skip to content

Spatial Queries

PhysicsPlugin exposes high-level overlap and raycast resources backed by the collider spatial index. Use these resources for gameplay queries instead of reading the low-level PhysicsWorld directly.

Overlap queries and raycasts share optional layer, mask, and filter fields.

FilterBehavior
CollisionQueryFilter.MutualThe query mask accepts the collider layer and the collider mask accepts the query layer
CollisionQueryFilter.OneWayOnly the query mask must accept the collider layer

Mutual filtering is the default when both layer and mask are present. One-way filtering is usually more convenient for searches such as “find an enemy”:

const filter = {
mask: Layers.Enemy,
filter: CollisionQueryFilter.OneWay,
};

Filtering is disabled when the fields required by the selected mode are omitted.

Access the physicsOverlap resource inside a system:

const overlap = resources.get('physicsOverlap');

Use circle() to process hits incrementally. Call cancel() when the query has found enough:

import { CollisionQueryFilter } from '@shell/physics';
let target;
overlap.circle(
{
x: position.x,
y: position.y,
radius: 64,
mask: Layers.Enemy,
filter: CollisionQueryFilter.OneWay,
},
(hit, cancel) => {
if (hit.entity === player) return;
target = hit.entity;
cancel();
},
);

Use circleAll() to collect every hit into a reusable array:

import type { ColliderHit } from '@shell/physics';
const hits: ColliderHit[] = [];
overlap.circleAll(
{
x: position.x,
y: position.y,
radius: 64,
mask: Layers.Pickup,
filter: CollisionQueryFilter.OneWay,
},
hits,
);

circleAll() clears the supplied array before filling it and returns the same array. The query performs exact circle-circle and circle-box tests after broad-phase lookup. Touching shapes count as overlaps.

ColliderHit objects belong to the spatial index and are reused by later rebuilds. Read them during the query, or copy the entity and values that must survive beyond the current spatial snapshot.

FieldTypeDescription
x, ynumberCenter of the query circle
radiusnumberQuery radius; negative values are treated as 0
layernumberOptional query layer for mutual filtering
masknumberOptional accepted collider layers
filterCollisionQueryFilterMutual or one-way filtering

Use cone() to process colliders intersecting a filled circular sector. The direction vector is normalized internally, and angle is the full aperture in radians:

overlap.cone(
{
x: position.x,
y: position.y,
directionX: aim.x,
directionY: aim.y,
radius: 96,
angle: Math.PI / 2,
mask: Layers.Enemy,
filter: CollisionQueryFilter.OneWay,
},
(hit, cancel) => {
target = hit.entity;
cancel();
},
);

Use coneAll() to collect every hit into a reusable array:

overlap.coneAll(
{
x: position.x,
y: position.y,
directionX: aim.x,
directionY: aim.y,
radius: 96,
angle: Math.PI / 2,
},
hits,
);

Cone queries perform exact intersections against circle and axis-aligned box colliders, so a collider can overlap even when its center is outside the cone. Touching either radial edge or the outer arc counts as an overlap. A zero angle is a line segment, angles of 2π or greater are full circles, and negative angles and radii are treated as 0.

FieldTypeDescription
x, ynumberCone origin
directionX, directionYnumberNon-zero direction, normalized internally
radiusnumberQuery radius; negative values are treated as 0
anglenumberFull aperture in radians, clamped to 0–2π
layernumberOptional query layer for mutual filtering
masknumberOptional accepted collider layers
filterCollisionQueryFilterMutual or one-way filtering

Access the physicsRaycaster resource:

const raycaster = resources.get('physicsRaycaster');

Use closest() for the nearest intersection:

const hit = raycaster.closest({
x: position.x,
y: position.y,
directionX: aim.x,
directionY: aim.y,
maxDistance: 500,
mask: Layers.World | Layers.Enemy,
filter: CollisionQueryFilter.OneWay,
exclude: player,
});
if (hit) {
console.log(hit.entity, hit.distance);
console.log(hit.pointX, hit.pointY);
console.log(hit.normalX, hit.normalY);
}

The direction is normalized internally, so distance and maxDistance are always measured in world units.

Use all() to collect intersections sorted from nearest to farthest:

import type { RaycastHit } from '@shell/physics';
const hits: RaycastHit[] = [];
raycaster.all(
{
x: 0,
y: 0,
directionX: 1,
directionY: 0,
maxDistance: 500,
mask: Layers.World,
filter: CollisionQueryFilter.OneWay,
},
hits,
);

Like circleAll(), all() clears and reuses the supplied array.

FieldTypeDescription
x, ynumberRay origin
directionX, directionYnumberNon-zero ray direction
maxDistancenumberMaximum distance in world units
excludeEntityOptional entity to ignore
layernumberOptional query layer for mutual filtering
masknumberOptional accepted collider layers
filterCollisionQueryFilterMutual or one-way filtering
FieldDescription
entityEntity owning the intersected collider
shape'circle' or 'box'
distanceDistance from the ray origin
pointX, pointYWorld-space intersection point
normalX, normalYOutward surface normal

Rays starting inside a collider return a hit at distance 0. Intersections exactly at maxDistance are included.

Overlap hits include the indexed shape data:

type ColliderHit =
| {
shape: 'circle';
entity: Entity;
x: number;
y: number;
radius: number;
layer: number;
mask: number;
}
| {
shape: 'box';
entity: Entity;
x: number;
y: number;
width: number;
height: number;
layer: number;
mask: number;
};

The spatial index is rebuilt during the fixed-step collision pipeline. Overlaps and raycasts use the latest completed rebuild, not live component storage.

If a system changes a position or collider after the rebuild, queries do not see that change until the next fixed step. Run query systems after PhysicsSet.Collide when they need the current fixed-step snapshot:

export const FindTargetsSystem = Define.system()
.after(PhysicsSet.Collide)
.update(({ resources }) => {
const overlap = resources.get('physicsOverlap');
// Queries now use the spatial index rebuilt this fixed step.
});