Skip to content

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:

typescript
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:

typescript
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:

typescript
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:

bash
# 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:5173

Then use the diagnostic tools:

  1. engine_world_snapshot -- verify entity count and engine version
  2. engine_system_timings -- find the slowest ECS system by name
  3. engine_render_stats -- check draw calls, triangle count, and culling efficiency
  4. engine_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:

typescript
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:

typescript
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:

typescript
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:

typescript
// 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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

GroupPurposeUpdate FrequencyTypical Contents
0CameraPer-frameView/projection matrices, camera position
1ModelPer-objectWorld matrix, normal matrix
2MaterialPer-materialProperties uniform buffer, textures, samplers
3LightingPer-frameLight array, shadow maps, IBL, fog
wgsl
// 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:

wgsl
// 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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

typescript
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:

GroupIncludes
Game2DPluginsInput, Audio, Physics2D, Sprite, Particle, Animation, Scripting
Game3DPluginsInput, Audio, Physics3D, Render, Particle, Animation, Scripting
FullPluginsAll of the above combined

Using Plugins with Engine Presets

Combine presets with plugin groups for quick engine setup:

typescript
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:

typescript
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:

typescript
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-size

When 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:

typescript
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.

typescript
// 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 storage

Resources behave differently -- world.getResource() returns a direct reference. Mutations to resources persist without calling insertResource():

typescript
// Resources: mutations persist automatically
const config = world.getResource(GameConfig);
config.difficulty += 1;  // This works -- it is the real object

Networking 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:

typescript
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:

typescript
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:

typescript
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 frame

Determinism 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:

typescript
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:

ZoneDistanceUpdate Behavior
Full update0 -- updateRadiusEvery tick, all properties
Reduced updateupdateRadius -- awarenessRadiusLower frequency, delta only
CulledBeyond awarenessRadiusNo 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:

typescript
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

Proprietary software. All rights reserved.