Architecture guide · React Three Fiber + TypeScript

Build RPG systems around React Three Fiber—not inside the scene graph.

React Three Fiber is an excellent rendering boundary. A maintainable RPG keeps quests, inventory, dialogue, saves, and multiplayer rules in headless systems that can run with or without React and WebGL.

Let R3F own presentation and the frame.

R3F should own components that express the visual scene: meshes, lights, cameras, effects, animation playback, and input hit targets. Domain systems should own facts that remain true when no frame is rendering: a quest is active, an item was acquired, a dialogue node was visited, or a save version was migrated.

The practical test is simple: could this rule execute in a Node.js process? If yes, it should not depend on a React hook, a Three.js object, or the browser DOM. This separation is what makes the same rules usable by tests, authoritative servers, editor previews, and different clients.

  • Scene layer: transforms, raycasts, animation, particles, camera behavior.
  • Domain layer: objectives, dialogue transitions, inventory rules, rewards, progression.
  • Application layer: creates systems, registers game-specific behavior, and binds UI.
  • Platform layer: storage, lifecycle, input, commerce, notifications, and native APIs.

Use facts for coordination and stores for durable state.

A typed event says that something happened. A store answers what is true now. Keeping those roles separate prevents an event bus from becoming hidden state and prevents React components from becoming the only place where game logic can run.

// R3F scene adapter: translate a visual interaction into a domain fact
function Chest({ id }: { id: string }) {
  return (
    <mesh onClick={() => gameEvents.emit('entity:interact', {
      kind: 'building',
      id,
    })}>
      {/* geometry and material */}
    </mesh>
  )
}

// Application composition root
const quests = createQuestEngine({ quests: QUESTS, effects, conditions, events: gameEvents })

// React HUD is a projection of durable state
const activeQuests = useStore(quests.store, (state) => state.active)

This produces a one-way flow: scene interaction becomes a fact; domain engines update their own state; UI renders a projection. Audio, achievements, analytics, and network replication may observe the same fact without the scene importing any of them.

Do not turn every RPG system into a useFrame callback.

Per-frame work is appropriate for interpolation, camera motion, animation, and spatial queries that truly change each frame. Most RPG rules are event-driven or clock-driven. Updating them only when relevant input arrives reduces work and makes behavior easier to replay.

  1. Sample device input and translate it into stable game actions.
  2. Advance movement or simulation using an explicit delta or fixed step.
  3. Emit domain facts such as movement, interaction, damage, or time-of-day changes.
  4. Let domain engines react synchronously or through a controlled scheduler.
  5. Render current state through React subscriptions with narrow selectors.

For multiplayer games, the server can run steps 2–4 without R3F. The client keeps the renderer and interpolation while the protocol carries commands, snapshots, or events.

For the concrete store, ref, selector, and event boundaries, read the React Three Fiber game state management guide. For measurement, instancing, LOD, adaptive quality, and asset loading, use the React Three Fiber game performance guide.

Keep game-specific behavior in one visible composition root.

Generic packages cannot know what “grant gold,” “has reputation,” or “unlock recipe” means in your game. Store those references as serializable content and register the actual behavior when the app starts. This keeps content editable while avoiding direct imports between systems.

const conditions = createConditionRegistry<GameContext>()
const effects = createEffectRegistry<GameContext>()

conditions.register('player.minLevel', ({ level }, ctx) =>
  ctx.player.level >= Number(level)
)

effects.register('wallet.addGold', ({ amount }, ctx) =>
  ctx.wallet.add(Number(amount))
)

export const game = {
  quests: createQuestEngine({ quests: QUESTS, conditions, effects, context, events }),
  dialogue: createDialogueEngine({ dialogues: DIALOGUES, conditions, effects, context, events }),
}

The composition root is also the right place to choose platform storage, create a room- scoped event bus, attach development tools, and decide which packages the product actually needs.

Migrate one vertical slice at a time.

Begin with a flow that crosses the architecture: interact with an entity, progress a quest, grant a reward, and update the HUD. Once that flow works, the boundary is proven. You can then move the next system without pausing rendering work or rewriting the game.

Next: decouple quest progression

See how event-driven objectives and registered rewards create a quest engine that runs in R3F, tests, and authoritative servers.

Read the headless quest system guide →