Serialization
Efficient binary serialization for networking and persistence.
Overview
Serialization in bitECS is fully decoupled from the core library. Import serializers from bitecs/serialization. Choose the right serializer for your use case:
SoA Serializer
AoS Serializer
Observer Serializer
Snapshot Serializer
SoA Serialization
The SoA serializer works directly with Structure of Arrays data. It's independent of bitECS and optimized for efficient binary transfer:
import { createSoASerializer, createSoADeserializer, f32 } from 'bitecs/serialization'
const Position = { x: f32([]), y: f32([]) }
const Velocity = { vx: f32([]), vy: f32([]) }
const Health = new Uint8Array(10000)
const components = [Position, Velocity, Health]
const serialize = createSoASerializer(components)
const deserialize = createSoADeserializer(components)
// Set data
const eid = 1
Position.x[eid] = 10.5
Position.y[eid] = 20.2
Velocity.vx[eid] = 1.3
Velocity.vy[eid] = 2.4
Health[eid] = 100
// Serialize entities (usually from a query)
const buffer = serialize([eid])
// Send buffer over network, then deserialize
deserialize(buffer)
console.assert(Position.x[eid] === 10.5)
console.assert(Health[eid] === 100)Binary serialization is typically 5-10x smaller than JSON for numeric data, and much faster to encode/decode. Perfect for real-time networking where bandwidth and latency matter.
Type Tags
Type tags tell the serializer how to encode regular arrays. TypedArrays don't need tags since their type is already known:
| Tag | Bytes | Description |
|---|---|---|
u8(), i8() | 1 | 8-bit unsigned/signed integers |
u16(), i16() | 2 | 16-bit unsigned/signed integers |
u32(), i32() | 4 | 32-bit unsigned/signed integers |
f32() | 4 | 32-bit floats |
f64() | 8 | 64-bit floats (default) |
str() | variable | UTF-8 strings |
import { f32, u8, u16, str } from 'bitecs/serialization'
// Tag regular arrays with their data type
const Position = { x: f32([]), y: f32([]) }
const Stats = { health: u8([]), mana: u16([]) }
const Meta = { name: str([]) }
// TypedArrays don't need tags
const Health = new Uint8Array(10000)
const Velocity = {
x: new Float32Array(10000),
y: new Float32Array(10000)
}Strings & Arrays
Use str() for strings and array() for nested arrays:
import { createSoASerializer, createSoADeserializer, str, array, f32, u8 } from 'bitecs/serialization'
const Meta = {
name: str([]), // Single string per entity
tags: array(str) // Array of strings per entity
}
const Inventory = {
items: array(u8), // Array of item IDs
pages: array(array(u8)) // 2D array: pages of item IDs
}
const Waypoints = {
path: array(f32) // Array of coordinates
}
const components = [Meta, Inventory, Waypoints]
const serialize = createSoASerializer(components)
const deserialize = createSoADeserializer(components)
const eid = 1
Meta.name[eid] = 'Player_One'
Meta.tags[eid] = ['warrior', 'guild_leader']
Inventory.items[eid] = [1, 5, 10, 15]
Inventory.pages[eid] = [[1, 2, 3], [10, 20], [100]]
Waypoints.path[eid] = [10.5, 20.2, 30.8, 40.1]
const buffer = serialize([eid])
deserialize(buffer)Options
Both serializer and deserializer accept options:
| Option | Default | Description |
|---|---|---|
diff | false | Only serialize changed values (uses shadow copy) |
buffer | 100MB | Pre-allocated backing ArrayBuffer |
epsilon | 0.0001 | Float comparison threshold for diff mode |
import { createSoASerializer, createSoADeserializer, f32 } from 'bitecs/serialization'
const Position = { x: f32([]), y: f32([]) }
// Diff mode - only sends changed values
const serialize = createSoASerializer([Position], {
diff: true,
buffer: new ArrayBuffer(1 << 20), // 1MB
epsilon: 1e-3
})
const deserialize = createSoADeserializer([Position], {
diff: true
})AoS Serialization
The AoS serializer works with components that store objects per entity. Same API and options as SoA:
import { createAoSSerializer, createAoSDeserializer, f32, u8, str, array } from 'bitecs/serialization'
// AoS components - objects at each entity index
const Position = Object.assign([], { x: f32(), y: f32() })
const Health = u8()
const Meta = Object.assign([], { name: str(), tags: array(str) })
const components = [Position, Health, Meta]
const serialize = createAoSSerializer(components, { diff: true })
const deserialize = createAoSDeserializer(components, { diff: true })
// Set data as objects
Position[0] = { x: 10.5, y: 20.2 }
Health[0] = 100
Meta[0] = { name: 'Player', tags: ['hero', 'leader'] }
const buffer = serialize([0, 1])
// ID mapping supported
const idMap = new Map([[0, 10], [1, 11]])
deserialize(buffer, idMap)Observer Serialization
The Observer serializer tracks entity and component additions/removals. Unlike SoA/AoS, it depends on bitECS. Use it with SoA for full network sync:
import { createWorld, addEntity, addComponent, removeComponent, hasComponent } from 'bitecs'
import { createObserverSerializer, createObserverDeserializer } from 'bitecs/serialization'
const world = createWorld()
const Position = { x: [] as number[], y: [] as number[] }
const Health = [] as number[]
const Networked = {} // Tag component for network entities
// Create serializers with network tag
const serialize = createObserverSerializer(world, Networked, [Position, Health])
const deserialize = createObserverDeserializer(world, Networked, [Position, Health])
const eid = addEntity(world)
// Mark entity for networking and add components
addComponent(world, eid, Networked)
addComponent(world, eid, Position)
addComponent(world, eid, Health)
// Serialize tracks additions
const buffer = serialize()
// On receiving end, deserialize restores structure
removeComponent(world, eid, Position)
removeComponent(world, eid, Health)
deserialize(buffer)
console.assert(hasComponent(world, eid, Position))
console.assert(hasComponent(world, eid, Health))// Server-side: serialize both structure and data
const structureBuffer = observerSerializer()
const dataBuffer = soaSerializer(query(world, [Networked]))
// Client-side: deserialize in order
observerDeserializer(structureBuffer) // Add/remove entities & components
soaDeserializer(dataBuffer) // Update component valuesSnapshot Serialization
Snapshot captures complete world state at a point in time. Perfect for save games, debugging, and full state sync:
import { createWorld, addEntity, addComponent, removeEntity, hasComponent } from 'bitecs'
import { createSnapshotSerializer, createSnapshotDeserializer, f32, u8 } from 'bitecs/serialization'
const world = createWorld()
const eid = addEntity(world)
const Position = { x: f32([]), y: f32([]) }
const Health = u8([])
const serialize = createSnapshotSerializer(world, [Position, Health])
const deserialize = createSnapshotDeserializer(world, [Position, Health])
// Set up state
addComponent(world, eid, Position)
Position.x[eid] = 10
Position.y[eid] = 20
addComponent(world, eid, Health)
Health[eid] = 100
// Capture full state
const saveData = serialize()
// Later: restore state
removeEntity(world, eid)
Position.x[eid] = 0
Position.y[eid] = 0
Health[eid] = 0
deserialize(saveData)
// State fully restored
console.assert(hasComponent(world, eid, Position))
console.assert(Position.x[eid] === 10)
console.assert(Health[eid] === 100)ID Mapping
When deserializing, you often need to map entity IDs from source to target. Common scenarios include network replication (client/server IDs differ), save game loading, and copying between worlds:
// Create a mapping: source ID -> target ID
const idMap = new Map<number, number>([
[1, 100], // Entity 1 in packet maps to entity 100 locally
[2, 101],
[3, 102],
])
// Pass to deserializer
deserialize(buffer, idMap)
// For SoA/AoS:
soaDeserialize(buffer, idMap)
aosDeserialize(buffer, idMap)
// For Observer:
const deserialize = createObserverDeserializer(world, Networked, components, {
idMap: initialMap // Optional initial map
})
deserialize(buffer, overrideMap) // Optional per-call override
// For Snapshot:
const snapshotDeserialize = createSnapshotDeserializer(world, components)
snapshotDeserialize(buffer, idMap)Network Replication
Save Games
World Transfer
Hot Reload
Common Patterns
Complete Network Sync
Combine Observer and SoA serializers for full network replication:
// Server: Create serializers
const observerSerialize = createObserverSerializer(world, Networked, components)
const soaSerialize = createSoASerializer(components)
// Server: Send updates each tick
const sendNetworkUpdate = () => {
const structurePacket = observerSerialize() // Entity/component changes
const dataPacket = soaSerialize(query(world, [Networked])) // Data updates
socket.send({ structure: structurePacket, data: dataPacket })
}
// Client: Receive and apply
socket.on('update', ({ structure, data }) => {
observerDeserialize(structure) // Apply structure changes first
soaDeserialize(data) // Then apply data
})Delta Compression
Only send changed values using diff mode:
const serialize = createSoASerializer([Position, Velocity], {
diff: true,
epsilon: 0.001 // Ignore tiny float changes
})
// First call serializes everything
let packet = serialize([1, 2, 3]) // Full data
// Subsequent calls only serialize changes
Position.x[1] += 0.5
packet = serialize([1, 2, 3]) // Only Position.x[1] changed
// If nothing changed, returns empty buffer
packet = serialize([1, 2, 3]) // Near-zero bytesSave Game System
Use snapshot for complete state persistence:
const saveComponents = [Position, Velocity, Health, Inventory, Equipment]
const serialize = createSnapshotSerializer(world, saveComponents)
const deserialize = createSnapshotDeserializer(world, saveComponents)
// Save game
const saveGame = () => {
const saveData = serialize()
localStorage.setItem('save', btoa(String.fromCharCode(...new Uint8Array(saveData))))
}
// Load game
const loadGame = () => {
const base64 = localStorage.getItem('save')
if (!base64) return false
const binary = Uint8Array.from(atob(base64), c => c.charCodeAt(0))
deserialize(binary.buffer)
return true
}Interpolation-Ready Replication
Store previous and current state for smooth rendering:
// Double-buffered state for interpolation
const PreviousPosition = { x: f32([]), y: f32([]) }
const CurrentPosition = { x: f32([]), y: f32([]) }
// On network receive
const deserialize = createSoADeserializer([CurrentPosition])
socket.on('state', (buffer) => {
// Copy current to previous before updating
for (const eid of query(world, [Networked])) {
PreviousPosition.x[eid] = CurrentPosition.x[eid]
PreviousPosition.y[eid] = CurrentPosition.y[eid]
}
deserialize(buffer)
})
// Render with interpolation
const renderSystem = (world) => {
const alpha = getInterpolationAlpha()
for (const eid of query(world, [Sprite, CurrentPosition])) {
const x = lerp(PreviousPosition.x[eid], CurrentPosition.x[eid], alpha)
const y = lerp(PreviousPosition.y[eid], CurrentPosition.y[eid], alpha)
drawSprite(eid, x, y)
}
}