Multithreading

Scale to millions of entities with parallel processing.

Overview

bitECS doesn't provide an explicit multithreading API, but its data structures are designed to work efficiently with web workers. The key is usingSharedArrayBuffer for components and query results.

Shared Memory

Components backed by SharedArrayBuffer share data across threads.

Buffer Queries

asBuffer returns SAB-backed Uint32Array for parallel iteration.

Command Queues

Structural changes queue to main thread for synchronization.
!
Structural Operations
Functions like addEntity, removeEntity, andaddComponent cannot be called from workers. They modify shared state and require synchronization on the main thread.
// SharedArrayBuffer allows true memory sharing
const buffer = new SharedArrayBuffer(1024)
const view1 = new Float32Array(buffer)
const view2 = new Float32Array(buffer)  // Same memory!

view1[0] = 42.5
console.log(view2[0])  // 42.5 - no copying!

Shared Components

Create components with SharedArrayBuffer-backed TypedArrays for cross-thread access:

shared-components.ts
const MAX_ENTITIES = 1_000_000

// Create SharedArrayBuffer-backed components
const Position = {
  x: new Float32Array(
    new SharedArrayBuffer(MAX_ENTITIES * Float32Array.BYTES_PER_ELEMENT)
  ),
  y: new Float32Array(
    new SharedArrayBuffer(MAX_ENTITIES * Float32Array.BYTES_PER_ELEMENT)
  )
}

const Velocity = {
  x: new Float32Array(
    new SharedArrayBuffer(MAX_ENTITIES * Float32Array.BYTES_PER_ELEMENT)
  ),
  y: new Float32Array(
    new SharedArrayBuffer(MAX_ENTITIES * Float32Array.BYTES_PER_ELEMENT)
  )
}

// Create world with shared components
const world = createWorld({
  components: { Position, Velocity }
})
Memory Layout

Each component property gets its own SharedArrayBuffer. For 1 million entities with a 2-property Position component using Float32, that's 8MB total (2 × 4 bytes × 1,000,000). Plan your memory budget accordingly.

Query Buffers

Use asBuffer to get query results as a SharedArrayBuffer-backed Uint32Array. This allows efficient transfer to workers:

query-buffers.ts
import { query, asBuffer } from 'bitecs'

// Regular query returns readonly number[]
const entities = query(world, [Position, Velocity])

// Buffer query returns SAB-backed Uint32Array
const entitiesBuffer = query(world, [Position, Velocity], asBuffer)

// Buffer can be shared with workers without copying
worker.postMessage({
  entities: entitiesBuffer,
  components: world.components
})
Query TypeReturnsUse Case
Regularreadonly number[]Single-threaded iteration
BufferedUint32Array (SAB)Multi-threaded, transferable

Worker Setup

Set up workers to receive and process shared data:

main.ts
// main.ts - Main thread setup
const MAX_ENTITIES = 1_000_000

const world = createWorld({
  components: {
    Position: {
      x: new Float32Array(new SharedArrayBuffer(MAX_ENTITIES * 4)),
      y: new Float32Array(new SharedArrayBuffer(MAX_ENTITIES * 4))
    }
  }
})

const worker = new Worker('./physics-worker.js')

// Send shared data to worker
worker.postMessage({
  entities: query(world, [world.components.Position], asBuffer),
  components: world.components
})

// Handle results from worker
worker.onmessage = ({ data }) => {
  const { removeQueue } = data
  for (let i = 0; i < removeQueue.length; i++) {
    removeEntity(world, removeQueue[i])
  }
}
physics-worker.ts
// physics-worker.ts - Worker thread
self.onmessage = ({ data }) => {
  const { entities, components: { Position } } = data

  // Process entities - direct memory access!
  for (let i = 0; i < entities.length; i++) {
    const eid = entities[i]
    Position.x[eid] += 1
    Position.y[eid] += 1
  }

  // Signal completion
  self.postMessage({ done: true })
}

Command Queues

Since structural operations must happen on the main thread, use command queues to collect operations in workers and execute them on main:

command-queue.ts
// Worker thread
const MAX_COMMANDS = 100_000
const removeQueue = new Uint32Array(
  new SharedArrayBuffer(MAX_COMMANDS * Uint32Array.BYTES_PER_ELEMENT)
)
const spawnQueue = new Float32Array(
  new SharedArrayBuffer(MAX_COMMANDS * 2 * Float32Array.BYTES_PER_ELEMENT)
)

self.onmessage = ({ data }) => {
  const { entities, components: { Position, Health } } = data

  let removeCount = 0
  let spawnCount = 0

  for (let i = 0; i < entities.length; i++) {
    const eid = entities[i]

    // Check if entity should be removed
    if (Health[eid] <= 0) {
      removeQueue[removeCount++] = eid
    }

    // Check if entity should spawn projectile
    if (shouldFire(eid)) {
      spawnQueue[spawnCount * 2] = Position.x[eid]
      spawnQueue[spawnCount * 2 + 1] = Position.y[eid]
      spawnCount++
    }
  }

  // Send queues back to main thread
  self.postMessage({
    removeQueue: removeQueue.subarray(0, removeCount),
    spawnQueue: spawnQueue.subarray(0, spawnCount * 2),
    spawnCount
  })
}
main-process.ts
// Main thread - process queues
worker.onmessage = ({ data }) => {
  const { removeQueue, spawnQueue, spawnCount } = data

  // Process remove queue
  for (let i = 0; i < removeQueue.length; i++) {
    removeEntity(world, removeQueue[i])
  }

  // Process spawn queue
  for (let i = 0; i < spawnCount; i++) {
    const eid = addEntity(world)
    addComponent(world, eid, Projectile)
    Position.x[eid] = spawnQueue[i * 2]
    Position.y[eid] = spawnQueue[i * 2 + 1]
  }
}

