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

Raw Structure of Arrays data transfer. Best for network replication.

AoS Serializer

Object-like storage patterns. Works with per-entity objects.

Observer Serializer

Track entity/component additions and removals. Combine with SoA.

Snapshot Serializer

Complete state capture. Save games, debugging, full sync.

SoA Serialization

The SoA serializer works directly with Structure of Arrays data. It's independent of bitECS and optimized for efficient binary transfer:

soa-basic.ts
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)
Why Binary?

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:

TagBytesDescription
u8(), i8()18-bit unsigned/signed integers
u16(), i16()216-bit unsigned/signed integers
u32(), i32()432-bit unsigned/signed integers
f32()432-bit floats
f64()864-bit floats (default)
str()variableUTF-8 strings
type-tags.ts
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:

strings.ts
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:

OptionDefaultDescription
difffalseOnly serialize changed values (uses shadow copy)
buffer100MBPre-allocated backing ArrayBuffer
epsilon0.0001Float comparison threshold for diff mode
options.ts
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
})
!
Diff Mode
Diff mode tracks changes internally and only serializes modified values. This dramatically reduces packet size for frequently-updated data.

AoS Serialization

The AoS serializer works with components that store objects per entity. Same API and options as SoA:

aos.ts
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:

observer.ts
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))
i
Complete Network Sync
For full networking, combine Observer (for entity/component presence) with SoA (for actual data). Observer handles the "what exists", SoA handles the "what values".
network-sync.ts
// 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 values

Snapshot Serialization

Snapshot captures complete world state at a point in time. Perfect for save games, debugging, and full state sync:

snapshot.ts
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:

id-mapping.ts
// 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

Server and client have different entity ID spaces. Map on receive.

Save Games

Regenerate entity IDs when loading. Map old IDs to new ones.

World Transfer

Copy entities between worlds with different ID spaces.

Hot Reload

Preserve entity references across code reloads during development.

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 bytes

Save 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)
  }
}

Next Steps

Continue Learning