Creating Custom Systems
AdvancedLearn how to create and register custom ECS systems with proper phases, dependencies, and optimization patterns.
Systems are the logic layer in Web Engine's ECS architecture. This guide covers creating custom systems, registering them with the SystemRegistry, managing dependencies, and optimizing for performance.
System Basics#
A system is a function that processes entities matching a query. Systems run in phases and can declare dependencies on other systems.
import { SystemRegistry, SystemPhase } from "@web-engine-dev/core";import type { IWorld } from "bitecs"; // Define the system execute functionfunction mySystemExecute(world: IWorld, delta: number, time: number): IWorld { // Process entities here return world;} // Register the systemSystemRegistry.register({ name: "MySystem", description: "Does something cool", phase: SystemPhase.Logic, priority: 0, execute: mySystemExecute,});System Definition#
interface SystemDefinition { name: string; // Unique system identifier description?: string; // Human-readable description phase: SystemPhase; // Execution phase priority?: number; // Priority within phase (default: 0) execute: SystemExecute; // System logic function enabled?: boolean; // Whether system is enabled (default: true) requires?: string[]; // Hard dependencies (must be registered) after?: string[]; // Systems that must run before this one before?: string[]; // Systems that must run after this one skipWhenEmpty?: boolean; // Skip execution if query is empty query?: SystemQueryRef; // Primary entity query}Phase-Based Execution
Systems run in ordered phases each frame
Dependency Graph
Declare explicit ordering with after/before
Conditional Execution
Skip systems when no entities match
Priority Ordering
Fine-grained control within phases
System Phases#
export enum SystemPhase { Input = 0, // Read input devices, update state Network = 100, // Network synchronization Logic = 200, // Game logic, AI, scripts Physics = 300, // Physics simulation step Animation = 400, // Skeletal animations, IK Audio = 500, // Audio playback updates Render = 600, // Three.js rendering PostRender = 700 // Post-processing, cleanup}Phase Ordering
Systems are executed in phase order. Within a phase, systems are sorted by priority (lower runs first), then by dependency graph.
Creating a System#
System with Entity Query#
import { defineQuery, IWorld } from "bitecs";import { SystemRegistry, SystemPhase } from "@web-engine-dev/core";import { Transform, RigidBody, Velocity } from "./components"; // Define entity queryconst gravityQuery = defineQuery([Transform, RigidBody, Velocity]); // System execute functionfunction gravitySystemExecute(world: IWorld, delta: number, time: number): IWorld { const entities = gravityQuery(world); const gravity = -9.81; for (let i = 0; i < entities.length; i++) { const eid = entities[i]; // Skip static bodies if (RigidBody.type[eid] === 1) continue; // Apply gravity Velocity.y[eid] += gravity * delta; } return world;} // Register the systemSystemRegistry.register({ name: "GravitySystem", description: "Applies gravity to dynamic rigid bodies", phase: SystemPhase.Physics, priority: -10, // Run before main physics step execute: gravitySystemExecute, skipWhenEmpty: true, query: gravityQuery,});System with Dependencies#
SystemRegistry.register({ name: "CharacterControllerSystem", description: "Player movement controller", phase: SystemPhase.Logic, priority: 10, execute: characterControllerExecute, // Declare dependencies requires: ["InputSystem"], // Must be registered after: ["InputSystem"], // Must run after InputSystem before: ["AnimationSystem"], // Must run before AnimationSystem skipWhenEmpty: true, query: characterQuery,});System Priority#
Priority controls execution order within a phase. Lower values run first:
// Priority: -100 to 100 (recommended range)SystemRegistry.register({ name: "EarlyInputSystem", phase: SystemPhase.Input, priority: -10, // Runs first execute: earlyInputExecute,}); SystemRegistry.register({ name: "InputSystem", phase: SystemPhase.Input, priority: 0, // Default priority execute: inputExecute,}); SystemRegistry.register({ name: "LateInputSystem", phase: SystemPhase.Input, priority: 10, // Runs last execute: lateInputExecute,});Conditional Execution#
import { defineQuery } from "bitecs";import { Particle, Transform } from "./components"; const particleQuery = defineQuery([Particle, Transform]); SystemRegistry.register({ name: "ParticleSystem", phase: SystemPhase.Logic, execute: particleSystemExecute, // Skip execution when no particles exist skipWhenEmpty: true, query: particleQuery,}); // System will only run when particleQuery matches entitiesfunction particleSystemExecute(world: IWorld, delta: number): IWorld { const entities = particleQuery(world); // This is only called when entities.length > 0 for (let i = 0; i < entities.length; i++) { // Process particles } return world;}Performance Optimization
Use skipWhenEmpty: true for systems that process specific entities. This saves CPU cycles when no matching entities exist.
Inter-System Communication#
Shared State via Components#
import { defineComponent, Types } from "bitecs"; // Define a singleton component for global stateexport const GameState = defineComponent({ isPaused: Types.ui8, timeScale: Types.f32, score: Types.i32,}); // System A writes to GameStatefunction systemA(world: IWorld) { const state = 0; // Singleton entity GameState.isPaused[state] = 1; return world;} // System B reads from GameStatefunction systemB(world: IWorld) { const state = 0; if (GameState.isPaused[state]) { // Game is paused, skip logic return world; } // Process normal logic return world;}Event Queues#
import { EventPool } from "@web-engine-dev/core"; // Define event structureinterface DamageEvent { entity: number; amount: number; source: number;} // Create event pool (zero-GC)const damageEvents = new EventPool<DamageEvent>( () => ({ entity: 0, amount: 0, source: 0 }), 256 // Max events per frame); // System A: Emit damage eventsfunction combatSystem(world: IWorld): IWorld { // ... const event = damageEvents.acquireSlot(); if (event) { event.entity = targetEid; event.amount = 25; event.source = attackerEid; } return world;} // System B: Process damage eventsfunction healthSystem(world: IWorld): IWorld { damageEvents.processEvents(event => { applyDamage(event.entity, event.amount); }); // Clear events for next frame damageEvents.clear(); return world;}Performance Best Practices#
Avoid Allocations#
import { Vector3Pool } from "@web-engine-dev/core"; // ❌ BAD - Allocates every framefunction badSystem(world: IWorld): IWorld { const entities = query(world); for (const eid of entities) { const direction = new THREE.Vector3(1, 0, 0); // ❌ // Use direction... } return world;} // ✅ GOOD - Reuses pooled objectfunction goodSystem(world: IWorld): IWorld { const entities = query(world); for (let i = 0; i < entities.length; i++) { const eid = entities[i]; const direction = Vector3Pool.acquire(); direction.set(1, 0, 0); // Use direction... Vector3Pool.release(direction); } return world;}Batch Operations#
// ❌ BAD - Individual updatesfunction individualUpdates(world: IWorld): IWorld { const entities = query(world); for (const eid of entities) { scene.add(getMesh(eid)); // ❌ Separate scene updates } return world;} // ✅ GOOD - Batched updatefunction batchedUpdates(world: IWorld): IWorld { const entities = query(world); const group = new THREE.Group(); for (let i = 0; i < entities.length; i++) { group.add(getMesh(entities[i])); } scene.add(group); // Single scene update return world;}Debugging Systems#
import { frameProfiler } from "@web-engine-dev/core"; // Enable/disable systems at runtimeSystemRegistry.setEnabled("MySystem", false); // DisableSystemRegistry.setEnabled("MySystem", true); // Enable // Check if system is registeredif (SystemRegistry.has("MySystem")) { console.log("MySystem is registered");} // Get system definitionconst systemDef = SystemRegistry.get("MySystem");console.log(systemDef?.phase, systemDef?.priority); // Get execution orderconst executionList = SystemRegistry.getExecutionList();executionList.forEach((sys, index) => { console.log(`${index}: ${sys.name} (phase: ${sys.phase})`);}); // Profile system performanceconst stats = frameProfiler.getStatistics();stats.slowestSystems.forEach(sys => { console.log(`${sys.name}: avg=${sys.avgTime.toFixed(2)}ms`);});Complete Example#
import { defineQuery, defineComponent, Types, IWorld } from "bitecs";import { SystemRegistry, SystemPhase } from "@web-engine-dev/core"; // Define componentsexport const Health = defineComponent({ current: Types.f32, max: Types.f32, regenRate: Types.f32, regenDelay: Types.f32, lastDamageTime: Types.f32,}); export const HealthRegen = defineComponent({ enabled: Types.ui8,}); // Create queryconst healthRegenQuery = defineQuery([Health, HealthRegen]); // System logicfunction healthRegenExecute(world: IWorld, delta: number, time: number): IWorld { const entities = healthRegenQuery(world); for (let i = 0; i < entities.length; i++) { const eid = entities[i]; // Skip if regen is disabled if (!HealthRegen.enabled[eid]) continue; // Wait for regen delay after damage const timeSinceDamage = time - Health.lastDamageTime[eid]; if (timeSinceDamage < Health.regenDelay[eid]) continue; // Apply regeneration const current = Health.current[eid]; const max = Health.max[eid]; const regenRate = Health.regenRate[eid]; if (current < max) { Health.current[eid] = Math.min(max, current + regenRate * delta); } } return world;} // Register systemSystemRegistry.register({ name: "HealthRegenSystem", description: "Regenerates health over time", phase: SystemPhase.Logic, priority: 20, execute: healthRegenExecute, skipWhenEmpty: true, query: healthRegenQuery,});