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.