Schemas
A schema is a serialization node. Primitive nodes are ready to use, while constructors such as Struct, DeltaStruct, Array, Map, and Optional return composed nodes.
local Types = VoidSentryUltimate.Types
local Item = Types.Struct({
Count = Types.U16,
Id = Types.U32,
Note = Types.Optional(Types.String8),
})
local Inventory = Types.Array8(Item)
The schema is required for both serialization and deserialization:
local bytes = VoidSentryUltimate.Serialize(Inventory, items)
local copy = VoidSentryUltimate.Deserialize(Inventory, bytes)
No schema identifier is written to bytes. Changing a schema changes its wire format; old data must continue to use its old schema or be migrated explicitly.
VoidSentryUltimate.Schema
For multi-field tables, VoidSentryUltimate.Schema(fields) returns a schema object with dedicated methods:
local Player = VoidSentryUltimate.Schema({
Health = Types.U8,
Name = Types.String8,
})
local bytes = Player.Serialize({ Health = 100, Name = "Ada" })
local copy = Player.Deserialize(bytes)
Types.Struct remains available as a SerdesNode for use with Serialize / Deserialize on a single node. Schema is now the preferred API.
Migrating old buffers
Migrate decodes a buffer with an old schema and writes it again with a new one. It does not embed a version. The caller still has to know which schema produced oldBytes.
For schema objects, call Migrate on the new schema and pass the old schema first:
local PlayerV1 = VoidSentryUltimate.Schema({
Health = Types.U8,
Name = Types.String8,
})
local PlayerV2 = VoidSentryUltimate.Schema({
Health = Types.U16,
Name = Types.String8,
})
local migrated = PlayerV2.Migrate(PlayerV1, oldBytes)
local copy = PlayerV2.Deserialize(migrated)
OldOffset and NewOffset are optional byte offsets. OldOffset is where the old payload starts. NewOffset is where the new payload is written; the returned buffer includes that leading gap.
The same idea exists for bare nodes:
local migrated = VoidSentryUltimate.Migrate(OldStruct, NewStruct, oldBytes)
Here the first argument is the old node and the second is the new node. This path is meant for Struct nodes. Other nodes can work when the decoded value is still valid input for the new node.
Decoded fields are written with the new schema’s field writers. A renamed or added field is not filled in for you: every field the new schema writes must already be present on the decoded table, or the new field node must accept the missing value. Changing a field’s node is safe when the old decoded value is still a valid input for the new node, such as widening U8 health to U16.
Struct
Types.Struct(fields) serializes every named field with its node. At schema creation, the implementation collects field names and sorts them. Serialization and deserialization use that sorted key order, not table iteration order or the visual order in the schema literal.
local Transform = Types.Struct({
Rotation = Types.Vector,
Position = Types.Vector,
})
For this schema, Position is written before Rotation because the keys are sorted lexicographically. Both ends still need the same field names and field nodes. Do not treat Lua table construction order as a wire-format contract, and do not infer broader deterministic cross-process behavior beyond the implementation's sorted string keys.
Delta structs
DeltaStruct and DeltaStruct16 serialize only keys present in the input table. Serialization walks the input (not the full schema), looks up each key’s field id, and writes that field. Deserialization returns a sparse table containing only the fields that were present on the wire.
At schema creation, field names are collected and sorted lexicographically to assign stable 1-based field ids. Entry order on the wire follows Luau table iteration order of the input, not sorted key order.
DeltaStruct(fields): 1-byte present-count and 1-byte field ids (max 255 present fields).DeltaStruct16(fields): 2-byte present-count and 2-byte field ids (max 65,535 present fields).
local PlayerDelta = Types.DeltaStruct({
Health = Types.U8,
Name = Types.String8,
Score = Types.U32,
})
-- Only Health and Score are encoded
local bytes = VoidSentryUltimate.Serialize(PlayerDelta, {
Health = 100,
Score = 42,
})
Omit keys you do not want to send. Nesting Optional inside a delta struct is usually redundant: presence is decided by whether the key exists in the input.
Unknown keys are not validated on the hot path; they will fail when the serializer looks up the field id.
Arrays
Array(node)writes a 2-byte unsigned element count, then the elements.Array8(node)writes a 1-byte count.Array24(node)writes a 3-byte count.ArrayFixed(node, length)writes no count prefix.
local RecentScores = Types.ArrayFixed(Types.U16, 3)
local bytes = VoidSentryUltimate.Serialize(RecentScores, { 10, 20, 30 })
For a fixed array, the caller must supply exactly the declared number of elements. The serializer iterates the provided array without validating its length, while the deserializer always reads the declared length. A mismatch shifts or truncates the surrounding wire layout.
Maps
Map(keyNode, valueNode)writes a 2-byte entry count.Map8(keyNode, valueNode)writes a 1-byte count.Map24(keyNode, valueNode)writes a 3-byte count.MapFixed(keyNode, valueNode, length)writes no count prefix.
local ScoresByUser = Types.Map(Types.U32, Types.U16)
Map entries are emitted in Luau table iteration order. The decoder reconstructs the same key/value pairs, but the encoded byte sequence is not promised to be stable across separately constructed maps or processes. If stable bytes matter for hashing or signing, serialize a canonically sorted array of entries instead.
For MapFixed, the caller must provide exactly the declared entry count. The serializer does not enforce it; the deserializer always reads that many entries.
Optional values
Optional(node) writes a 1-byte presence marker. A present value follows immediately after the marker.
local MaybeOwner = Types.Optional(Types.U32)
local absent = VoidSentryUltimate.Serialize(MaybeOwner, nil)
local present = VoidSentryUltimate.Serialize(MaybeOwner, 42)
Strings and buffers
String lengths are byte lengths because the implementation uses Luau's #string, including for UTF-8 text. They are not character or code-point counts.
StringandBufferuse a 2-byte length prefix.String8andBuffer8use a 1-byte length prefix.String24andBuffer24use a 3-byte length prefix.StringFixed(length)andBufferFixed(length)have no length prefix.
Fixed strings and buffers require exactly the configured number of bytes from the caller. The library does not validate that requirement.
Compatibility checklist
- Keep the same schema, including nested nodes, on every endpoint.
- Keep field names unchanged in structs; names determine sorted wire order.
- Version schemas when persisted data or network peers can outlive a deployment.
- Treat prefix limits as hard input constraints because the library does not check them.
- Do not deserialize untrusted or malformed data without an outer validation strategy.