Skip to content

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.

Terminal window
npm install @yagejs-addons/interaction
Terminal window
npm install @yagejs/core @yagejs/input
  • @yagejs/core is a required peer.
  • @yagejs/input is 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";

The addon answers three different questions, at three different scopes. Most games never leave the first row.

Use casePullPushInteract
Prompt + press E (the default)interactor.focusInteractionFocusChangedEventinteractor.interact()
Multi-target selection UIinteractor.inRangeInteractionInRangeChangedEventinteractor.interact(chosen)
Scene-wide or custom queryinteractablesIn(scene), rankInteractablesnone — query on demandinteractable.interact() (scripted bypass)

focus is simply inRange[0].

  1. Mark anything the player can interact with.

    chest.add(
    new Interactable({
    prompt: "Open",
    onInteract: () => chest.open(),
    }),
    );
  2. 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 }));
  3. 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.

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 prompt

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();
},
}),
);

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.

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 tracking
chest.setActive(false); // no interactor can focus or interact with the chest

Turn the entity back on and it picks up where it left off, including its place in the focus tie-break order.