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.

    import { Interactable } from "@yagejs-addons/interaction";
    import { Entity, Transform, type Vec2Like } from "@yagejs/core";
    class Chest extends Entity {
    setup(position: Vec2Like) {
    this.add(new Transform({ position }));
    this.add(
    new Interactable({
    prompt: "Open",
    onInteract: () => this.open(),
    }),
    );
    }
    open() {
    // swap the sprite, drop the loot
    }
    }
  2. Give the player (or any detector entity) an Interactor. Defaults: 48px range, the "interact" action, nearest-in-range focus.

    import { Interactor } from "@yagejs-addons/interaction";
    import { Entity, Transform, type Vec2Like } from "@yagejs/core";
    class Player extends Entity {
    setup(position: Vec2Like) {
    this.add(new Transform({ position }));
    this.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. A component on your HUD’s prompt label listens for it; the event bubbles from the player to the scene.

    import { Component } from "@yagejs/core";
    import { TextComponent } from "@yagejs/renderer";
    import { InteractionFocusChangedEvent } from "@yagejs-addons/interaction";
    /** Shows the focused interactable's prompt; hidden when nothing is in reach. */
    class PromptLabel extends Component {
    private readonly text = this.sibling(TextComponent);
    onAdd(): void {
    this.listenScene(InteractionFocusChangedEvent, ({ prompt }) => {
    this.text.setText(prompt ?? "");
    this.text.visible = prompt !== null;
    });
    }
    }

    Add PromptLabel next to the label’s TextComponent.

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:

import { Interactable } from "@yagejs-addons/interaction";
import { Entity } from "@yagejs/core";
class Lever extends Entity {
isOn = false;
setup(player: { isBusy(): boolean }) {
// Transform added first, as for the chest above.
this.add(
new Interactable({
prompt: () => (this.isOn ? "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: () => this.toggle(),
}),
);
}
toggle() {
this.isOn = !this.isOn;
}
}

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:

import { Interactor } from "@yagejs-addons/interaction";
import type { Entity } from "@yagejs/core";
// Your own input source, not @yagejs/input:
interface MyInput {
isJustPressed(action: string): boolean;
}
function addInteractor(player: Entity) {
return player.add(new Interactor({ range: 70, action: null }));
}
// Each frame, your own controller detects the press:
function controllerUpdate(interactor: Interactor, myInput: MyInput) {
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:

import { Interactable } from "@yagejs-addons/interaction";
import type { DialogueController, DialogueScript } from "@yagejs-addons/dialogue";
import type { Inventory } from "@yagejs-addons/inventory";
import type { Entity } from "@yagejs/core";
function connectAddons(
npc: Entity,
coin: Entity,
dialogue: DialogueController,
script: DialogueScript,
inventory: Inventory<"coin">,
) {
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:

import {
InteractionInRangeChangedEvent,
type Interactable,
type Interactor,
} from "@yagejs-addons/interaction";
import type { Entity } from "@yagejs/core";
// Your own selection UI.
interface Wheel {
show(targets: readonly Interactable[]): void;
hide(): void;
}
function showWheel(player: Entity, wheel: Wheel) {
player.on(InteractionInRangeChangedEvent, ({ inRange }) => {
if (inRange.length > 1) wheel.show(inRange); // ranked; inRange[0] is the focus
else wheel.hide();
});
}
function confirm(interactor: Interactor, 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";
import type { Entity, Scene, Vec2Like } from "@yagejs/core";
function reveal(scene: Scene, playerPos: Vec2Like, outline: (entity: Entity) => void) {
// 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);
return nearby;
}

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:

import { Interactor } from "@yagejs-addons/interaction";
const interactor = entity.get(Interactor);
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:

import type { Entity } from "@yagejs/core";
function deactivate(player: Entity, chest: Entity) {
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.

Coding agents: fetch https://yage.dev/llms.txt first and prefer the Markdown references it links over these HTML pages. This page's Markdown counterpart is /llms/addons/interaction.md.