Parallel Processing

Split query results across multiple workers for maximum throughput:

parallel.ts
const WORKER_COUNT = navigator.hardwareConcurrency || 4

// Create worker pool
const workers = Array(WORKER_COUNT)
  .fill(null)
  .map(() => new Worker('./physics-worker.js'))

// Track completion
let completedWorkers = 0
const commandQueues: Uint32Array[] = []

workers.forEach((worker, index) => {
  worker.onmessage = ({ data }) => {
    commandQueues[index] = data.removeQueue
    completedWorkers++

    // All workers done - process results
    if (completedWorkers === WORKER_COUNT) {
      processCommandQueues(commandQueues)
      completedWorkers = 0
      commandQueues.length = 0
    }
  }
})

// Distribute entities across workers
function processParallel(world: World) {
  const entities = query(world, [Position], asBuffer)
  const partitionSize = Math.ceil(entities.length / WORKER_COUNT)

  for (let i = 0; i < WORKER_COUNT; i++) {
    const start = i * partitionSize
    const end = Math.min(start + partitionSize, entities.length)
    const partition = entities.subarray(start, end)

    workers[i].postMessage({
      entities: partition,
      components: world.components
    })
  }
}

function processCommandQueues(queues: Uint32Array[]) {
  for (const queue of queues) {
    for (let i = 0; i < queue.length; i++) {
      removeEntity(world, queue[i])
    }
  }
}

Common Patterns

Physics Worker

Offload physics calculations to a dedicated worker:

// physics-worker.ts
self.onmessage = ({ data }) => {
  const { entities, Position, Velocity, dt } = data

  for (let i = 0; i < entities.length; i++) {
    const eid = entities[i]

    // Apply velocity
    Position.x[eid] += Velocity.x[eid] * dt
    Position.y[eid] += Velocity.y[eid] * dt

    // Apply gravity
    Velocity.y[eid] += 9.8 * dt

    // Simple ground collision
    if (Position.y[eid] > 500) {
      Position.y[eid] = 500
      Velocity.y[eid] *= -0.8  // Bounce
    }
  }

  self.postMessage({ done: true })
}

AI Worker Pool

Distribute AI calculations across multiple workers:

// ai-worker.ts
self.onmessage = ({ data }) => {
  const { entities, Position, Target, moveQueue } = data
  let moveCount = 0

  for (let i = 0; i < entities.length; i++) {
    const eid = entities[i]

    // Pathfinding / decision making
    const dx = Target.x[eid] - Position.x[eid]
    const dy = Target.y[eid] - Position.y[eid]
    const dist = Math.sqrt(dx * dx + dy * dy)

    if (dist > 1) {
      // Queue movement command
      moveQueue[moveCount * 3] = eid
      moveQueue[moveCount * 3 + 1] = dx / dist
      moveQueue[moveCount * 3 + 2] = dy / dist
      moveCount++
    }
  }

  self.postMessage({ moveCount })
}

Render Preparation

Prepare render data in worker, draw on main thread:

// render-worker.ts
self.onmessage = ({ data }) => {
  const { entities, Position, Rotation, Scale, renderBuffer } = data

  for (let i = 0; i < entities.length; i++) {
    const eid = entities[i]
    const offset = i * 4

    // Pre-compute transform data
    renderBuffer[offset] = Position.x[eid]
    renderBuffer[offset + 1] = Position.y[eid]
    renderBuffer[offset + 2] = Rotation.angle[eid]
    renderBuffer[offset + 3] = Scale.value[eid]
  }

  self.postMessage({ count: entities.length })
}

// Main thread
worker.onmessage = ({ data }) => {
  const { count } = data
  // renderBuffer is already populated - just draw!
  for (let i = 0; i < count; i++) {
    const x = renderBuffer[i * 4]
    const y = renderBuffer[i * 4 + 1]
    const rotation = renderBuffer[i * 4 + 2]
    const scale = renderBuffer[i * 4 + 3]
    drawSprite(x, y, rotation, scale)
  }
}

Best Practices

Avoid Data Races

Ensure workers operate on independent entity sets to prevent simultaneous reads and writes to the same data.

Batch Commands

Collect structural changes in queues and process them in batches on the main thread to reduce synchronization overhead.

Partition Wisely

Split entities evenly across workers. Consider cache locality - contiguous ranges often perform better than interleaved access.

Minimize Transfers

Share SAB references once at startup. Only transfer command queues and small metadata between frames.
!
When to Multithread
Multithreading adds complexity. Only use it when you have:
  • Tens of thousands of entities or more
  • CPU-intensive systems (physics, pathfinding, AI)
  • Measured performance bottlenecks
Profile first, then optimize!
OperationThreadNotes
addEntity / removeEntityMain onlyStructural change
addComponent / removeComponentMain onlyStructural change
Component data read/writeAnyWith SAB-backed arrays
query()Main onlyReturns SAB-backed buffer
Iterate entitiesAnyUse partitioned ranges

Next Steps

Continue Learning