System guide · React Three Fiber + TypeScript

Build one NPC interaction pipeline for keyboard, touch, dialogue, and quests.

Treat interaction as a small protocol: select an eligible target, receive an explicit player action, emit one typed fact, let application systems react, and suspend gameplay input while the resulting UI owns focus.

Separate targeting, intent, gameplay response, and presentation.

1. Eligible target

Choose the nearest in-range NPC, a pointer-selected entity, or a server-approved target.

2. Player intent

Translate E, a controller action, or a touch button into the same interact command.

3. Typed fact

Emit an entity id and kind instead of importing dialogue, quest, shop, and analytics code.

4. Focus ownership

Open the appropriate UI and lock conflicting movement, camera, and interaction sources.

R3F pointer events are useful for object selection, and the official interaction tutorial documents that surface. An RPG interaction still needs product rules around distance, input modality, focus, dialogue, quests, multiplayer validation, and testability.

Track one stable in-range target at scene level.

import {
  SceneShell,
  type NPCConfig,
} from '@overworld-engine/scene'

const npcs: NPCConfig[] = [{
  id: 'guide',
  name: 'Village guide',
  modelPath: '/models/guide.glb',
  position: [4, 0, 2],
  rotation: [0, Math.PI, 0],
}]

function Village() {
  return (
    <SceneShell
      npcs={npcs}
      npcProximityRadius={3}
      interactHint={(id) => <TalkHint npcId={id} />}
    >
      <VillageEnvironment />
    </SceneShell>
  )
}

SceneShell runs one proximity pass for the scene. It reads the player position every frame, selects the nearest NPC inside the configured radius, updates nearbyNpcId, and emits proximity:enter or proximity:leave only when the selected target changes.

Moving NPCs can provide live position refs so the visual, collider, selection ring, and proximity query share the same current position. Avoid mounting one global scan inside every NPC: one scene-level query makes ownership and transition behavior explicit.

Map every input device to one interaction command.

import {
  interact,
  useInteractKey,
  useSceneStore,
} from '@overworld-engine/scene'

function GameInput() {
  useInteractKey('e') // ignores key repeat and the shared input lock
  return null
}

function TouchInteractButton() {
  const target = useSceneStore((state) => state.nearbyNpcId)
  return (
    <button
      type="button"
      disabled={!target}
      aria-label={target ? `Talk to ${target}` : 'No character nearby'}
      onClick={() => interact()}
    >
      Talk
    </button>
  )
}

interact() reads the current target and emits one event; it returns false when nothing is eligible. NPCs take precedence over buildings when both are in range. A gamepad action can call the same function, so input adapters do not duplicate dialogue or quest logic.

Do not make proximity alone start a conversation. Entering a radius is useful for hints, ambient barks, highlighting, and preloading, but an explicit action preserves player intent and works predictably with controllers and touch screens.

Let the application decide what each entity interaction means.

import { gameEvents } from '@overworld-engine/core'

const npcDialogues: Record<string, string> = {
  guide: 'village-welcome',
  merchant: 'merchant-shop',
}

const stopInteraction = gameEvents.on(
  'entity:interact',
  ({ kind, id }) => {
    if (kind === 'npc') {
      const dialogueId = npcDialogues[id]
      if (dialogueId) dialogue.getState().start(dialogueId, id)
      return
    }

    if (kind === 'building' && id === 'forge') {
      forgePanel.open()
    }
  },
)

// Dispose when this application/room composition root is destroyed.
stopInteraction()

The scene reports a fact; it does not know which dialogue tree, shop, quest reward, audio cue, analytics event, or network command should follow. Optional systems can observe the same typed event without creating import chains between them.

Acquire one named lock while dialogue or a modal owns focus.

import { inputLock } from '@overworld-engine/core'

function openDialogue(dialogueId: string, npcId: string) {
  inputLock.acquire('dialogue')
  dialogue.getState().start(dialogueId, npcId)
}

function closeDialogue() {
  try {
    dialogue.getState().close()
  } finally {
    inputLock.release('dialogue')
  }
}

// Scene transitions and test cleanup may release every abandoned owner.
function leaveScene() {
  dialogue.getState().close()
  inputLock.releaseAll()
}

