On this page
  1. Save games
  2. Binary and table formats
  3. Snapshot contents
    1. Durable world state
    2. Runtime state
  4. Component durability
    1. Transient components
    2. Derived entities
  5. Save selection and custom data
  6. Load lifecycle
  7. Snapshot handlers
  8. Engine subsystem state
  9. Saving files
  10. Binary snapshot layout
  11. Table snapshot layout

Save games

Snapshots carry durable world state between processes. Use them for save games, checkpoints, replay buffers, and hot reload.

local save = world:saveSnapshot().buffer
world:loadSnapshot(save)

Binary and table formats

saveSnapshot writes binary data by default. The binary format uses a LuaJIT string.buffer and copies dense FFI columns in bulk. Use it for shipped saves.

The table format returns plain Lua data. Use it for inspection, migrations, and tools:

local snapshot = world:saveSnapshot({format = "table"}).snapshot
snapshot.data[#snapshot.data + 1] = {
    key = "mygame.migrated",
    value = true,
}
world:loadSnapshot(snapshot)

Table saves accept the same selection options as binary saves. They reject buffer and path. Encoding a table as JSON through tecs.data costs more allocations and per-component work than the binary format.

Snapshot contents

Durable world state

A snapshot records:

  • entity IDs, including their slot and generation
  • components and relationship targets
  • the complete state stack
  • the fixed-step accumulator and per-phase enable flags
  • custom data

Load rebuilds the EntityKey index after it restores the entities. A same-world load therefore preserves entity handles whose saved IDs still exist.

Create every named state before loading a snapshot that uses it:

world:createState("menu")
world:createState("playing")
world:loadSnapshot(save)

Load raises when the snapshot names an unregistered state. Save also raises when the world contains a non-exclusive sparse relationship unless its component declares transient = true. A non-exclusive sparse store can hold several targets for one source, while a snapshot component row holds one value.

Runtime state

Snapshots omit:

  • world.resources
  • GPU buffers and device handles
  • audio voices and playback positions
  • open files and worker threads
  • futures and native work in flight
  • Lua locals, closures, and entity-address observers

A Future is never a component field, and the omission above is what that rule buys. A future holds listeners and the source that settles them, so the binary encoder walks a cyclic graph of runtime state rather than refusing the component: the save fails with too deep to serialize and names nothing. Keep the future in a table beside the world and give the entity a transient marker, which is what tecs.io.http does with Pending.

A sequence cursor waiting on a future saves the provider name, entity, and key. Load restores the cursor without restoring the future. isPending then returns false, and the cursor resumes on the next fixed step. Reissue and retrack the work when the wait must continue.

Load replaces the world in place and despawns nothing, so a subsystem holding work for an entity never hears that the entity is gone. Cancel or reissue from FinishSnapshotLoad rather than from OnDespawn.

Keep durable input in components. Recreate process-local objects from that input after load.

Component durability

Dense FFI components normally cross the binary format as raw columns. Table components use their default serializer. A component can instead provide serialize and deserialize callbacks. See Component serialization for the registration API.

Custom codecs must turn process-local numbers into durable names:

  • tecs.gfx.Sprite saves the image name instead of its intern index.
  • tecs.gfx.animation.Animation saves sheet and tag names, and the phase in the cycle rather than the frame. Load clears the frame field, so the next update encodes the playback the sprite carries from that phase.
  • tecs.audio.Sound saves the clip path and group name. Load starts a new voice instead of restoring playback progress.
  • tecs.gfx.Text saves authored fields and the font name. Load resolves only fonts whose newTTF future already returned under that name; a missing font produces no layout.
  • tecs.physics.RigidBody saves no component value. The physics snapshot handler stores Rapier's complete versioned state under "tecs.physics" and reconnects body and collider handles by entity.

An FFI component also stores a schema fingerprint. When the saved and current fingerprints match, load copies the column in bulk. Otherwise load maps fields by name into the current schema. New fields start at zero, removed fields disappear, and LuaJIT converts numeric types. Renaming a field discards the saved value.

Transient components

Declare process-local backing data as transient when the entity itself belongs in the save:

local record PathCache is tecs.ecs.Component
    nodeCount: integer
    cursor: integer
end

local PathCacheComponent = tecs.ecs.newFFIComponent({
    name = "PathCache",
    container = PathCache,
    fields = {
        {"nodeCount", "int32_t"},
        {"cursor", "int32_t"},
    },
    transient = true,
})

Save omits the transient column. Load applies normal spawn behavior, including requires defaults. Component registration rejects the combination of transient = true and a custom serializer.

Derived entities

A subsystem can exclude fully derived entities during OnSnapshotSave:

world:observe(
    0,
    tecs.ecs.OnSnapshotSave,
    function(ev: tecs.ecs.OnSnapshotSave) ev:exclude(TileInstance) end
)

The snapshot omits every entity that carries TileInstance. The owning subsystem must recreate those entities from durable input after load.

Save selection and custom data

saveSnapshot accepts a reusable binary buffer, a destination path, a query, layer selection, and custom data:

local buffer = require("string.buffer")
local replayBuffer = buffer.new()

local result = world:saveSnapshot({
    buffer = replayBuffer,
    path = tecs.io.files.writablePath("checkpoint.bin"),
    filterQuery = {include = {Persist}},
    layers = {2, 3},
    customData = {
        build = "v12",
        checkpoint = {level = "intro", elapsed = 42.5},
    },
})

local bytes = result.buffer

The world resets a supplied buffer before writing. A path writes the same binary bytes and still returns the tagged result. The world clones filterQuery, so repeated saves never mutate the caller's descriptor.

