Tutorial: a locked mechanism
Your host creates and renders the objects. A registered root can be a Group containing deeply nested meshes.
import * as THREE from "three";
import { Latch } from "@cranberry-forge/latch";
const latch = new Latch({ reach: 3, focusTolerance: 0.015 });
const game = { hasFuse: false, charged: false };
// fuseRoot, crankRoot and wallRoot are your own Three.Object3D objects.
latch.register({ id: "fuse", root: fuseRoot, label: "Take fuse" });
latch.register({
id: "crank",
root: crankRoot,
label: "Wind mechanism",
mode: "hold",
holdDuration: 1.8,
condition: ({ context }) =>
context.charged
? "Already wound"
: context.hasFuse || "Find the fuse first",
});
latch.setOccluders([wallRoot]);
const aim = new THREE.Raycaster();
const actorPosition = new THREE.Vector3();
const pointerNDC = new THREE.Vector2();
let pressed = false;
function step(dt) {
// Call after your movement and world transforms, before applying interactions.
aim.setFromCamera(pointerNDC, camera);
actor.getWorldPosition(actorPosition);
actorPosition.y += 0.8; // Your chosen hand/body interaction origin.
const result = latch.update({
dt, // Elapsed seconds, never milliseconds.
aimRay: aim.ray, // May originate at a distant third-person camera.
actorPosition, // Reach is measured from this actor position.
pressed, // Current physical button state.
suspended: menuIsOpen,
context: game,
camera, // Needed for view-dependent raycasting, e.g. Sprites.
});
prompt.textContent = result.focus
? result.focus.available
? result.focus.label
: result.focus.reason
: "";
progress.value = result.progress;
// The traversal is finished. Apply gameplay changes here.
for (const event of result.events) {
if (event.type !== "activate") continue;
if (event.target.id === "fuse") {
game.hasFuse = true;
latch.remove("fuse");
fuseRoot.visible = false;
}
if (event.target.id === "crank") game.charged = true;
}
}actor, camera, scene roots, prompt, progress element, and menuIsOpen are host variables. Latch deliberately does not create them. The same loop works with a gamepad, touch button, first-person camera, or XR controller: supply a world-space ray and a current button state.
Connect input without losing quick taps
Track a button's down/up state. Call step(0) at each transition as well as step(dt) in your frame loop so a tap completed between rendered frames is still observed. Use one source of elapsed time: transition updates receive zero seconds. For multiple devices, aggregate their down states with a Set, as the showcase does.
window.addEventListener("keydown", (event) => {
if (event.code !== "KeyE" || event.repeat) return;
pressed = true;
step(0);
});
window.addEventListener("keyup", (event) => {
if (event.code !== "KeyE") return;
pressed = false;
step(0);
});On blur, lost pointer capture, pointer cancellation, and hidden-page transitions, clear held input and call an update. For a menu or background state use suspended: true. Continue sending released input while suspended when possible. If the input remains held during suspension, it must be released before starting again after resumption. No DOM listeners or background timers are installed by the package.
TypeScript
import { Latch, type InteractionEvent } from "@cranberry-forge/latch";
type GameState = { hasFuse: boolean; charged: boolean };
const latch = new Latch<GameState>();
latch.register({
id: "door",
root: doorRoot,
condition: ({ context }) => context.hasFuse || "A fuse is required",
});
function consume(event: InteractionEvent) {
if (event.type === "cancel") console.log(event.reason);
if (event.type === "activate") console.log(event.target.id);
}When a condition uses context, supply it on every update, including zero-time input and suspension updates. Install @types/three matching your Three version if your TypeScript project does not already provide Three declarations.