Snapshot Schemas

Fast-paced games send the same state many times a second: every player, the ball, the clock. The compact way to send it is a positional row, a float[] that MessagePack writes as a short array with no names. The cost is that the server and every client have to agree on what sits at each position. Written by hand, adding one field means editing the writer, the packet, the client decoder and the client type, in two languages.

A snapshot schema declares each row once in C#. The same declaration:

  • compiles into the server's encoder (no reflection, no boxing, no allocation when you pass a buffer);
  • exports to a JSON document that you commit next to the client;
  • drives the client's decoders in TypeScript (@altruist/net), so the client never indexes rows by hand.

The bytes on the wire don't change. A schema writes the same floats as hand-written casts, and the packet stays an ordinary MessagePack [Key] class.

When to Use It

You sendUse
Numeric state of many objects, every tick (players, projectiles, a ball, a match clock)Snapshot schemas (this page)
Rare, mixed-type messages (names, lists, nested objects)A plain packet with [Key] members
World entities where only changed properties should travelEntity Synchronization ([Synced] deltas)

Snapshot schemas fit match rooms: call the encoders in CaptureSnapshot and keep PersonalizeSnapshot for per-receiver values such as the input ack.

Declaring a Row

SnapshotRowSchema.For<T>(name) starts a row. Every call adds the next position. A value can be any expression over the source object, so computed values like a physics body's position work too.

using Altruist.Networking;

public static class Wire
{
    public static readonly SnapshotRowSchema<Ship> ShipRow = SnapshotRowSchema.For<Ship>("ship")
        .Int("id", s => s.Id)
        .Enum("team", s => s.Team)                   // labels: camelCase enum names by value
        .Number("x", s => s.Body.Position.X)
        .Number("y", s => s.Body.Position.Y)
        .Bool("shielded", s => s.Shielded)           // 1 / 0
        .Int("kills", s => s.Kills, whenMissing: 0)  // added in a later release
        .Build();
}
Builder methodWire valueA decoder produces
Number(name, s => float)the floatthe number
Int(name, s => int)(float)value, exact up to ±2^24the number
Bool(name, s => bool)1 / 0true for 1 or true
Enum(name, s => TEnum, labels?, fallback?)the enum's integral valuelabels[value], else fallback, else undefined

Build() compiles every value expression into one writer delegate. Build each schema once, in a static readonly field.

From attributes

If the state class holds the values as plain members, you can put the declaration on the members instead:

public sealed class Ship
{
    [SnapshotField(0)] public int Id;
    [SnapshotField(1)] public Team Team;
    [SnapshotField(2, Name = "hot")] public float Heat;
    [SnapshotField(3, WhenMissing = 0)] public int Kills;
}

var shipRow = SnapshotRowSchema.FromAttributes<Ship>("ship");

The kind comes from the member's type: bool becomes Bool, an enum becomes Enum, an integral type becomes Int, and float / double become Number. Indexes must run 0..n-1 with no gaps. Use For<T> instead when values are computed, when you need several rows from one type, or when the state type lives in a project that should not reference networking.

Note:

[SnapshotField] has nothing to do with [Synced]. [Synced] drives the named, change-tracked entity sync. [SnapshotField] only declares slots of a positional row.

Encoding

float[] row = Wire.ShipRow.Encode(ship);         // allocates exactly the row
Wire.ShipRow.Encode(ship, buffer, offset);       // allocation-free, e.g. a pooled or packed buffer

Use Encode(source) when the row has to outlive the call. A snapshot shared by every receiver and encoded later by the outbox is the usual case. Encoding only reads the source, so the same state always produces the same bits.

Packets That Carry Rows

The packet stays a normal MessagePack class. [WireField] only adds what a decoder needs to know, and MessagePack encodes the class exactly as before:

[MessagePackObject]
public class StatePacket : IPacketBase
{
    [Key(0)] public uint MessageCode { get; set; } = 3001;
    [Key(1)] public int Tick { get; set; }
    [Key(2), WireField(Row = "clock")] public float[] Clock { get; set; } = [];
    [Key(3), WireField(Rows = "ship")] public float[][] Ships { get; set; } = [];
    [Key(4), WireField(WhenMissing = new float[0])] public float[] Heat { get; set; } = [];
    [Key(5), WireField(WhenMissing = 0)] public int Wave { get; set; }
}
WireField propertyMeaning
Row = "name"The member is one row (float[]) of that schema.
Rows = "name"The member is an array of rows (float[][]).
WhenMissing = …Value a decoder uses when an older server's packet has no such key. Unset means the raw value (undefined) passes through.
Name = "…"Wire name. Defaults to the camelCase member name.

