Advanced Guides
This page covers advanced topics for developers who want to push the engine further: eliminate allocations in hot paths, write custom ECS systems with precise scheduling, author WGSL shaders for custom materials, build engine plugins, and implement multiplayer networking patterns.
Prerequisites
This guide assumes familiarity with the engine's core concepts. If you have not read them yet, start with ECS Concepts, Architecture Overview, and Data-Oriented Design.
Performance Optimization
Game engines run code every frame -- typically 60 times per second. Allocations in hot paths create garbage collection (GC) pauses that cause visible stuttering. The engine provides several tools for writing zero-allocation code.
Zero-Allocation Math with MathPool
The math library defaults to immutable operations that return new objects. In per-frame code, use MathPool to borrow temporary objects instead of allocating:
import { MathPool, Vec3 } from '@web-engine-dev/math';
// Preallocate pools at startup (once)
MathPool.preallocateAll(32);
// Per-frame: borrow temporaries with automatic cleanup
const distance = MathPool.withVec3((tmp1, tmp2) => {
Vec3.subOut(targetPos, entityPos, tmp1); // tmp1 = target - entity
return tmp1.length();
});
// Or manual get/release for longer-lived temporaries
const temp = MathPool.getVec3();
temp.setMut(1, 2, 3);
// ... use temp across multiple operations ...
MathPool.releaseVec3(temp);Mutable Math Operations
For values you own and want to modify in place, use the Mut suffix methods. These modify the receiver and return this for chaining -- zero allocations:
import { Vec3, Mat4, Quat } from '@web-engine-dev/math';
// Mutable methods modify in place (no allocation)
velocity.addMut(acceleration).scaleMut(deltaTime);
position.addMut(velocity);
// Static out-parameter methods write to a pre-allocated target
const result = Vec3.zero(); // Allocate once, reuse every frame
Vec3.addOut(a, b, result); // Writes a + b into result
// Mat4 TRS in one operation (avoids 3 separate multiplies)
const worldMatrix = Mat4.identity(); // Allocate once
worldMatrix.setFromTRSMut(position, rotation, scale);
// Chain multiple matrices without intermediate allocations
const mvp = Mat4.identity();
Mat4.multiplyChainOut([model, view, projection], mvp);Batch Operations on Float32Arrays
When transforming many vectors (particles, mesh vertices, skeletal animation), use BatchMath to operate on packed Float32Array data. These methods are loop-unrolled for auto-vectorization:
import { BatchMath } from '@web-engine-dev/math';
// Transform 1000 vertices by a matrix (in place)
const vertices = new Float32Array(3000); // [x,y,z, x,y,z, ...]
BatchMath.transformVec3PointArray(vertices, worldMatrix, vertices);
// Batch lerp for animation blending
BatchMath.lerpVec3Array(poseA, poseB, blendFactor, output);
// Batch quaternion slerp (optimized for small angles, common in animation)
BatchMath.slerpQuatArrayFast(rotationsA, rotationsB, t, output);
// Strided operations for interleaved vertex data
// e.g., position(3) + normal(3) + uv(2) = stride 8
BatchMath.transformVec3Strided(
vertices, worldMatrix, vertices, /*stride=*/ 8, /*offset=*/ 0
);
BatchMath.normalizeVec3Strided(
normals, normals, /*stride=*/ 8, /*offset=*/ 3
);Profiling with Engine Diagnostics
When the engine is running in the browser with Chrome DevTools Protocol enabled, use the MCP diagnostic tools to find bottlenecks:
# Terminal 1: Start the playground
pnpm --filter playground dev
# Terminal 2: Open Chrome with debug port
google-chrome --remote-debugging-port=9222 \
--user-data-dir=remote-debug-profile \
http://localhost:5173Then use the diagnostic tools:
engine_world_snapshot-- verify entity count and engine versionengine_system_timings-- find the slowest ECS system by nameengine_render_stats-- check draw calls, triangle count, and culling efficiencyengine_capture_frame-- screenshot the current canvas for visual verification
WARNING
These tools are read-only diagnostics. They connect via Chrome DevTools Protocol and will return connection errors if Chrome is not running with the debug port.
Custom Systems
Systems contain all game logic. The ECS provides two approaches for defining them: the simple defineSystem function and the fluent systemBuilder API.
Defining Systems with defineSystem
The simplest way to create a system -- provide a name and a function:
import { defineSystem } from '@web-engine-dev/ecs';
const MovementSystem = defineSystem('Movement', (world, dt) => {
for (const entity of world.entitiesWith(Position, Velocity)) {
const pos = world.get(entity, Position);
const vel = world.get(entity, Velocity);
// CRITICAL: world.get() returns a COPY. Must write back.
world.insert(entity, Position, {
x: pos.x + vel.x * dt,
y: pos.y + vel.y * dt,
z: pos.z + vel.z * dt,
});
}
});For more control, pass a configuration object with ordering constraints and run conditions:
const RenderSystem = defineSystem({
name: 'Render',
run: (world, dt) => {
// Rendering logic
},
after: [MovementSystem], // Runs after MovementSystem
runConditions: [
(world) => world.entityCount > 0, // Only run if entities exist
],
});Fluent System Builder
The systemBuilder from @web-engine-dev/ecs provides a chainable API for building systems with ordering constraints and conditions:
import { systemBuilder } from '@web-engine-dev/ecs';
const PhysicsSystem = systemBuilder('Physics')
.after(InputSystem) // Run after input processing
.before(RenderSystem) // Run before rendering
.runIf((world) => { // Only run when not paused
const state = world.getResource(GameStateResource);
return state !== undefined && !state.paused;
})
.build((world, dt) => {
// Physics simulation logic
});System Ordering and Dependencies
Systems declare ordering with before and after constraints. The scheduler performs a topological sort to determine execution order. Systems with no conflicts can run in parallel:
// Explicit ordering chain: Input -> Physics -> Movement -> Render
const InputSystem = defineSystem('Input', (world, dt) => { /* ... */ });
const PhysicsSystem = defineSystem({
name: 'Physics',
after: [InputSystem],
run: (world, dt) => { /* ... */ },
});
const MovementSystem = defineSystem({
name: 'Movement',
after: [PhysicsSystem],
run: (world, dt) => { /* ... */ },
});
const RenderSystem = defineSystem({
name: 'Render',
after: [MovementSystem],
run: (world, dt) => { /* ... */ },
});Run Conditions
Run conditions are predicate functions that gate system execution. If any condition returns false, the system is skipped for that frame:
import { defineSystem, defineResource } from '@web-engine-dev/ecs';
const GameState = defineResource<{ paused: boolean }>('GameState');
const GameplaySystem = defineSystem({
name: 'Gameplay',
runConditions: [
// Skip gameplay when paused
(world) => {
const state = world.getResource(GameState);
return state !== undefined && !state.paused;
},
],
run: (world, dt) => {
// This only runs when the game is not paused
},
});System Sets for Grouping
System sets let you configure ordering between groups of systems instead of managing individual system dependencies:
import { defineSystemSet, CommonSets } from '@web-engine-dev/ecs';
// Define custom sets
const AISet = defineSystemSet('AI');
const CombatSet = defineSystemSet('Combat');
// Configure set ordering on the schedule
schedule.configureSet(AISet, { after: [CommonSets.Input] });
schedule.configureSet(CombatSet, { after: [AISet] });
schedule.configureSet(CommonSets.Render, { after: [CombatSet] });
// Add systems to sets
schedule.addSystem(pathfindingSystem, { set: AISet });
schedule.addSystem(steeringSystem, { set: AISet });
schedule.addSystem(damageSystem, { set: CombatSet });
schedule.addSystem(healthSystem, { set: CombatSet });The engine also provides pre-defined sets via EngineSets for common game loop phases:
import { EngineSets } from '@web-engine-dev/engine';
// EngineSets.Input, EngineSets.Physics, EngineSets.Movement,
// EngineSets.Animation, EngineSets.PreRender, EngineSets.Render,
// EngineSets.PostRender, EngineSets.Network, EngineSets.Cleanup, etc.Registering Systems with the Engine
When using the Engine class, the systemBuilder from @web-engine-dev/engine provides integration with engine schedules:
import {
systemBuilder, registerSystem, CoreSchedule, EngineSets,
} from '@web-engine-dev/engine';
// Register with explicit options
registerSystem(engine, MovementSystem, {
schedule: CoreSchedule.Update,
inSets: [EngineSets.Movement],
});
// Or use the engine-level systemBuilder for one-step creation + registration
systemBuilder('Cleanup')
.inSchedule(CoreSchedule.PostUpdate)
.inSets(EngineSets.Cleanup)
.buildAndRegister(engine, (world) => {
// Cleanup logic
});Custom Materials and Shaders
The renderer uses WGSL (WebGPU Shading Language) for all runtime shaders. The shader-compiler package is used for preprocessing, variant tooling, and optional transpilation workflows.
The 4-Group Bind Layout
The renderer constrains all shaders to 4 bind groups to keep binding frequency boundaries explicit and stable:
| Group | Purpose | Update Frequency | Typical Contents |
|---|---|---|---|
| 0 | Camera | Per-frame | View/projection matrices, camera position |
| 1 | Model | Per-object | World matrix, normal matrix |
| 2 | Material | Per-material | Properties uniform buffer, textures, samplers |
| 3 | Lighting | Per-frame | Light array, shadow maps, IBL, fog |
// Group 0: Camera uniforms
@group(0) @binding(0) var<uniform> camera: CameraUniforms;
// Group 1: Per-object data
@group(1) @binding(0) var<uniform> model: ModelUniforms;
// Group 2: Material properties and textures
@group(2) @binding(0) var<uniform> material: MaterialUniforms;
@group(2) @binding(1) var baseColorTexture: texture_2d<f32>;
@group(2) @binding(2) var baseColorSampler: sampler;
// Group 3: Lights and shadows
@group(3) @binding(0) var<uniform> lights: LightData;Critical: std140 Alignment
vec3f has 16-byte alignment in uniform buffers, not 12. Always pack a scalar after vec3f or add explicit padding:
// WRONG: color starts at offset 12, misaligned
struct Bad { position: vec3f, color: vec3f }
// CORRECT: scalar fills vec3 padding slot
struct Good { position: vec3f, intensity: f32, color: vec3f, _pad: f32 }Shader Variants with Preprocessor Defines
The shader system supports compile-time feature composition via defines and template markers:
import { ShaderVariantBuilder, ShaderFeature } from '@web-engine-dev/renderer';
const variant = ShaderVariantBuilder.build(PBRTemplate, {
features: [
ShaderFeature.NormalMapping,
ShaderFeature.Skinning,
],
defines: {
MAX_LIGHTS: 16,
SHADOW_CASCADES: 4,
},
});Shader templates use placeholders that the variant builder fills based on enabled features. The ShaderVariantCache ensures each unique combination is compiled only once:
import { ShaderVariantCache } from '@web-engine-dev/renderer';
const cache = new ShaderVariantCache();
const shader = cache.getOrCreate(device, {
template: PBRTemplate,
features: enabledFeatures,
defines,
});Creating Custom Materials
Materials bind shader programs to property values and textures:
import {
Material, PBRMaterialDefinition, BlendMode, Color,
} from '@web-engine-dev/renderer';
const material = new Material(device, PBRMaterialDefinition);
material.setProperty('baseColor', new Color(1, 0, 0, 1));
material.setProperty('metallic', 0.8);
material.setProperty('roughness', 0.2);
material.setTexture('baseColorTexture', albedoTexture);
material.blendMode = BlendMode.Opaque;
// Update uniform buffer before rendering
material.updateUniforms();To create a renderable from a mesh and material:
import {
Mesh, createBox, createMaterialRenderable,
} from '@web-engine-dev/renderer';
const geometry = createBox({ width: 2, height: 1, depth: 1 });
const mesh = Mesh.fromGeometry(device, geometry);
const renderable = createMaterialRenderable(mesh, material, worldMatrix);
renderQueue.add(renderable);TIP
Sort draw calls by material (group 2) to minimize GPU state changes. The render queue does this automatically when you submit renderables.
Plugin Architecture
Plugins are the primary extension mechanism for the engine. They follow a lifecycle with dependency resolution: build (in dependency order), finish (after all plugins built), and cleanup (in reverse order on shutdown).
Creating a Plugin
The simplest plugin uses definePlugin with a name and a build function:
import { definePlugin, defineResource } from '@web-engine-dev/engine';
const ScoreResource = defineResource<{ value: number }>('Score');
const ScorePlugin = definePlugin('Score', (app) => {
app.insertResource(ScoreResource, { value: 0 });
});For plugins with full lifecycle control, implement the Plugin interface directly:
import { type Plugin, type EngineApp } from '@web-engine-dev/engine';
const DebugOverlayPlugin: Plugin = {
name: 'DebugOverlay',
dependencies: ['Render'], // Must be built after RenderPlugin
build(app: EngineApp): void {
// Register resources and systems
app.insertResource(DebugConfig, { showFps: true, showWireframe: false });
},
finish(app: EngineApp): void {
// Cross-plugin setup after all plugins are built
// Safe to read resources from other plugins here
},
cleanup(app: EngineApp): void {
// Release resources on engine shutdown
// Called in reverse build order
},
};Plugin Groups and Presets
Group related plugins with pluginGroup for one-line engine setup:
import { pluginGroup, RenderPlugin, InputPlugin, AudioPlugin } from '@web-engine-dev/engine';
const MyGamePlugins = pluginGroup('MyGame', [
InputPlugin,
AudioPlugin,
RenderPlugin,
ScorePlugin, // Your custom plugin
DebugOverlayPlugin, // Your custom plugin
]);
// One-line setup
engine.addPlugin(MyGamePlugins);The engine ships three built-in plugin groups:
| Group | Includes |
|---|---|
Game2DPlugins | Input, Audio, Physics2D, Sprite, Particle, Animation, Scripting |
Game3DPlugins | Input, Audio, Physics3D, Render, Particle, Animation, Scripting |
FullPlugins | All of the above combined |
Using Plugins with Engine Presets
Combine presets with plugin groups for quick engine setup:
import {
createEngine, Game3DPreset, Game3DPlugins,
} from '@web-engine-dev/engine';
const engine = createEngine(Game3DPreset);
engine.addPlugin(Game3DPlugins);
await engine.init();
engine.start();The configureEngine Hook in Demos
When extending the demo base class ECSRendererDemoUnified, override configureEngine() to add plugins before the engine initializes:
import { type Engine, createInputPlugin, createPhysics3DPlugin } from '@web-engine-dev/engine';
class MyDemo extends ECSRendererDemoUnified {
protected configureEngine(engine: Engine): void {
engine.addPlugin(createInputPlugin({
enableSystems: false, // Registries only; run systems manually
config: { enableKeyboard: true, enableMouse: true },
}));
engine.addPlugin(createPhysics3DPlugin({
gravity: [0, -9.81, 0],
}));
}
}INFO
Setting enableSystems: false on a plugin creates only the ECS resources and registries without registering automatic systems. This is useful in demos where you call systems manually in your own render loop.
Data-Oriented Design in Practice
For the conceptual foundations of DOD, see Data-Oriented Design. This section covers practical how-to patterns.
Understanding Archetype Storage
Every unique combination of component types defines an archetype. Entities sharing the same component set are stored together in contiguous typed arrays (SoA layout):
Archetype [Position, Velocity] Archetype [Position, Velocity, Health]
Position.x: [1.0, 2.0, 3.0] Position.x: [7.0, 8.0]
Position.y: [0.0, 1.0, 0.0] Position.y: [3.0, 5.0]
Velocity.x: [0.5, -1.0, 0.0] Velocity.x: [0.0, 1.0]
Velocity.y: [0.0, 0.5, 9.8] Velocity.y: [0.0, -2.0]
Health.value: [100, 75]Adding or removing a component moves an entity to a different archetype. The edge cache makes repeated transitions O(1), but frequent component toggling still copies data. For components that toggle frequently (cooldowns, buffs, debuffs), use sparse components instead.
Sparse vs Dense Components
Dense components (the default) are stored in archetype tables. Adding or removing them triggers an archetype transition. Sparse components are stored in a separate sparse set and do not affect archetype membership:
import { defineComponent, defineTag } from '@web-engine-dev/ecs';
// Dense: fast iteration, but adding/removing causes archetype transition
const Position = defineComponent('Position', { x: 'f32', y: 'f32', z: 'f32' });
// Sparse: O(1) add/remove, no archetype transition, but slower iteration
const Cooldown = defineComponent('Cooldown', { remaining: 'f32' }, { sparse: true });
const Stunned = defineTag('Stunned'); // Tags are always zero-sizeWhen to use sparse components:
- Frequently toggled: buffs, debuffs, cooldown timers, temporary flags
- Rare queries: data you write often but read infrequently
- Avoid archetype fragmentation: if many entities would each have a slightly different component set
When to use dense components:
- Hot path iteration: position, velocity, transform -- iterated every frame
- Stable composition: component set rarely changes after entity creation
Cache-Friendly Iteration
For maximum performance, iterate archetype columns directly instead of per-entity world.get() calls:
for (const archetype of query.archetypes) {
const positions = archetype.getColumn(Position);
const velocities = archetype.getColumn(Velocity);
const count = archetype.count;
for (let i = 0; i < count; i++) {
positions[i].x += velocities[i].x * dt;
positions[i].y += velocities[i].y * dt;
}
}This pattern reads contiguous memory sequentially, which is optimal for CPU cache prefetching. The engine's SoA layout ensures that positions[i] and positions[i+1] are adjacent in memory.
Component Data is COPIED
Critical Pitfall
world.get(entity, Component) returns a copy of the component data, not a reference. The ECS uses SoA columnar storage -- Table.get() allocates a new object and populates it from column values on every call. Mutating the returned object does NOT update storage.
// BUG: Changes silently lost
const pos = world.get(entity, Position);
pos.x += 10; // Modifies local copy only!
// Next frame: world.get() returns the original, unmodified values
// CORRECT: Write back after modifying
const pos = world.get(entity, Position);
pos.x += 10;
world.insert(entity, Position, pos); // Persist to storageResources behave differently -- world.getResource() returns a direct reference. Mutations to resources persist without calling insertResource():
// Resources: mutations persist automatically
const config = world.getResource(GameConfig);
config.difficulty += 1; // This works -- it is the real objectNetworking Patterns
For the full networking API reference, see Networking Concepts. This section provides practical implementation patterns.
Client-Side Prediction Workflow
Client-side prediction hides latency by applying player inputs immediately on the client while waiting for the server's authoritative response:
import type { PredictionConfig } from '@web-engine-dev/netcode';
const predictionConfig: PredictionConfig = {
enabled: true,
maxBufferSize: 128,
correctionThreshold: 0.01,
smoothCorrection: true,
correctionBlendTime: 100, // ms to blend corrections
};
// Step 1: Client collects input and assigns a sequence number
const input = { type: 'move', direction: { x: 1, y: 0 }, sequence: nextSeq++ };
// Step 2: Send input to server AND apply locally (prediction)
client.sendInput(input);
localPlayer.applyInput(input);
predictedStates.push({ sequence: input.sequence, state: localPlayer.getState() });
// Step 3: When server state arrives, reconcile
function onServerState(authState: AuthoritativeState): void {
// Discard predictions older than the acknowledged sequence
while (predictedStates.length > 0 && predictedStates[0].sequence <= authState.sequence) {
predictedStates.shift();
}
// Check for misprediction
const predicted = predictedStates[0];
if (predicted && mismatch(predicted.state, authState)) {
// Snap to authoritative state, then replay unacknowledged inputs
localPlayer.setState(authState);
for (const p of predictedStates) {
localPlayer.applyInput(p.input);
}
}
}Server Reconciliation with Lag Compensation
The server rewinds time to validate hits at the client's perceived perspective:
import type { LagCompensationConfig } from '@web-engine-dev/netcode';
const lagCompConfig: LagCompensationConfig = {
enabled: true,
maxRewindTime: 200, // Max ms to rewind
historyBufferSize: 64, // Ticks of state history
interpolationPeriod: 100, // Render interpolation delay (ms)
serverHitValidation: true,
};
// Server-side hit validation
function validateHit(request: HitRequest, clientRTT: number): HitValidationResult {
// Rewind world to the client's perceived time
const rewindTime = request.attackTime - clientRTT / 2;
const historicalState = stateHistory.getStateAt(rewindTime);
// Validate hit against rewound positions
const target = historicalState.entities.get(request.targetId);
if (target && rayIntersects(request.origin, request.direction, target.bounds)) {
return { valid: true };
}
return { valid: false, reason: 'missed' };
}Rollback Networking for Fighting Games
Rollback netcode exchanges inputs (not state) between peers and re-simulates when late inputs arrive. This requires a fully deterministic simulation:
import type { RollbackConfig } from '@web-engine-dev/netcode';
const rollbackConfig: RollbackConfig = {
enabled: true,
maxRollbackFrames: 7, // Max frames to re-simulate
inputDelayFrames: 2, // Local input delay (reduces rollback frequency)
frameAdvantageLimit: 8, // Max frames ahead before waiting for peer
syncTestMode: false, // Enable to verify determinism via state hashes
};The rollback loop follows this pattern each frame:
1. Receive remote inputs (may arrive for past frames)
2. If a late input arrived for frame N:
a. Save current state
b. Restore state at frame N
c. Apply the correct input at frame N
d. Re-simulate frames N through current
3. Predict remote input for current frame (repeat last known)
4. Advance simulation one frameDeterminism Requirements
Rollback networking requires that the same inputs always produce the same outputs. Avoid Math.random() (use seeded RNG), Date.now(), and floating-point-order-dependent accumulation. The syncTestMode flag enables state hash comparison to detect non-deterministic bugs -- the desync-detected event fires when hashes diverge.
Interest Management and Area of Interest
For large worlds with many entities, Area of Interest (AOI) limits bandwidth by only sending relevant updates to each client:
import type { AOIConfig } from '@web-engine-dev/netcode';
const aoiConfig: AOIConfig = {
enabled: true,
updateRadius: 100, // Full-fidelity update radius (world units)
awarenessRadius: 150, // Reduced update radius
maxEntitiesPerClient: 100, // Hard cap per client (bandwidth safety)
cellSize: 25, // Spatial hash grid cell size
priorityWeight: 100, // Weight for entity priority scoring
distanceWeight: 50, // Weight for distance in scoring
updateFrequency: 5, // Ticks between AOI recalculation
};The three update zones work as follows:
| Zone | Distance | Update Behavior |
|---|---|---|
| Full update | 0 -- updateRadius | Every tick, all properties |
| Reduced update | updateRadius -- awarenessRadius | Lower frequency, delta only |
| Culled | Beyond awarenessRadius | No updates sent |
Entities also have a priority (0-255). Higher-priority entities receive more bandwidth allocation and are less likely to be culled when the maxEntitiesPerClient cap is reached.
Network Simulation for Testing
Test your netcode under adverse conditions without deploying to a real network:
import type { NetworkSimulation } from '@web-engine-dev/netcode';
const badNetwork: NetworkSimulation = {
latency: 150, // 150ms base latency
latencyVariance: 50, // +/- 50ms jitter
packetLoss: 10, // 10% packet loss
packetDuplication: 2, // 2% duplicates
packetReordering: 5, // 5% out-of-order delivery
bandwidthLimit: 25000, // 25 KB/s limit
};This lets you verify that prediction, interpolation, and rollback handle real-world conditions gracefully during local development.
Further Reading
- Architecture Overview -- How all the pieces fit together
- Data-Oriented Design -- Conceptual foundations of SoA and archetype storage
- ECS Concepts -- Entities, components, systems, queries, and events
- Networking Concepts -- Full API reference for netcode, netcode-ecs, and netcode-server