Architecture
AdvancedDeep dive into Web Engine's internal architecture. Understand the registry-first design, system phases, and data flow.
Web Engine follows a Registry-First architecture where all components, systems, and asset types are registered at runtime. This enables extensibility, runtime modification, and a clean separation between the engine core and game-specific logic.
Core Philosophy#
- Data-Oriented Design — Data lives in flat TypedArrays. Logic operates on data in batches.
- Registry-Driven — No hardcoded lists. All types are registered dynamically.
- Zero-GC Hot Path — No allocations in the game loop. Pre-allocate everything.
- Monorepo Structure — Clear separation between packages (core, editor, player).
- Production-First — No placeholders. Every feature is production-ready.
Registry Architecture#
Registries are the central source of truth for metadata:
ComponentRegistry
Defines all ECS components with their schemas and editor metadata.
SystemRegistry
Registers all systems with their phase, dependencies, and enable conditions.
AssetLoaderRegistry
Maps file extensions to asset loader functions.
// Register a custom componentComponentRegistry.register("MyComponent", { name: "MyComponent", description: "Does something cool", category: "gameplay", props: { speed: { type: "float", default: 5.0, min: 0, max: 100 }, target: { type: "entity", default: null }, enabled: { type: "boolean", default: true }, },}); // Later: query the registryconst schema = ComponentRegistry.get("MyComponent");const allComponents = ComponentRegistry.getAll();System Phases#
Systems run in ordered phases each frame. Each phase has a numeric value, and systems within a phase can specify a priority for fine-grained ordering:
export enum SystemPhase { Input = 0, // Read input devices Network = 100, // Network sync Logic = 200, // Game logic, AI, scripts Physics = 300, // Physics simulation Animation = 400, // Skeletal animations Audio = 500, // Audio updates Render = 600, // Three.js rendering PostRender = 700 // Post-processing, cleanup}Systems are sorted first by phase, then by priority within that phase. Lower priority values run first:
SystemRegistry.register({ name: "InputSystem", phase: SystemPhase.Input, priority: 0, // Runs first in Input phase execute: (world, delta, time) => { /* ... */ }}); SystemRegistry.register({ name: "ScriptSystem", phase: SystemPhase.Logic, priority: 10, // Runs after priority 0 systems after: ["InputSystem"], // Explicit ordering dependency execute: (world, delta, time) => { /* ... */ }});System Dependencies
Use after and before to declare explicit ordering dependencies. The registry validates the dependency graph at initialization and throws an error if dependencies are circular or missing.
Data Flow#
Scene Loading#
scene.json → SceneLoader → Create Entities → Add Components → Run onStartGame Loop#
Input → Physics → Scripts → Animation → RenderEditor → Engine#
UI Event → Command → ECS Mutation → Sync to Three.js → RenderModule Structure#
Web Engine is organized as a monorepo with clear separation between packages:
@web-engine-dev/core
ECS runtime, systems, components, registries, asset loading, and physics integration.
@web-engine-dev/config
Branding configuration, storage keys, and white-label settings.
@web-engine-dev/logging
Performance monitoring, frame profiling, and logging utilities.
@web-engine-dev/build-config
Shared Vite/Rollup chunking strategy for consistent builds.
web-engine/├── apps/│ ├── studio/ # Visual editor (Next.js)│ │ ├── src/│ │ │ ├── app/ # Next.js pages│ │ │ ├── components/ # React UI components│ │ │ ├── managers/ # Editor-specific managers│ │ │ ├── services/ # Context menus, diagnostics│ │ │ └── store/ # Zustand state management│ │ └── ...│ ││ ├── player/ # Runtime player (Vite)│ │ └── src/│ ││ ├── server/ # Multiplayer server (Colyseus)│ │ └── src/│ ││ └── docs/ # Documentation site (Next.js)│ └── src/│├── packages/│ ├── core/ # Engine runtime│ │ ├── src/│ │ │ ├── engine/ # Core engine systems│ │ │ │ ├── ecs/ # bitECS integration│ │ │ │ ├── physics/ # Rapier3D integration│ │ │ │ ├── assets/ # Asset loading pipeline│ │ │ │ ├── perf/ # Performance profiling│ │ │ │ └── utils/ # Object pools, math│ │ │ └── registries/ # Component/System registries│ │ └── ...│ ││ ├── config/ # Branding configuration│ ├── logging/ # Performance monitoring│ └── build-config/ # Build configuration│└── turbo.json # Turborepo build orchestrationDependency Graph#
Packages follow a strict dependency hierarchy to avoid circular dependencies:
@web-engine-dev/config (0 dependencies) ↑@web-engine-dev/logging (depends on config) ↑@web-engine-dev/core (depends on logging, config) ↑apps/studio, apps/player, apps/server (depend on core)Zero-GC Principles#
The game loop must never allocate memory. Here's how we achieve this:
Critical Rule
In src/engine (the "hot path"): NEVERuse new inside update loops. No temporary objects, arrays, or closures. Always use pre-allocated buffers.
// ❌ BAD - Allocates every framefunction update(entities) { for (const eid of entities) { const pos = new THREE.Vector3(); // ❌ new pos.set(Transform.x[eid], ...); const offset = { x: 1, y: 0, z: 0 }; // ❌ object literal }}// ✅ GOOD - Zero allocationsconst _tempVec3 = new THREE.Vector3(); // Pre-allocatedconst _offset = { x: 0, y: 0, z: 0 }; // Reused function update(entities) { for (let i = 0; i < entities.length; i++) { const eid = entities[i]; _tempVec3.set(Transform.x[eid], Transform.y[eid], Transform.z[eid]); _offset.x = 1; _offset.y = 0; _offset.z = 0; }}Extending the Engine#
Add custom components and systems without modifying core code:
import { ComponentRegistry, SystemRegistry } from "@web-engine-dev/core"; // Register custom componentComponentRegistry.register("Inventory", { name: "Inventory", description: "Player inventory", props: { capacity: { type: "int", default: 20 }, gold: { type: "int", default: 0 }, },}); // Register custom systemSystemRegistry.register("InventorySystem", { phase: "GAMEPLAY", query: ["Inventory"], update: (world, entities) => { for (const eid of entities) { // Process inventory logic } },});