Interaction
@yagejs-addons/interaction implements “walk near a thing, see a
prompt, press E” — the interaction every game hand-rolls to connect the
player to dialogue NPCs, inventory pickups, doors, and levers. It tracks the
nearest in-range, enabled Interactable for an Interactor, and hands the
result back as events: your game renders the prompt and decides what
interacting means.
The addon is headless — no bundled prompt view, no renderer dependency —
and treats @yagejs/input as optional: install it and the interactor
handles the interact key itself, or drive it manually with no input package at
all.
Install
Section titled “Install”npm install @yagejs-addons/interactionnpm install @yagejs/core @yagejs/input@yagejs/coreis a required peer.@yagejs/inputis the optional peer. Skip it if your game drives interaction from its own controller — the addon never imports a value from@yagejs/input, so a headless install carries zero runtime footprint from it.
There is a single entry point — no ./presenters subpath, because the addon
draws nothing:
import { Interactable, Interactor, InteractionFocusChangedEvent, InteractionInRangeChangedEvent, InteractionPerformedEvent, interactablesIn,} from "@yagejs-addons/interaction";Which API for what
Section titled “Which API for what”The addon answers three different questions, at three different scopes. Most games never leave the first row.
| Use case | Pull | Push | Interact |
|---|---|---|---|
| Prompt + press E (the default) | interactor.focus | InteractionFocusChangedEvent | interactor.interact() |
| Multi-target selection UI | interactor.inRange | InteractionInRangeChangedEvent | interactor.interact(chosen) |
| Scene-wide or custom query | interactablesIn(scene), rankInteractables | none — query on demand | interactable.interact() (scripted bypass) |
focus is simply inRange[0].
Quick start
Section titled “Quick start”-
Mark anything the player can interact with.
chest.add(new Interactable({prompt: "Open",onInteract: () => chest.open(),}),); -
Give the player (or any detector entity) an
Interactor. Defaults: 48px range, the"interact"action, nearest-in-range focus.const interactor = player.add(new Interactor({ range: 70 })); -
Render the prompt from the focus-changed event. This is the whole render step — the event fires only when the focus (or its prompt text) actually changes.
player.on(InteractionFocusChangedEvent, ({ prompt }) => {promptLabel.text.text = prompt ?? "";promptLabel.text.visible = prompt !== null;});
With @yagejs/input installed and an interact action bound, pressing it
while something is focused calls that interactable’s onInteract and emits
InteractionPerformedEvent. No setup beyond the three steps above.
Per-interactable overrides
Section titled “Per-interactable overrides”Every field on InteractableOptions is optional — configure per interactable, no
subclassing:
lever.add( new Interactable({ prompt: () => (lever.on ? "Turn off" : "Turn on"), // re-read live, every frame radius: 40, // this interactable's own reach, added to the interactor's range priority: 10, // wins focus ties over a nearer, lower-priority thing enabled: () => !player.isBusy(), // false → dropped from focus entirely onInteract: () => lever.toggle(), }),);prompt and enabled both accept a static value or a () => value
provider, resolved fresh every frame — a live label or a live gate needs no
extra setup when the underlying state changes.
Manual drive (no @yagejs/input, or a test)
Section titled “Manual drive (no @yagejs/input, or a test)”action: null turns off auto-input; interactor.interact() is the same
call the built-in input path makes, so a custom controller or a test drives
it with no synthetic device input:
const interactor = player.add(new Interactor({ range: 70, action: null }));
// your own controller detects the press:if (myInput.isJustPressed("interact")) interactor.interact();
interactor.focus; // the focused Interactable | null — read for a custom promptCross-addon composition
Section titled “Cross-addon composition”An interactable’s onInteract is a plain closure — connecting another addon
takes one line, with no addon-to-addon dependency:
npc.add( new Interactable({ prompt: "Talk", onInteract: () => dialogue.play(script) }),);coin.add( new Interactable({ prompt: "Pick up", onInteract: () => { inventory.add("coin"); coin.destroy(); }, }),);Focus rules
Section titled “Focus rules”A candidate is in range when distance(interactor, interactable) <= range + interactable.radius. Among in-range candidates, the interactor picks the
highest priority; ties break by nearest distance, then by registration
order — deterministic even when two interactables sit exactly on top of each
other.
Multiple targets, selection UI, and highlighting
Section titled “Multiple targets, selection UI, and highlighting”focus is the single default target. Behind it, interactor.inRange is the
full set of in-range, enabled interactables, in the order focus picks from — so
inRange[0] is the current focus. It reads empty before the first frame and
while the interactor is disabled.
Use it when several things overlap and the player should choose — the pickup
selection in games like Genshin. Listen to InteractionInRangeChangedEvent,
show your own list or wheel, then pass the chosen one back to interact:
player.on(InteractionInRangeChangedEvent, ({ inRange }) => { if (inRange.length > 1) wheel.show(inRange); // ranked; inRange[0] is the focus else wheel.hide();});
function confirm(chosen: Interactable) { interactor.interact(chosen);}interact(target) acts only on something the interactor can actually reach: it
does nothing unless the interactor is enabled, the target is in the current
inRange, and the target is still live (host not destroyed, component not
removed, enabled gate still true). To fire an interactable the interactor
can’t reach — a scripted or remote trigger — call interactable.interact()
directly. That skips every check and emits no interactor event.
For a scene-wide reveal — an observation skill that highlights everything
interactable, whatever the interactor’s range — enumerate by scene with
interactablesIn:
import { interactablesIn, rankInteractables } from "@yagejs-addons/interaction";
// interactablesIn drops destroyed hosts but keeps DISABLED ones, since whether// an ungated target is still worth revealing is the game's call.const live = interactablesIn(scene).filter((it) => it.isEnabled());
for (const it of live) outline(it.entity); // it.entity is the host to draw the outline on
// rankInteractables is geometry only, so filter the enabled gate first:const nearby = rankInteractables({ position: playerPos, range: 200 }, live);Each Interactable exposes read-only position, radius, priority,
prompt, order, isEnabled(), and its host entity — the per-target data a
highlight outline or a proximity icon needs. To place an icon over the nearest
target when the player is close, read interactor.focus?.position; for an icon
over every in-range target, iterate interactor.inRange.
This is data only — the addon draws nothing. You keep full control of the wheel, the outline shader, and the icon; the addon tells you what and where.
Pausing tracking
Section titled “Pausing tracking”interactor.enabled (inherited from Component, default true) doubles as
the tracking toggle — flip it to pause one interactor during a cutscene, or to
switch tracking between several:
interactor.enabled = false; // empties the snapshot immediately (emitting the // transitions), then halts tracking, input polling, // and interact()A disabled interactor interacts with nothing, even when handed an explicit target — the pause is not something an argument can bypass.
Deactivating an entity has the same effect on both sides, so an entity you turn off or hand back to a pool leaves nothing behind:
player.setActive(false); // its interactor drops the snapshot and stops trackingchest.setActive(false); // no interactor can focus or interact with the chestTurn the entity back on and it picks up where it left off, including its place in the focus tie-break order.