Skip to content

Collision

Detection belongs to the engine and response belongs to the game. The engine finds the pairs, reports them with the manifold that separates them, and moves nothing. A game declares what an actor collides with by attaching a ColliderComponent, and reads the result from the collision events and from world.collision.

type CollisionResponse = "ignore" | "overlap" | "block";
ResponseMeaning
ignoreThe pair is never tested.
overlapThe pair is reported when its shapes begin intersecting and when they stop.
blockThe pair is reported with a manifold on every frame its shapes intersect.

A channel is a name a collider is on and a name every other collider answers for. The channel vocabulary belongs to the game: a channel is any string, and the game fixes the set it uses.

type ColliderShape =
| { kind: "box"; width: number; height: number; depth: number }
| { kind: "sphere"; radius: number }
| { kind: "capsule"; radius: number; height: number };
ShapeExtent
boxwidth along local X, height along local Y, depth along local Z.
sphereradius in every direction.
capsuleA cylinder of height along local Y and radius, capped by a hemisphere of radius at each end, so the whole spans height + 2 * radius along local Y.

Every shape is centered on the component’s world transform and sized in world units. A box and a capsule are oriented by the transform’s rotation; a sphere ignores it. The transform’s scale applies to the shape as well: a box’s extents scale per axis, a capsule’s height scales by the Y factor, and a sphere’s or capsule’s radius scales by the largest of the three factors.

interface ColliderOptions {
shape: ColliderShape;
channel?: string;
responses?: Readonly<Record<string, CollisionResponse>>;
}
class ColliderComponent extends Component {
constructor(options: ColliderOptions);
shape: ColliderShape;
channel: string;
responses: Record<string, CollisionResponse>;
bounds(): Box3;
}
MemberDefaultSemantics
shape—The shape tested, in world units relative to the component’s world transform.
channel"default"The channel this collider is on.
responsesEmptyMaps a channel name to how this collider answers a collider on it. An unlisted channel answers "ignore".
bounds()—The world-space axis-aligned Box3 enclosing the shape at the component’s world transform, in world units.

The shape is positioned by worldTransform(), the actor’s transform composed with the component’s offset, so one actor carries several colliders at different offsets. shape, channel, and responses are mutable, and the pass reads them as they stand when it runs.

A collider takes part in the pass while it is enabled and its actor is alive. A disabled component and a destroyed actor are left out.

A pair is evaluated in both directions. Each collider’s responses are asked for the other’s channel, and the pair takes the stronger of the two answers, ordered ignore below overlap below block. A pair both sides ignore is never tested.

new ColliderComponent({
shape: { kind: "sphere", radius: 0.5 },
channel: "ball",
responses: { wall: "block", goal: "overlap" },
});

A collider on ball and a collider on wall that answers ball with "overlap" resolve to "block", because block is the stronger of the two. One side is therefore enough to establish a response, and a game that wants a pair left alone declares "ignore" on both.

interface Manifold {
normal: Vec3;
depth: number;
point: Vec3;
}
FieldMeaning
normalThe unit direction separating the pair, pointing from the first collider of the pair toward the second.
depthHow far the two shapes penetrate along normal, in world units.
pointA point on the shared boundary.

The manifold is oriented by the reported order of the pair, so the first collider is moved out of the second along -normal and the second out of the first along +normal. Multiplying normal by depth gives the smallest translation that separates them.

The pass runs once per frame, after every controller, actor, and component has ticked and after the frame’s timers have fired, and before the game mode ticks. A pair produced by this frame’s movement is therefore reported in this frame, and the game mode decides the match from a settled world. A paused world runs no pass.

The pass emits three events on the engine’s broadcaster, reachable as world.events:

"overlap:begin": {
a: Actor;
b: Actor;
colliders: [ColliderComponent, ColliderComponent];
};
"overlap:end": {
a: Actor;
b: Actor;
colliders: [ColliderComponent, ColliderComponent];
};
"hit": {
a: Actor;
b: Actor;
colliders: [ColliderComponent, ColliderComponent];
manifold: Manifold;
};
EventEmitted
overlap:beginOn the first frame the pass finds an overlapping pair.
overlap:endOn the first frame the pass stops finding it, and when either actor is destroyed or the world closes.
hitOn every frame the pass finds a blocking pair, so a game applies its response each frame the pair persists.

a is the actor with the lower id and colliders is in the same order, so a pair reports the same way whichever side moved. manifold.normal points from colliders[0] toward colliders[1].

The renderer’s collision overlay draws every enabled collider’s shape as a wireframe in the world pass, after the scene and with depth testing off, in a color per response.

interface Overlap {
actor: Actor;
collider: ColliderComponent;
}
interface Hit {
actor: Actor;
collider: ColliderComponent;
point: Vec3;
normal: Vec3;
distance: number;
}
interface QueryOptions {
channel?: string;
responses?: Readonly<Record<string, CollisionResponse>>;
ignore?: readonly Actor[];
}
interface CollisionWorld {
overlaps(actor: Actor): readonly Overlap[];
query(
shape: ColliderShape,
at: Vec3,
rotation?: Quat,
options?: QueryOptions,
): readonly Overlap[];
raycast(
origin: Vec3,
direction: Vec3,
distance: number,
options?: QueryOptions,
): Hit | null;
raycastAll(
origin: Vec3,
direction: Vec3,
distance: number,
options?: QueryOptions,
): readonly Hit[];
}
MemberResult
overlaps(actor)The colliders currently intersecting one of actor’s, each with the actor that owns it.
query(shape, at, rotation, options)The colliders shape intersects when it is centered at at and turned by rotation, which defaults to the identity.
raycast(origin, direction, distance, options)The nearest hit along the ray, or null.
raycastAll(origin, direction, distance, options)Every hit along the ray, in increasing distance.
Hit fieldMeaning
actorThe actor the ray met.
colliderThe collider on it the ray met.
pointWhere the ray meets the collider, in world units.
normalThe unit surface normal at point.
distanceHow far along the ray point lies, from origin.

direction is a unit vector and distance bounds the ray’s length, in world units. A queried shape is placed at unit scale, so its fields are the world extents tested.

QueryOptions puts the query on a channel and gives it a response map, so the query is filtered by the same both-directions rule a pair of colliders is. A collider the resolution leaves at "ignore" is left out of the result, and so is every collider owned by an actor ignore names.

A query is answered from the colliders as they stand when it is called and returns its result to the caller. The three collision events belong to the frame’s pass.

class CursorController extends PlayerController {
override tick(): void {
const ray = this.world.camera.logicalToRay(this.input.pointer());
const hit = this.world.collision.raycast(ray.origin, ray.direction, 100, {
channel: "cursor",
responses: { target: "overlap" },
});
if (hit) hit.actor.addTag("hovered");
}
}

A pointer pick is a raycast along the ray the camera hands back for a logical point. The nearest collider on a channel the query answers is the hit.

ConditionResult
engine.world, and so world.collision, reached before engine.initialize resolvesError naming the ordering
A hit, overlap:begin, or overlap:end handler throwsThe error reaches the console and the remaining handlers still run

ColliderComponent is exported as a class from @clockwyrks/structured-3d. ColliderShape, CollisionResponse, ColliderOptions, Manifold, Overlap, Hit, QueryOptions, and CollisionWorld are exported as types.