layers accepts values from 0 through 31. It rejects an entity with Transform2D when the component's layer falls outside the allowlist. It keeps an entity without Transform2D.

Custom-data values must support string.buffer encoding. Prefix your keys with the game or subsystem name. Tecs reserves keys that begin with __tecs. for the state stack and pipeline state.

Load lifecycle

loadSnapshot accepts a Lua string, string.buffer, snapshot table, or tagged save result. It clears and repopulates the world in place. It does not emit OnDespawn for the replaced entities. A format-version mismatch raises.

Use EntityKey to rediscover important entities:

local player = world:spawn(tecs.ecs.EntityKey("player"), Player())

local save = world:saveSnapshot().buffer
world:loadSnapshot(save)
player = world:requireKey("player")

Global observers registered at address 0 survive a same-world load because the world keeps its systems and event bus. A fresh process must register those observers during setup. Load clears entity-address observers with the discarded entity set.

Store durable per-entity behavior as component data. Let a query or global observer interpret that data and install any runtime callbacks.

Snapshot handlers

Register a named handler for durable state that lives outside components:

local player: tecs.ecs.Entity = 0

world:addSnapshotHandler({
    name = "mygame.session",
    save = function(_world: tecs.World): any
        return {
            difficulty = session.difficulty,
            rng = rng:save(),
        }
    end,
    load = function(_world: tecs.World, value: any)
        session.difficulty = value.difficulty
        rng:load(value.rng)
    end,
    finish = function(
        loadedWorld: tecs.World, _prelude: tecs.ecs.SnapshotPrelude
    )
        player = loadedWorld:requireKey("player")
    end,
})

The caller supplies any combination of save, load, and finish, plus a nonempty name. save returns one encodable value for that name. A nil result writes no value. Load restores the ECS first, then calls matching load callbacks, then calls every finish callback.

addSnapshotHandler builds on three global events:

  • OnSnapshotSave fires before the archetype walk. Tecs owns addData and exclude; an observer may call these functions but must not replace them.
  • StartSnapshotLoad fires after ECS restoration and before data dispatch. Tecs owns onData; an observer may call it to register callbacks by key.
  • FinishSnapshotLoad fires after every data callback. Tecs owns its prelude; observers may read it but must not replace it.

The prelude reports the format version and entity, archetype, and component table information. Use handlers unless a subsystem needs the lower-level event ordering.

Engine subsystem state

Engine plugins register their own snapshot behavior:

  • tecs.ecs.random stores the world seed and every named stream under "tecs.random". The first random.stream or random.seed call installs the handler. Seed during world setup so a load cannot encounter the saved value before the handler exists.
  • Audio stores master and group gain, mute, and pause settings under "tecs.audio". It does not store keyed limits or voice progress. Configure limits during setup.
  • The sequence plugin stores its runtime under "tecs.sequence".
  • The text plugin discards cached glyph runs after load and derives them again.
  • The HTTP plugin saves the Request and not the transient Pending marker, so a request that was in flight is sent again after load. It stops the transfers the load replaced, because the entities waiting on them are gone.
  • Physics stores Rapier's complete state under "tecs.physics" and reconnects transient handles. physics.hasBody reports whether an entity has a live body after reconnection.

Saving files

path provides the shortest binary save:

local path = tecs.io.files.writablePath("save.bin")
world:saveSnapshot({path = path})
world:loadSnapshot(tecs.io.files.read(path))

To transform the bytes first, write the returned buffer:

local path = tecs.io.files.writablePath("save.bin")
local bytes = tostring(world:saveSnapshot().buffer)
tecs.io.files.write(path, bytes)

tecs.io.files.writablePath resolves a path inside the application's writable directory and works on targets where stdio cannot reach platform storage.

Binary snapshot layout

The binary format uses one component table and column-major storage:

  • Archetypes refer to component names and schemas by table index.
  • Dense FFI columns use one raw copy per column.
  • Entity IDs use raw doubles, which retain the packed 22-bit slot and 31-bit generation.
  • Custom codecs encode one value per row.
  • Archetypes with a serializable sparse relationship use row-major data and a presence mask.

The wire format follows this sequence:

prelude:
    encode(version)
    encode(nextEntityId)
    encode(entityCount)
    encode(archetypeCount)
    encode(componentCount)
    per component:
        encode(name)
        encode(fingerprint)       # empty for non-FFI components

per archetype:
    encode(columnCount)
    encode(entityCount)
    per column: encode(componentTableIndex)
    encode(mode)                  # 0 column-major, 1 row-major

    mode 0:
        putcdata(entityIds, entityCount * 8)
        per column:
            putcdata(column, structSize * entityCount)
            OR per row: encode(serializedValue)

    mode 1:
        per row:
            encode(entityId)
            encode(presenceMask)
            per present column: encode(serializedValue)

data:
    repeat: encode(true); encode(key); encode(value)
    encode(false)

Row-major presence masks use exact double arithmetic and support at most 52 columns.

Table snapshot layout

The table writer produces this general shape:

{
    version = 1,
    nextEntityId = 42,
    componentTable = {
        {name = "Position"},
        {name = "Health"},
    },
    archetypes = {
        {
            columnIndices = {1, 2},
            entities = {
                {1, {x = 10, y = 20}, {hp = 100}},
                {2, {x = 30, y = 40}, {hp = 50}},
            },
        },
    },
    data = {
        {key = "build", value = "v12"},
    },
}
Each entity row aligns its component values with the archetype's columnIndices. Table load always calls each component's deserializer, so the table writer omits schema fingerprints.