component

Interactable

Wrap anything to make it something the player can act on: walk within range and a prompt shows its actions with the keys the world binds to them; press one and onAction gets its id. Only the nearest in range takes the key, and keys yield to text fields.

⌖ Explore the library in 3D → · View as Markdown

A wrapper: put it around anything to give the player a key prompt and actions.
Tune every prop in the workshop →

Add it

npx @runek/cli add interactable

Pulls sign, @react-three/drei@^10.7.7, @runek/core@^0.13.0, three@^0.184.0.

Use it

import { Interactable } from './runek/Interactable'

<Interactable position={[0, 0, 0]} />

Or as a node in a world file (worlds as data):

{ "type": "Interactable", "props": { "position": [0, 0, 0] } }

Props

PropTypeDefaultDescription
actionsInteractionAction[][]What the player can do here. Each names a world controls action for its key, so declare it in the world (controls: { use: ['KeyE'] }).
radiusnumber2How close the avatar must come for the prompt to show, in units.
promptHeightnumber1.4Height of the prompt above the origin, in units.
onActionfunction (code only)
childrennode (code only)
position[x, y, z][0, 0, 0]Position [x, y, z] in units (1 unit = 1 m, Y-up).
rotation[x, y, z][0, 0, 0]Euler rotation [x, y, z] in radians.
seednumberAccepted for the component contract; this component has no seeded variation yet.

InteractionAction

PropTypeDefaultDescription
idstringrequiredPassed back to onAction.
labelstringrequiredShown in the prompt, e.g. Talk.
controlstringrequiredThe world controls action that triggers it (declare talk: ['KeyT'] in the world’s controls). The prompt shows the key bound to it, so a remap relabels it.

Registry manifest

registry manifest /r/components/interactable.json Self-contained JSON: inlined source plus resolved dependencies, exactly what runek add interactable fetches.

Browse the whole catalog in the gallery →.

Source (Interactable.tsx)
import { Billboard } from '@react-three/drei'
import {
  type InteractionAction,
  keyLabel,
  useInteraction,
  useWorld,
  type Vec3,
  type WorldComponentProps,
} from '@runek/core'
import { type ReactNode, useRef } from 'react'
import type { Group } from 'three'
import { Sign } from './Sign'

export interface InteractableProps extends WorldComponentProps {
  /** What the player can do here. Each names a world `controls` action for its key, so declare
   *  it in the world (`controls: { use: ['KeyE'] }`). */
  actions?: InteractionAction[]
  /** How close the avatar must come for the prompt to show, in units. */
  radius?: number
  /** Height of the prompt above the origin, in units. */
  promptHeight?: number
  onAction?: (id: string) => void
  children?: ReactNode
}

export interface InteractionPromptProps {
  actions: InteractionAction[]
  position?: Vec3
  /** Cap height of the text, in units. */
  size?: number
}

const INK = '#f6f1e7'

/** The key-and-label rows over something you can act on: one row per action, its key cap showing
 *  the key the world binds to it (so a remap relabels it). Always faces the camera. */
export function InteractionPrompt({ actions, position, size = 0.09 }: InteractionPromptProps) {
  const { unit, palette, controls } = useWorld()
  const s = size * unit
  const row = s * 2.3
  return (
    <Billboard position={position}>
      {actions.map((action, i) => {
        const code = controls[action.control]?.[0]
        const key = code ? keyLabel(code) : '?'
        const cap = Math.max(1, key.length * 0.62) * s + s * 0.9
        const y = (actions.length - 1 - i) * row
        return (
          <group key={action.id} position={[0, y, 0]}>
            <mesh position={[-cap / 2, 0, 0]} renderOrder={1}>
              <planeGeometry args={[cap, s * 1.75]} />
              <meshBasicMaterial color={palette.accent} toneMapped={false} />
            </mesh>
            <Sign
              position={[-cap / 2, 0, 0.002 * unit]}
              variant="body"
              size={size}
              color={palette.metal}
            >
              {key}
            </Sign>
            <Sign
              position={[s * 0.6, 0, 0]}
              variant="body"
              size={size}
              color={INK}
              outline={palette.metal}
              anchorX="left"
            >
              {action.label}
            </Sign>
          </group>
        )
      })}
    </Billboard>
  )
}

/**
 * Wrap anything to make it something the player can act on: walk within `radius` and a prompt
 * appears over it (`E Use`), press the key and `onAction` gets the action's `id`. Of everything
 * in range only the nearest shows its prompt and takes the key, and keys already yield to text
 * fields on the page. The actions are plain JSON, so a world file can declare them; the callback
 * is the host's. No geometry or colliders of its own.
 *
 * ```tsx
 * <World controls={{ use: ['KeyE'] }}>
 *   <Interactable actions={[{ id: 'sit', label: 'Sit', control: 'use' }]} onAction={sitDown}>
 *     <Chair />
 *   </Interactable>
 * </World>
 * ```
 */
export function Interactable({
  position = [0, 0, 0],
  rotation = [0, 0, 0],
  actions = [],
  radius = 2,
  promptHeight = 1.4,
  onAction,
  children,
}: InteractableProps) {
  const { unit } = useWorld()
  const anchor = useRef<Group>(null)
  const focused = useInteraction(anchor, { actions, radius, onAction })
  return (
    <group ref={anchor} position={position} rotation={rotation}>
      {children}
      {focused && <InteractionPrompt actions={actions} position={[0, promptHeight * unit, 0]} />}
    </group>
  )
}