[Key] members without [WireField] export as plain values under their camelCase names. MessageCode becomes the packet's code.

Exporting the Schema

Collect the rows and packets in a SnapshotSchemaSet and commit its JSON. A test keeps the file current, much like a golden file:

public static readonly SnapshotSchemaSet Document = new SnapshotSchemaSet()
    .Add(Wire.ClockRow)
    .Add(Wire.ShipRow)
    .AddPacket<StatePacket>("state");

[Fact]
public void Snapshot_schema_file_is_current()
{
    var path = Path.Combine(RepoRoot, "shared", "protocol", "snapshot-schema.json");
    if (Environment.GetEnvironmentVariable("SCHEMA_UPDATE") == "1") { Document.Write(path); return; }
    Assert.True(Document.IsCurrent(path), "snapshot-schema.json is stale: rerun with SCHEMA_UPDATE=1");
}

The document is deterministic, with one field per line so it diffs cleanly:

{
  "format": "altruist.snapshot-schema/1",
  "rows": {
    "ship": {
      "length": 6,
      "fields": [
        {"name":"id","index":0,"kind":"int"},
        {"name":"team","index":1,"kind":"enum","labels":["red","blue"]},
        {"name":"kills","index":5,"kind":"int","whenMissing":0}
      ]
    }
  },
  "packets": {
    "state": {
      "code": 3001,
      "fields": [
        {"name":"tick","index":1,"kind":"value"},
        {"name":"ships","index":3,"kind":"rows","row":"ship"},
        {"name":"wave","index":5,"kind":"value","whenMissing":0}
      ]
    }
  }
}

Decoding in TypeScript (@altruist/net)

@altruist/net (Clients/TypeScript/net) has no dependencies and ships as TypeScript sources, like @altruist/sim2d. Load the document once and take decoders from it:

import { SnapshotSchema, type SnapshotSchemaDocument } from '@altruist/net';
import doc from '../shared/protocol/snapshot-schema.json';

const schema = new SnapshotSchema(doc as SnapshotSchemaDocument); // validated once
const decodeState = schema.packet<StatePacket>('state');          // rows inside are decoded too
const decodeShip = schema.row<ShipState>('ship');

// after MessagePack-decoding the envelope [code, header, packetArray]:
const state = decodeState(packetArray);
APIUse
new SnapshotSchema(doc)Validates the document and caches its decoders. Make one per document, at module level.
schema.packet<T>(name)Decodes a packet array (index 0, the code, is skipped), including its row / rows members.
schema.row<T>(name) / rowDecoder<T>(rowSchema)Decodes a single row. Build the decoder once. Each call allocates only the result object.
decodeRow(row, rowSchema)One-off decode, for tools and tests.
rowEncoder<T>(rowSchema)Object to row, for tests and TypeScript servers.
missingFields(schema, keys)Lists wire fields a client type lacks. See below.

Decoding is a pure function of its input, so it is safe in deterministic replays. An optional field gets its whenMissing value when the row is shorter or the slot holds nil. A required field passes through as is.

Keeping client types in sync

T is not checked at run time. To make a type mismatch fail a test, compare an exhaustive key record against the schema:

const SHIP: Record<keyof ShipState, true> = { id: true, team: true, x: true, y: true, shielded: true, kills: true };
expect(missingFields(schema.rowSchema('ship'), Object.keys(SHIP))).toEqual([]);

TypeScript rejects the literal when a property is added to or removed from ShipState. The assertion catches a wire field that the type lacks.

Changing a Schema

  1. Add a field at the end of its row with a whenMissing value, so rows from older servers still decode. After the first optional field, every later field must be optional too. Build() enforces this.
  2. Regenerate the JSON (your update switch) and commit it.
  3. Add the property to the client type. The key-record test fails until you do.

Never reorder or remove fields, because indexes are the wire contract. To retire a field, keep writing a constant in its slot.

Note:

Snapshot schemas send full rows. Delta encoding against the client's acknowledged snapshot (send only the slots that changed since the acked tick) would build on the same declarations, but it isn't part of the API yet.