Runnable starter · Tested vertical slice

A React Three Fiber RPG starter that begins with gameplay, not a spinning cube.

Run a complete interaction → dialogue → quest → collection → reward → HUD loop. The starter uses geometric fallbacks, so you can verify the architecture before downloading art or committing to a production world.

Clone, build, and play the vertical slice.

git clone https://github.com/luzhenqian/overworld.git
cd overworld
corepack enable
pnpm install
pnpm build
pnpm --filter starter dev

Walk with WASD or the virtual joystick, talk to the guide with E, accept the crystal quest, collect three items, receive the reward, and return for a condition-gated dialogue choice. The same scene also demonstrates a minimap, day/night state, AI navigation, localization, an editor, and cross-tab multiplayer presence.

Create systems together, but keep their state separate.

The starter's engine module is the one place where content, registries, stores, and events meet. A factory creates isolated instances for tests; the production application passes the shared event bus explicitly.

export function createEngines(overrides = {}) {
  const events = overrides.events ?? new EventBus()
  const rng = overrides.rng ?? { next: Math.random }
  const conditions = createConditionRegistry()
  const effects = createEffectRegistry()

  const quests = createQuestEngine({
    quests: QUESTS, conditions, effects, events, persist: false,
  })
  const inventory = createInventory({ items: ITEMS, effects, events })
  const loot = createLootTable(LOOT_POOL, { rng })

  effects.register('loot.random', () => inventory.add(loot.roll(), 1))
  return { events, conditions, effects, quests, inventory, loot }
}

Content refers to effects such as quest.start and wallet.add by identifier. It does not import the wallet, quest engine, React UI, or scene. That makes content serializable and statically validatable.

Let the scene report facts instead of owning RPG rules.

R3F owns frames, transforms, cameras, materials, and pointer hits. When a player reaches an item, the scene emits a typed gameplay fact. Domain systems update their own state, and the HUD renders projections from those stores.

function Crystal({ id, position }) {
  const collect = () => {
    inventory.add('energy_crystal', 1)
    gameEvents.emit('item:collected', {
      itemId: 'energy_crystal',
      quantity: 1,
    })
  }

  return (
    <mesh position={position} onClick={collect}>
      <octahedronGeometry />
      <meshStandardMaterial color="#62d6ff" />
    </mesh>
  )
}

In the full starter, proximity collection and input arbitration add production detail. The important boundary stays the same: a mesh does not mutate quest internals, and a quest does not know that the item was rendered by R3F.

Trace one flow through six small files.

content.ts

NPCs, dialogue, quests, items, achievements, and declarative references.

engines.ts

Composition root, registries, systems, event wiring, AI, and presence.

World.tsx

Player, NPCs, lighting, collection checks, and minimap markers.

HUD.tsx

Quest tracking, dialogue, inventory, notifications, and touch controls.

loot.ts

A game-specific random mechanic with an injected random source.

__tests__

Engine wiring, input lifecycle, seeded loot, and state snapshots.

Protect the architecture before adding production art.

pnpm --filter starter test
pnpm --filter starter typecheck
pnpm --filter starter build

Extend the slice one seam at a time: replace geometric fallbacks with GLB assets, replace in-memory persistence with a platform adapter, or replace BroadcastChannel presence with WebSocket transport. Avoid moving domain rules back into frame callbacks as the scene becomes more detailed.

Decide which layer you actually need

Compare renderers, RPG system layers, full frameworks, and multiplayer backends by responsibility.

Read the TypeScript RPG framework comparison →