Skip to content

Colliders

Colliders make entities visible to collision detection and spatial queries. A collider needs Position and automatically requires CollisionLayer. Add RigidBody only when the built-in resolver should move or bounce the entity.

import { Define } from '@shell/ecs';
import { CircleCollider } from '@shell/physics';
import { Position } from '@shell/transform';
const CircleColliderPrefab = Define.prefab()
.with(Position, { x: 100, y: 100, z: 0 })
.with(CircleCollider, { radius: 16 });
commands.spawn(CircleColliderPrefab);
FieldTypeDefaultDescription
radiusnumber1Radius around the entity’s Position
const BoxColliderPrefab = Define.prefab()
.with(Position, { x: 200, y: 100, z: 0 })
.with(BoxCollider, { width: 64, height: 32 });
commands.spawn(BoxColliderPrefab);
FieldTypeDefaultDescription
widthnumber1Full width centered on Position
heightnumber1Full height centered on Position

Box colliders are axis-aligned. Rotation, pivot, and collider offsets are not applied.

If an entity has both collider components, collision detection uses its circle collider.

CollisionLayer controls which colliders interact. Define layers as bit flags so masks can combine them:

export const Layers = {
Player: 1 << 0,
Enemy: 1 << 1,
World: 1 << 2,
Pickup: 1 << 3,
} as const;

Configure each entity with its own layer and the layers it accepts:

const PlayerColliderPrefab = Define.prefab()
.with(Position)
.with(CollisionLayer, {
layer: Layers.Player,
mask: Layers.Enemy | Layers.World | Layers.Pickup,
})
.with(CircleCollider, { radius: 16 });
const WallColliderPrefab = Define.prefab()
.with(Position)
.with(CollisionLayer, {
layer: Layers.World,
mask: Layers.Player | Layers.Enemy,
})
.with(BoxCollider, { width: 64, height: 32 });

Place a custom CollisionLayer before the collider in the prefab definition. This makes the intended layer data explicit instead of relying on the default supplied by the collider requirement.

Normal collision detection is mutual. Both expressions must match:

(player.mask & wall.layer) !== 0;
(wall.mask & player.layer) !== 0;

Adding CircleCollider or BoxCollider automatically adds CollisionLayer with both fields set to 1 when it is missing.

A collider does not require a rigid body. This is useful for triggers and query-only entities:

const PickupPrefab = Define.prefab()
.with(Position, { x: 250, y: 100, z: 0 })
.with(CollisionLayer, {
layer: Layers.Pickup,
mask: Layers.Player,
})
.with(CircleCollider, { radius: 10 });
const pickup = world.get('commands').spawn(PickupPrefab).root;

The pickup participates in detection and emits collision events, but the built-in resolver ignores the contact because both entities need RigidBody for response.

The built-in resolver handles contacts between two entities that both have RigidBody and Position. Every dynamic participant also needs Velocity.

Response includes:

  • Impulses along the collision normal
  • Restitution using the lower restitution of the two bodies
  • Positional correction to reduce overlap
  • Infinite effective mass for static and kinematic bodies

RigidBody.friction is stored but is not currently applied. Collision response does not generate angular impulses, and boxes remain axis-aligned even when Rotation.z is integrated. The resolver is suited to simple push-apart behavior rather than a full constraint-based simulation.

During each fixed step, CollisionSystem:

  1. Rebuilds the spatial index from current collider and position data.
  2. Filters candidate pairs using collision layers.
  3. Tests circle-circle, circle-box, and box-box intersections.
  4. Emits collision lifecycle events.

CollisionResolverSystem then consumes active contacts and applies response. Systems that need current collision data should run after PhysicsSet.Collide in Schedule.FixedPostUpdate.

const GameplayCollisionSystem = Define.system()
.after(PhysicsSet.Collide)
.update(({ events }) => {
// Read collision events here.
});

See Collision Events for contact payloads and event handling.