guide

Embedding a world in an app

Host a world beside ordinary UI, in a browser or a desktop webview. Content security policy, lazy loading, input that yields to the page, pausing, and figures driven by app state.

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

A Runek world doesn’t have to be the whole page. It can be one panel of a larger app: a map beside a sidebar, a room under a chat composer, a scene in a desktop app’s webview. This guide covers what changes when it is.

Content security policy

A world needs three things a strict policy usually blocks. On top of whatever your app already allows:

DirectiveAddWhy
script-src'wasm-unsafe-eval'Rapier, the physics engine, is WebAssembly. Without it the world fails to start.
script-srcblob:Text (Sign, a Person’s label, interaction prompts) is laid out in a worker that loads its code from blob: URLs.
connect-srcdata:The default font bundled in @runek/core is a data: URL, fetched from that worker. Fonts your world declares in fonts need their own origins here too.

So a host that otherwise allows only itself ends up with:

default-src 'self';
script-src 'self' 'wasm-unsafe-eval' blob:;
connect-src 'self' data:;

worker-src falls back to script-src, so it needs nothing unless your policy sets it, in which case add blob: there too. img-src and font-src need nothing: no component loads an image or a CSS font. Development servers usually want more than this (inline scripts for hot reload), which is between you and your bundler.

In Tauri the policy lives in app.security.csp in tauri.conf.json.

Desktop webviews

Every desktop webview runs WebGL2, which is all a world needs:

Load it lazily

three.js, Rapier, and drei are a large download. Put the world behind React.lazy so the rest of your app starts without them:

const Room = lazy(() => import('./world/Room'))

export function RoomPage() {
  return (
    <Suspense fallback={<p>Loading the room…</p>}>
      <Room />
    </Suspense>
  )
}

The editor ships from its own entry, @runek/core/editor, and is the only part that imports leva. An app that never mounts WorldEditor doesn’t need leva installed.

Input that yields to the page

World reads the keyboard from the whole window, but never keys aimed at a text field, a select, or editable text, so typing “wasd” into a composer beside the canvas never walks the avatar. Pointer look listens on the canvas only, so panels drawn over it already take the pointer.

Switch the keyboard off entirely while a modal is open:

<World input={!modalOpen}>…</World>

Keys held when input switches off are released, so none stay stuck down.

Pause when hidden

A world in a tab nobody is looking at should cost nothing. paused stops the frame loop and physics: no rendering, no steps, no input.

<World paused={!pageVisible}>…</World>

Anything driven by the wall clock is simply where it should be on resume. A Person on a route was never stepping along it; its position is a function of the time, so pausing for an hour and resuming puts it exactly where an hour of walking would have.

A view of the whole place

An app showing many figures at once usually wants to open on all of them. view="overhead" puts the camera high above the avatar at a fixed tilt; it follows as the avatar walks, scroll zooms, and WASD walks relative to the screen. Give the world a view control and the player can switch between overhead, third person, and first person:

<World avatar="overhead" controls={{ view: ['KeyV'] }}>
  <Player />
</World>

Figures driven by app state

When people in the world stand for something live in your app (agents, players, orders), give each one a trip whenever its state moves it. A trip is a route with a start time:

<Person
  seed={hashOf(agent.id)}
  position={trip.from}
  route={trip.waypoints}            // relative to position, like patrol
  departAt={trip.startedAt}         // epoch ms
  pose="work"                       // held on arrival
  onArrive={() => report(agent.id, trip.to)}
/>

Before departAt the figure waits at the first point; after the last it stays put, facing the way it came, in its pose. Because the position is a function of (route, departAt, now), two windows showing the same world agree, and reopening the page puts everyone mid-stride where they should be without replaying anything.

onArrive fires once per trip, on the first frame the trip is over. That includes a trip that ended while the world was paused or not mounted at all, so the arrival is always reported; dedupe by departAt if your app remounts the world.

Pathfinding stays in your app: compute the waypoints (a small graph through your doorways is plenty) and hand them over. If a new trip starts before the last one ended, tripAt(waypoints, departAt, Date.now()) gives where the figure had got to, in the same node-local frame, as the start of the next.

Actions near a figure

Give a figure actions and a prompt appears over it when the avatar comes close, showing the key bound to each:

<World controls={{ talk: ['KeyT'], info: ['KeyI'] }}>
  <Person
    actions={[
      { id: 'talk', label: 'Talk', control: 'talk' },
      { id: 'info', label: 'Info', control: 'info' },
    ]}
    actionRadius={2}
    onAction={(id) => (id === 'talk' ? openChat(agent) : openSheet(agent))}
  />
</World>

Only the nearest figure in range shows its prompt and takes the key, so two people standing together never both answer T. Keys come through the world’s keyboard, so they yield to text fields, input={false}, and paused like everything else. The prompt reads its key names from the world’s controls, so remapping talk relabels it.

Anything else can offer actions the same way by wrapping it in Interactable (with use: ['KeyE'] in the world’s controls):

<Interactable actions={[{ id: 'sit', label: 'Sit', control: 'use' }]} onAction={sitDown}>
  <Sofa />
</Interactable>