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
Buffer Queries
asBuffer returns SAB-backed Uint32Array for parallel iteration.Command Queues
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:
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 }
})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:
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 Type | Returns | Use Case |
|---|---|---|
| Regular | readonly number[] | Single-threaded iteration |
| Buffered | Uint32Array (SAB) | Multi-threaded, transferable |
Worker Setup
Set up workers to receive and process shared data:
// 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 - 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:
// 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 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:
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
Batch Commands
Partition Wisely
Minimize Transfers
- Tens of thousands of entities or more
- CPU-intensive systems (physics, pathfinding, AI)
- Measured performance bottlenecks
| Operation | Thread | Notes |
|---|---|---|
| addEntity / removeEntity | Main only | Structural change |
| addComponent / removeComponent | Main only | Structural change |
| Component data read/write | Any | With SAB-backed arrays |
| query() | Main only | Returns SAB-backed buffer |
| Iterate entities | Any | Use partitioned ranges |