Skip to content

Performance Optimization ​

Web games run in a constrained environment. This guide covers the tools and techniques for identifying bottlenecks and optimizing your game to run smoothly across target devices.

Profiling Tools ​

Always measure before optimizing. The engine provides built-in profiling at every layer.

System Timings ​

typescript
import { PerformanceMonitor } from '@web-engine-dev/devtools';

const perf = world.getResource(PerformanceMonitor);

// Enable per-system timing
perf.enableSystemTimings(true);

// Read timings each frame
function PerformanceLogSystem(world: World): void {
  const timings = world.getResource(SystemTimings);

  if (timings.anyExceeds(5)) {
    // any system over 5ms?
    console.table(timings.sorted()); // log sorted by duration
  }
}

Render Stats ​

typescript
import { RenderStats } from '@web-engine-dev/renderer';

function RenderStatsHUDSystem(world: World): void {
  const stats = world.getResource(RenderStats);

  updateDebugOverlay({
    fps: stats.fps,
    frameTime: `${stats.frameTimeMs.toFixed(2)}ms`,
    drawCalls: stats.drawCalls,
    triangles: stats.triangleCount.toLocaleString(),
    texMemory: `${stats.textureMemoryMB.toFixed(1)}MB`,
    vertexMem: `${stats.vertexMemoryMB.toFixed(1)}MB`,
  });
}

Memory Allocation Tracking ​

typescript
// Enable GC pressure tracking in dev mode
import { AllocationTracker } from '@web-engine-dev/devtools';

AllocationTracker.enable();

// After running for a while
const report = AllocationTracker.report();
console.log('Hot allocation sites:', report.topSites);
// Look for per-frame allocations: these drive GC pauses

ECS Performance ​

Zero-Allocation Hot Paths ​

The most common cause of ECS performance issues is per-frame heap allocation inside systems:

typescript
// BAD: allocates a new object every frame for every entity
function BadMovementSystem(world: World): void {
  const q = world.query().with(Position, Velocity).build();
  for (const {
    entity,
    components: [pos, vel],
  } of world.run(q)) {
    // This creates a new object on the heap every frame
    world.insert(entity, Position, { x: pos.x + vel.x, y: pos.y + vel.y, z: pos.z + vel.z });
  }
}

// GOOD: use a pre-allocated scratchpad or mutate in-place via a pool
const _scratchPos = { x: 0, y: 0, z: 0 };
function GoodMovementSystem(world: World): void {
  const q = world.query().with(Position, Velocity).build();
  for (const {
    entity,
    components: [pos, vel],
  } of world.run(q)) {
    _scratchPos.x = pos.x + vel.x;
    _scratchPos.y = pos.y + vel.y;
    _scratchPos.z = pos.z + vel.z;
    world.insert(entity, Position, _scratchPos);
  }
}

Narrow Queries ​

Overly broad queries iterate more entities than needed:

typescript
// BAD: queries ALL entities with Health, most won't need updating each frame
const allHealthQuery = world.query().with(Health).build();
for (const { entity, components: [health] } of world.run(allHealthQuery)) { ... }

// GOOD: narrow with additional component filters so only relevant entities are iterated
const activeHealthQuery = world.query().with(Health, Active).build();
for (const { entity, components: [health] } of world.run(activeHealthQuery)) { ... }

// GOOD: use tags to narrow (Active enemies only)
const activeEnemyPosQuery = world.query().with(Position, Enemy, Active).build();
for (const { entity, components: [pos] } of world.run(activeEnemyPosQuery)) { ... }

Parallel Systems ​

Mark independent systems to run in parallel using worker threads:

typescript
// Systems with non-overlapping component access can run in parallel
world.addSystemSet(
  'UpdateParallel',
  [
    MovementSystem, // writes Position, Velocity
    AnimationSystem, // writes AnimState (no overlap)
    AudioSourceSystem, // writes AudioSource (no overlap)
  ],
  { parallel: true }
);

Object Pooling ​

Avoid spawning/despawning frequently, pool entities instead:

typescript
import { EntityPool } from '@web-engine-dev/pooling';

// Pre-allocate a pool of 200 bullets
const bulletPool = new EntityPool(world, {
  prefab: 'Bullet',
  initialSize: 200,
  maxSize: 500,
  autoExpand: true,
});