Overworld movement, interact-key, and orbit-camera primitives consult the shared lock by default. A virtual joystick or custom controller should check the same source. Named idempotent locks allow nested owners—dialogue, inventory, pause—to release themselves without accidentally unlocking another active layer.

Release locks on normal close, cancellation, error, route change, scene change, and unmount. A focus system is only reliable when its exceptional paths are as explicit as its happy path.

See keyboard layers, window z-order, and gamepad focus in the HUD guide →

Project interaction state into accessible, replaceable UI.

const active = useStore(quests.store, (state) => state.active)
const completed = useStore(quests.store, (state) => state.completed)

const indicators = {
  guide: completed.includes('gather-crystals')
    ? 'quest-complete'
    : active['gather-crystals']
      ? 'quest-in-progress'
      : 'quest-available',
} satisfies Record<string, NPCIndicator>

<SceneShell
  npcs={npcs}
  npcIndicators={indicators}
  interactHint={(id) => <TalkHint npcId={id} />}
/>

Quest indicators and interaction hints are props supplied by the application. The scene package does not read quest or localization stores. Keep critical instructions in DOM UI with readable labels, keyboard focus, sufficient contrast, and a touch target; a 3D “E” bubble can remain supplementary feedback.

  • Show the action only when an eligible target exists.
  • Name the action and target instead of exposing only an unexplained icon.
  • Support keyboard, controller, and touch without changing gameplay semantics.
  • Move focus into modal dialogue and restore it when the modal closes.
  • Do not encode quest state only by color or a tiny 3D marker.

Revalidate competitive or economic interactions on the server.

Client proximity is a presentation and prediction signal, not proof. For multiplayer shops, rewards, doors, trades, or shared quest state, send an interaction command with the entity id and client sequence. The server checks room membership, entity existence, distance, cooldowns, permissions, and current authoritative state before producing a result.

Cosmetic dialogue may remain local when it changes no authoritative facts. The authoritative multiplayer guide covers validated inputs, snapshots, prediction, and reconciliation boundaries.

Test the action-to-event seam without mounting WebGL.

it('emits one interaction for E near an NPC', () => {
  useSceneStore.setState({
    nearbyNpcId: 'guide',
    nearbyBuildingId: null,
  })
  const recorder = createEventRecorder(gameEvents)
  const { unmount } = renderHook(
    useInteractKey,
    'e',
    { isInputBlocked: () => false },
  )

  window.dispatchEvent(new KeyboardEvent('keydown', { key: 'e' }))

  expect(recorder.events).toContainEqual(
    expect.objectContaining({
      event: 'entity:interact',
      payload: { kind: 'npc', id: 'guide' },
    }),
  )

  unmount()
  recorder.stop()
})

Add negative tests for no target, held-key repeat, active input locks, unmounted bindings, and removed listeners. Test proximity selection separately as spatial behavior, then keep one end-to-end scene test for the rendered hint and dialogue transition.

React Three Fiber NPC interaction questions

Should an NPC interaction use raycasting or proximity?

Use the intent that fits the game. Proximity plus an explicit action works well for controller, keyboard, and touch RPGs. Raycasting fits pointer-led selection. A game may use raycasting to select a target and still require distance and authority checks before acting.

Where should dialogue start in a React Three Fiber game?

Start dialogue in an application-level handler for a typed interaction event. The scene reports which entity was used; the application maps that entity to dialogue content without making the scene package import the dialogue engine.

How do I stop repeated interaction while a dialogue is open?

Ignore keyboard repeat, acquire a named gameplay input lock when the dialogue opens, release it on every close or scene-change path, and make movement, camera, keyboard, and touch sources consult the same lock.

Can the same NPC interaction run on mobile?

Yes. The visible touch button can call the same interact function used by the keyboard binding. Target selection and the emitted typed event remain identical, so dialogue and quest systems do not need a mobile-specific branch.

Run this interaction as a complete vertical slice

Try movement, NPC dialogue, quest progression, rewards, inventory, HUD, and tests together.

Open the runnable React Three Fiber RPG starter →