
# Save games

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

```teal
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`](https://luajit.org/ext_buffer.html#serialize) 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:

```teal
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](/modules/ecs/states)
- the fixed-step accumulator and per-phase enable flags
- custom data

Load rebuilds the [`EntityKey`](/modules/ecs/builtins#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:

```teal
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`](/modules/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`](/modules/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`](/modules/ecs/builtins#snapshot-events) 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](/modules/ecs/components/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:

```teal
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`](/modules/ecs/builtins#snapshot-events):

```teal
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:

```teal
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:

```teal
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:

```teal
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:

```teal
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:

```teal
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:

```text
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:

```teal
{
    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.