// Acquire from pool (fast: no archetype migration)
function SpawnBulletSystem(world: World): void {
  if (input.justPressed('fire')) {
    const bullet = bulletPool.acquire();
    world.insert(bullet, Position, { x: playerPos.x, y: playerPos.y });
    world.insert(bullet, Velocity, { x: 0, y: -500 });
    world.insert(bullet, Active, {});
  }
}

// Release back to pool (doesn't actually despawn)
function BulletLifetimeSystem(world: World): void {
  const q = world.query().with(Bullet, Active).build();
  for (const {
    entity,
    components: [bullet],
  } of world.run(q)) {
    if (bullet.lifetime <= 0 || isOutOfBounds(entity)) {
      bulletPool.release(entity); // marks as inactive, returns to pool
    }
  }
}

Rendering Performance ​

Frustum & Occlusion Culling ​

Enable GPU-driven culling (default for large scenes):

typescript
world.insertResource(CullingSettings, {
  frustumCulling: true, // don't render off-screen objects
  occlusionCulling: true, // don't render objects behind other objects
  distanceCulling: {
    enabled: true,
    maxDistance: 300, // cull beyond 300 world units
  },
});

Draw Call Batching ​

typescript
// Static objects (never move) are auto-batched into draw call groups
world.insert(rockEntity, StaticRenderer, {}); // tagged as static

// Dynamic objects with the same material are batched via GPU instancing
world.insertResource(InstancingSettings, {
  enabled: true,
  minInstancesForBatch: 4, // batch if 4+ of same mesh/material
});

Texture Atlases ​

Reduce draw calls by packing sprites into atlases:

typescript
import { TextureAtlasBuilder } from '@web-engine-dev/texture-compression';

// Build in the asset pipeline (offline)
const atlas = await TextureAtlasBuilder.build({
  inputs: ['sprites/hero/**/*.png', 'sprites/enemies/**/*.png', 'sprites/pickup/**/*.png'],
  maxSize: 2048,
  padding: 2,
  output: 'dist/atlases/characters.png',
});

LOD System ​

typescript
world.insertResource(LODSettings, {
  auto: true, // auto-assign LOD based on screen coverage
  screenSizeBias: 1.0,
  // Override per quality tier
  qualityPresets: {
    low: { lodBias: 0.5 }, // use lower LOD earlier
    medium: { lodBias: 1.0 },
    high: { lodBias: 1.5 }, // use highest LOD longer
  },
});

Asset Streaming ​

typescript
import { StreamingSystem } from '@web-engine-dev/streaming';

world.addSystem(StreamingSystem);

world.insertResource(StreamingSettings, {
  // Load assets in advance as the player approaches
  preloadRadius: 200, // world units ahead of current chunk
  unloadDelay: 10, // seconds before unloading a background chunk
  maxConcurrentLoads: 4,
  prioritizeVisible: true,
});

Mobile Optimization Checklist ​

For games targeting mobile browsers:

typescript
// Detect and adapt to device performance tier
import { DeviceCapabilities } from '@web-engine-dev/platform';

const caps = world.getResource(DeviceCapabilities);

if (caps.tier === 'low') {
  // Reduce quality settings
  world.insertResource(RenderQuality, {
    shadowQuality: 'off',
    particleCount: 0.25, // 25% of full particle budget
    targetFPS: 30,
    textureQuality: 'half',
  });

  // Disable expensive post-processing
  pp.setEnabled('bloom', false);
  pp.setEnabled('ambientOcclusion', false);

  // Use simpler physics
  world.insertResource(PhysicsSettings, {
    substeps: 1, // instead of 4
  });
}

Key mobile rules:

  • Target 60fps on mid-tier, 30fps minimum on low-tier, measure on real devices
  • Total draw calls ≤ 100, batch everything
  • Texture memory ≤ 128MB, use compressed formats (BC7/ASTC via @web-engine-dev/texture-compression)
  • Zero per-frame GC, profile allocations, eliminate all hot allocations
  • Avoid large uniforms, keep shader constant buffers minimal

Performance Budget Template ​

CategoryTarget (60fps)Budget
Frame time16.67ms100%
ECS systems-3ms
Physics-2ms
Render (CPU)-2ms
GPU-8ms
Audio-0.3ms
Misc-1ms
Draw calls≤ 200-
Triangle count≤ 500K-
Texture memory≤ 256MB-

Next Steps ​

Proprietary software. All rights reserved.