Architecture guide · Six runtime targets

Cross-platform TypeScript games without platform checks in every system.

Share content and game rules across browser, desktop, mobile, mini apps, and Node. Keep each delivery shell small by putting storage, lifecycle, input, networking, and rendering differences behind explicit capabilities.

Share rules and content—not every line of application code.

Cross-platform architecture works when the portable layer has no dependency on a DOM, WebGL context, native SDK, or component lifecycle. Quest objectives, dialogue choices, inventory operations, achievements, and validated content can then run in a browser, a test process, or an authoritative Node server.

Portable domain core

Serializable content, state machines, typed events, conditions, and effects.

Composition root

Creates engines, selects adapters, registers game-specific rules, and wires events.

Presentation

R3F or Three.js renders the world; React, WXML, or another UI projects state.

Platform shell

Owns native SDKs, app lifecycle, storage, safe areas, back buttons, and distribution.

The goal is not “100% shared code.” The goal is one authoritative implementation of game rules, with replaceable delivery edges.

Ask for capabilities instead of branching on platform names.

A quest engine needs storage; it does not need to know whether that storage is localStorage, a Tauri file, or WeChat storage. Application code chooses the adapter once and injects the narrow interface.

import { createBridge } from '@overworld-engine/platform'
import { gameEvents } from '@overworld-engine/core'
import { createQuestEngine } from '@overworld-engine/quest'

const bridge = createBridge()
const unbindLifecycle = bridge.bindLifecycle(gameEvents)

const quests = createQuestEngine({
  quests: QUESTS,
  conditions,
  effects,
  events: gameEvents,
  persist: { storage: () => bridge.storage() },
})

// UI handles app:back according to current state.
gameEvents.on('app:back', () => {
  if (dialogue.getState().activeDialogue) dialogue.end()
})

Keep SDK imports in the shell or adapter package. That prevents a native dependency from leaking into domain bundles and makes unsupported capabilities visible during composition rather than at an arbitrary call site.

Choose the shell that matches the distribution target.

TargetRendering hostPlatform edgeReference
WebR3F CanvasBrowser APIsStarter
Telegram Mini AppWebViewTelegram bridgeRepository example
macOS / WindowsTauri WebViewFile storage and native lifecycleRepository example
iOS / AndroidCapacitor WebViewSafe areas and app lifecycleRepository example
WeChat Mini GameR3F createRootCanvas, pointer, audio, socket adaptersRepository example
NodeNoneServer transport and persistenceAuthority guide

A WeChat Mini Program without a 3D canvas can still run the headless systems and render state with WXML. A WeChat Mini Game needs a canvas and SDK-specific adapters. Treat those as different products even though they share domain packages.

Normalize intentions, not raw device events.

Keyboard keys, touch sticks, controller axes, and native back buttons should become domain-level intentions such as move, interact, pause, and back. Input priority can then block movement while dialogue or a modal is active without platform-specific conditionals in the player controller.

  • Bind visibility, activation, focus, and native app callbacks to pause/resume events.
  • Route the back action through the UI state stack before allowing the shell to exit.
  • Choose quality presets from device capabilities, then let players override them.
  • Keep safe-area CSS and native permissions in the platform shell.

Test the shared core once, then test every adapter seam.

Unit tests should inject storage, clocks, event buses, and seeded randomness into the portable core. Integration tests should verify that each shell forwards lifecycle, input, and persistence correctly. A successful browser build does not prove that touch, native resume, or a mini-game pointer bridge works.

// Domain test: no renderer and no native shell.
const events = new EventBus()
const game = createEngines({
  events,
  rng: createSeededRng(42),
  storage: createMemoryStorage(),
})

events.emit('item:collected', { itemId: 'crystal', quantity: 1 })
expect(game.quests.getState().active).toMatchObject({
  collect_crystals: { progress: 1 },
})

See the boundary in a complete R3F slice

Run the repository starter, then trace one interaction across scene, systems, reward, and HUD.

Open the React Three Fiber RPG starter guide →