VoidSentry2

Efficient SerDes Library for Roblox. Successor to VoidSentry.

What it is

Sending Luau tables or JSON over remotes is convenient but costly: payloads grow quickly and every value carries type metadata on the wire. VoidSentry2 converts the same data into a compact, typed byte layout using the Luau buffer type, so the bytes you ship match the shape of the data you actually store.

Two usage modes cover most cases. Static: you declare the exact shape once as a type node and reuse it to serialize and deserialize — smallest size, lowest CPU, predictable layout. Dynamic: you pass a variable list of values and the library tags each with a 1-byte type id so the receiver can decode them without a shared schema.

Highlights

  • Static (schema) and dynamic (tagged) serialization in one module.
  • 45+ built-in types: fixed-width integers and floats (including 16/24-bit variants), strings, vectors, CFrame (matrix and quaternion, full and compressed), Color3, EnumItem, arrays, maps, structs, optional, union, any, boolPacked, plus instance (by-reference) and serInstance (by-snapshot).
  • Optional Zstd compression through Roblox EncodingService.
  • Configurable: buffer cap, strict vs lax failure mode, dynamic inference for instances and vectors, instance-id attribute name.
  • Fully typed Luau API so generics, autocomplete, and refactors work through the call sites.
  • Manual cursor API (writeStart / writeFinish, readStart / readFinish) if you want to interleave VoidSentry2 writes with your own bytes.

Quick start

Static

--!strict
local VS = require(path.to.VoidSentry2)
local T = VS.types
local static = VS.static

local row = T.struct({
    n = T.int32,
    s = T.string,
})

local buf = static.serialize(row, { n = 42, s = "hi" }, nil, nil)
local out = static.deserialize(row, buf, nil, nil)

Dynamic

Each value you pass becomes one tagged chunk in the buffer. The receiver reads the same number of values in the same order.

local VS = require(path.to.VoidSentry2)
local dynamic = VS.dynamic

-- Example: send a name, a user id, a position, and whether sprint is on — no struct definition.
local playerId = 9001
local targetPos = Vector3.new(10, 0, -3)

local buf = dynamic.serialize(nil, nil, "MovePlayer", playerId, targetPos, true)
if not buf then return end

local name, id, pos, sprinting = dynamic.deserialize(nil, nil, buf)
-- name == "MovePlayer", id == 9001, pos == targetPos, sprinting == true

Installation

Wally

Add the dependency and run the installer.

[dependencies]
VoidSentry2 = "elentium/voidsentry2@0.1.1"

Then run wally install and require the module from your tree (for example game.ReplicatedStorage.Packages.VoidSentry2 depending on layout).

Pesde

Install with the Pesde CLI, then require the module from the path Pesde uses in your project (commonly under your packages tree).

pesde add elentium/voidsentry2

Package page

GitHub

Clone github.com/Elentium/VoidSentry2 and wire the src tree into your project with Rojo (or copy the source into ReplicatedStorage). Point require at the package entry module.

Model file (.rbxm)

A packaged /roblox/VoidSentry2.rbxm model is provided for drag-and-drop into Studio. Insert it under ReplicatedStorage (or your preferred folder) and require the ModuleScript entry.

Roblox Creator Store

The library is published as a Creator Store asset so you can add it from the toolbox.

Configuration

All switches live in src/shared/config/constants.luau. Edit a vendored or forked copy so Wally updates do not overwrite your changes.

Options

maxBufferSize

Default: 10_000_000 bytes. Effect: hard cap for the internal scratch buffer used during a single serialize call. If a payload would exceed it, serialization fails.

Change when: you know your payloads never approach this size (reduce to catch bugs earlier) or you legitimately serialize larger snapshots (raise it).

strictMode

Default: true. Effect: type mismatches, missing fields, buffer overflows, invalid input, etc. throw an error. With false the same conditions warn and serialize/deserialize return nil.

Change when: shipping to production and you want best-effort failure tolerance (network payloads, corrupted save data). Keep true during development to fail loudly.

dynamicVectorType

Default: "vector". Values: "vector" or "Vector3". Effect: when the dynamic serializer infers a vector value, it chooses the Luau vector node or the Roblox Vector3 node. Both are 12 bytes; the difference is which Luau type is produced on deserialize.

Change when: your codebase prefers one native type over the other. Ignored by the static serializer — static schemas always use whichever node you put in them.

dynamicInstanceType

Default: "instance". Values: "instance" or "serInstance". Effect: when the dynamic serializer encounters an Instance, "instance" writes its numeric id and looks the same object up on the peer; "serInstance" writes a property snapshot using the registered schema for the instance's class.

Change when: dynamic messages should carry property snapshots by default (e.g. template replication) instead of live references. Ignored by the static serializer.

instanceIdAttribute

Default: "_VSID". Effect: attribute name used by types.instance and the server/client instance-map scripts to tag and look up instances. Both sides must agree on this name.

Change when: the default collides with your own attribute names or you want a shorter name. Set once, before any instance is tagged.

unsupportedUnionExTypes

Default: { union, any, void, optional }. Effect: set of exType strings that cannot appear as a variant of T.union(...); the builder throws if you try.

Change when: almost never. These are blocked because they do not round-trip cleanly through extypeof (unions cannot distinguish them from other variants).

Internal status codes

unknownType, success, fail, eor are the numeric flags returned by reader/writer primitives. Do not change.

Related code

src/shared/config/init.luau re-exports the constants plus typeSigns for the rest of the package. Game code does not require it directly — the module is only touched when you maintain a fork of VoidSentry2.

API reference

Library properties

Top-level members of the module returned by require(VoidSentry2).

.types

Table of built-in type nodes and factories (int32, struct(...), array(T), union(...), etc.). You pass these to the static serializer. See Types for the full list.

.static

Schema-based serializer: { serialize, deserialize }. You supply a root typeNode that describes the whole payload. See Static API.

.dynamic

Schemaless serializer: { serialize, deserialize }. You pass a variable list of values; each is tagged with a 1-byte type id on the wire. See Dynamic API.

.registerInstanceSchema(className, schema)

registerInstanceSchema(className: string, schema: { [string]: typeNode }) -> ()

Declares which properties of className should be captured by types.serInstance(className). Keys are property names; values are type nodes (e.g. Name = T.string255). Call once at startup per class, before any serialize that references it.

.combine(t1, t2)

combine<T1, T2>(t1: T1, t2: T2) -> T1 & T2

Shallow merge helper. Useful for composing struct field tables at module scope. Not serialization-specific.

Manual cursor API

Low-level hooks for advanced users who want to mix VoidSentry2 writes with their own bytes, or batch several type-node writes under one buffer.

  • writeStart(offset: number?) -> () — reset the scratch buffer cursor.
  • writeFinish() -> buffer — copy bytes [0, cursor) into a new exact-sized buffer.
  • getWriteCursor() -> number — current write position.
  • readStart(buf: buffer, offset: number?) -> () — bind the reader to buf.
  • readFinish() -> () — release the current read buffer.
  • getReadCursor() -> number — current read position.

If you only call static / dynamic you never touch these.

Static API

Each static call has exactly one root type node. To send multiple logical fields, wrap them in a single struct (or array / map).

static.serialize(serdes, input, offset?, compressionLevel?) -> buffer?

<T>(serdes: typeNode<T>, input: T, offset: number?, compressionLevel: number?) -> buffer?

  • serdes — root type node describing the payload shape.
  • input — the Luau value matching serdes.
  • offset (optional, default 0) — number of bytes to reserve at the start of the returned buffer. These bytes are zero-filled; you can overwrite them with a header, magic value, or version after the call. Deserialize with the same value.
  • compressionLevel (optional) — Zstd level passed to EncodingService. When set, the full payload is compressed. When nil, no compression.

Returns the packed buffer, or nil in lax mode when the input fails validation.

static.deserialize(serdes, buf, offset?, wasCompressed?) -> T?

<T>(serdes: typeNode<T>, buf: buffer, offset: number?, wasCompressed: boolean?) -> T?

  • serdes — same type node used on the sender side.
  • buf — the buffer to decode.
  • offset — must match the offset passed to serialize.
  • wasCompressedtrue only when serialize was called with a non-nil compressionLevel.

Example

local T = VS.types
local static = VS.static

local Player = T.struct({
    id = T.uint32,
    name = T.string255,
    hp = T.float32,
})

local buf = static.serialize(Player, { id = 1, name = "Aria", hp = 100 })
local back = static.deserialize(Player, buf)

Dynamic API

Dynamic packs a variable list of values. Each value is tagged with a 1-byte type id so the receiver can decode without a shared schema. No root node; decode order matches encode order. Leading arguments are options, not values.

dynamic.serialize(compressionLevel?, offset?, ...) -> buffer?

<T...>(compressionLevel: number?, offset: number?, T...) -> buffer?

  • compressionLevel — Zstd level, or nil for no compression. First positional argument.
  • offset — header-reservation, same semantics as the static API. Second positional argument.
  • ... — the values to pack, in order.

dynamic.deserialize(wasCompressed?, offset?, buf) -> ...any?

(wasCompressed: boolean?, offset: number?, buf: buffer) -> ...any?

  • wasCompressedtrue when the buffer was produced with a compression level; otherwise nil/false.
  • offset — must match the offset passed to serialize.
  • buf — the buffer to decode.

Returns the unpacked values in the order they were written.

Inference notes

The dynamic serializer picks a type for each value via src/shared/inferValueType.luau. Integers are chosen by magnitude; numbers with a fractional part become float32; tables with sequential integer keys become array, otherwise map. Empty tables become void. Instance and vector inference follow dynamicInstanceType / dynamicVectorType in the config. Wider integer widths (uint40, uint48) are not inferred — use the static API when you need them.

Example

local dynamic = VS.dynamic

local buf = dynamic.serialize(nil, nil, "Move", 42, Vector3.new(0, 10, 0), true)
local op, id, pos, sprint = dynamic.deserialize(nil, nil, buf)

Types

Every member of VoidSentry2.types. Sizes below are for the payload only; collection types also write a length or count prefix as noted.

Integers

TypeSizeRange
int8 / uint81 B−128..127 / 0..255
int16 / uint162 B−32 768..32 767 / 0..65 535
int24 / uint243 B±8 M / 0..16 M
int32 / uint324 B±2.1 B / 0..4.2 B
int40 / uint405 B±549 B / 0..1.1 T
int48 / uint486 B±140 T / 0..281 T

Floats

TypeSizeNotes
float162 BIEEE half.
float243 BCustom reduced-precision float (1 sign + 8 exp + 15 mantissa).
float324 BIEEE single.
float648 BIEEE double.

Primitives & strings

TypeSizeNotes
boolean1 BSingle bool.
boolPacked1 BEight booleans packed into one byte; the array contains 8 entries no matter if a smaller amount of booleans was provided.
string2 B + Nuint16 length prefix; max 65 535 bytes.
string2551 B + Nuint8 length prefix; max 255 bytes. Use this for short strings.
nothing0 BRepresents nil; carries no bytes.
void0 BRepresents the empty table {}; carries no bytes.

Vectors & CFrames

TypeSizeNotes
vector12 BLuau vector, three float32 axes.
vector28 BRoblox Vector2, two float32 axes.
vector2f246 BVector2 with float24 axes.
vector2f164 BVector2 with float16 axes.
vector312 BRoblox Vector3, three float32 axes.
vector3f249 BVector3 with float24 axes.
vector3f166 BVector3 with float16 axes.
color33 BOne uint8 per channel; suitable for 8-bit color.
cframe48 BPosition (3×float32) + full rotation matrix (9×float32).
cframef2436 BSame layout as cframe using float24.
cframef1624 BSame layout using float16.
qcframe28 BPosition (3×float32) + rotation quaternion (4×float32). Smaller than matrix CFrames; slightly slower on decode.
qcframef2421 BQuaternion CFrame with float24.
qcframef1614 BQuaternion CFrame with float16.

Collections

TypeSizeNotes
array(T)2 B + elemsuint16 length prefix + elements. Max 65 535 entries.
array255(T)1 B + elemsuint8 length prefix + elements. Max 255 entries.
map(K, V)2 B + pairsuint16 pair count + (K, V) pairs in table iteration order.
map255(K, V)1 B + pairsuint8 pair count; max 255 pairs.
struct({ k = T, … })sum of fieldsFixed record. Field order on the wire is sorted by key name — no per-field tags, no length prefix.

Roblox-specific

TypeSizeNotes
enum(Enum.X)2 BWrites EnumItem.Value as uint16. Bind to a specific Enum at schema-build time.
instance3 BWrites a uint24 id from the instanceIdAttribute. Resolves the same object on the peer via the shared instance map. Use for live references.
serInstance(className)sum of fieldsWrites a snapshot of the properties registered via registerInstanceSchema. Deserialize constructs a new Instance.new(className).

Specials

TypeSizeNotes
optional(T)1 B + maybe TPresence byte; payload only when the value is present. Absent round-trips as nil.
any1 B + valueWrites a 1-byte type sign via the dynamic inference path, then the value. Use when a field can hold multiple unrelated shapes.
union(v1, v2, …)1 B + variantWrites a 1-byte variant index + the chosen variant's payload. Dispatches on runtime exType. See union details.

union(v1, v2, …) — in depth

T.union(...) builds a typeNode that accepts any one of the provided variant nodes. On the wire it emits a 1-byte variant index followed by the chosen variant's payload, so the total size is 1 byte + variant size and the upper bound is 255 variants.

How dispatch works. At write time, the library calls extypeof(value) and looks the returned string up in the union's internal exType → variantNode table. The chosen variant's write runs; the variant's position in the argument list is stored as the index byte. At read time that byte selects the variant and its read runs.

exType rules (enforced at build time).

  • Each variant's exType must be unique within the union — the dispatch table rejects duplicates.
  • Type families that share an exType therefore collapse into one slot: all integer widths share "int"; all floats share "float"; string and string255 share "string"; every CFrame encoding shares "CFrame"; every Vector3 encoding shares "Vector3"; struct and map share "map"; instance and serInstance share "Instance". Pick the width/encoding you want, once per union.
  • Variants whose exType is listed in config.unsupportedUnionExTypes (union, any, void, optional) are rejected — these cannot participate because their runtime shape does not round-trip through extypeof.
  • A value whose extypeof result matches no variant is a serialization error in strict mode (or a warn + nil buffer in lax mode).

What extypeof returns. Numbers become "int" or "float" by value (integer-valued floats go to "int"). Tables are "array" if sequential, otherwise "map". EnumItem becomes "Enum." .. EnumType. Everything else is the raw typeof string ("string", "boolean", "Vector3", "CFrame", "Instance", …).

-- Three-way union: integer | float | string
local MessageField = T.union(T.int32, T.float64, T.string255)

static.serialize(MessageField, 42)         -- routes via "int"    → int32  branch
static.serialize(MessageField, math.pi)    -- routes via "float"  → float64 branch
static.serialize(MessageField, "hi")       -- routes via "string" → string255 branch

-- Build-time error: both widths share exType "int".
-- T.union(T.int32, T.int16)

To route a value shape that extypeof does not distinguish (e.g. two different kinds of non-sequential table), extend extypeof to return a new tag and add a variant whose exType equals that tag. See Extending union.

Usage

Choosing between static and dynamic, handling failure, and interleaving your own bytes with VoidSentry2 payloads.

Static vs dynamic — how to choose

Use static when the layout is stable across calls: gameplay state, inventory snapshots, save blobs, RPC payloads with a fixed shape. The wire carries no per-field type metadata, so the result is smaller and faster than dynamic and matches what the code already knows about the data.

Use dynamic when the values or their types change call-to-call: debug tooling, admin commands, "send whatever mix I have right now". You pay one tag byte per value plus any extra metadata (enum id, serInstance class id) but avoid maintaining a schema per shape.

A static struct covers the "many fields, one message" case without reaching for dynamic.

Type nodes and one root value

Entries in VoidSentry2.types are small internal objects. You build them at module scope and pass them to static.serialize / static.deserialize. One static call = one root value — usually a struct wrapping the real fields.

local T = VS.types
local Packet = T.struct({ id = T.uint16, name = T.string255 })

Strict vs lax failure (strictMode)

With strictMode = true (default), invalid input or buffer overflows throw. With false, they warn and serialize/deserialize return nil. In lax mode every call site must check the result.

Important: an absent optional(T) legally decodes to nil on success. In lax mode a nil result from deserialize is ambiguous — use strict mode, an outer length/checksum, or protocol context to distinguish "absent" from "failed".

Headers with offset

Pass a positive offset to reserve that many leading bytes for your own header (magic, protocol version, chunk length). The returned buffer's bytes [0, offset) are zero-filled and yours to overwrite. Deserialize with the same value so reading begins after the header.

If keeping the header in the same buffer is inconvenient, serialize with offset = 0 and frame the payload yourself.

Compression choices

Zstd (via Roblox EncodingService) costs CPU on both ends and rarely shrinks small payloads meaningfully. Measure on real traffic: it helps the most on repetitive or string-heavy blobs, less on mostly-numeric packets that are already compact.

Manual buffer mode

The manual cursor API (writeStart/writeFinish, readStart/readFinish) exposes the same scratch buffer used internally. You can call a type node's write/read directly between writeStart and writeFinish to interleave your own buffer.write* calls with VoidSentry2 nodes. This is an escape hatch — prefer the static API for everything that fits in a single schema.

Examples

Player snapshot (static)

Assume local VS = require(...), local T = VS.types, local static = VS.static for each block below.

local T = VS.types
local static = VS.static

local Player = T.struct({
    id = T.uint32,
    name = T.string255,
    pos = T.vector3,
    hp = T.float32,
})

local buf = static.serialize(Player, {
    id = 1,
    name = "Aria",
    pos = Vector3.new(0, 10, 0),
    hp = 100,
}, nil, nil)

local round = static.deserialize(Player, buf, nil, nil)

Batched entities + Zstd

Serialize with a numeric level; deserialize with wasCompressed = true.

local EntityList = T.array(T.struct({
    id = T.uint16,
    x = T.float32,
    y = T.float32,
}))

local buf = static.serialize(EntityList, entities, nil, 5)
local out = static.deserialize(EntityList, buf, nil, true)

Offset: reserve bytes, then write a header

First HEADER bytes stay zero until you fill magic/version; payload starts at HEADER. Deserialize with the same offset.

local HEADER = 8
local Payload = T.struct({ score = T.uint16, name = T.string255 })

local buf = static.serialize(Payload, { score = 10, name = "Ann" }, HEADER, nil)
buffer.writeu32(buf, 0, 0x50505332) -- example magic "PPS2"
buffer.writeu32(buf, 4, 1)        -- example protocol version

local data = static.deserialize(Payload, buf, HEADER, nil)

Dynamic with Zstd

First argument to serialize is the compression level (or nil for none). First argument to deserialize is true if that buffer was compressed.

local dynamic = VS.dynamic

local bigString = string.rep("x", 4000)
-- First arg = Zstd level 4; then offset nil; then the values to pack.
local buf = dynamic.serialize(4, nil, "ServerLog", bigString)
if not buf then return end

-- Because we used a compression level above, pass true here.
local label, payload = dynamic.deserialize(true, nil, buf)
-- label == "ServerLog", payload == bigString

Static “RPC-shaped” packet (several fields, one struct)

local Rpc = T.struct({
    op = T.string255,
    playerId = T.uint32,
    amount = T.float32,
})

local buf = static.serialize(Rpc, {
    op = "Damage",
    playerId = 7,
    amount = 12.5,
}, nil, nil)

enum (Roblox EnumItem)

local MaterialField = T.enum(Enum.Material)
local Row = T.struct({ mat = MaterialField })

local buf = static.serialize(Row, { mat = Enum.Material.Slate }, nil, nil)
local back = static.deserialize(Row, buf, nil, nil)

Dynamic RPC-style tuple

Uncompressed dynamic buffer: first two arguments to serialize are nil (no Zstd, no write offset). Receiver unpacks in order.

local dynamic = VS.dynamic

local playerId = 42
local targetPos = Vector3.new(1, 0, 0)

local b = dynamic.serialize(nil, nil, "Move", playerId, targetPos, true)
if not b then return end

local opName, id, pos, wantsJump = dynamic.deserialize(nil, nil, b)
-- opName == "Move", id == playerId, pos == targetPos, wantsJump == true

Optional fields + nested struct

Model “maybe present” values with optional; nest struct for grouped fields.

local T = VS.types
local static = VS.static

local Profile = T.struct({
    userId = T.uint32,
    displayName = T.string255,
    bio = T.optional(T.string255),
    stats = T.struct({
        kills = T.uint16,
        deaths = T.uint16,
    }),
})

local buf = static.serialize(Profile, {
    userId = 9001,
    displayName = "Neo",
    bio = nil,
    stats = { kills = 12, deaths = 3 },
}, nil, nil)

String keys → numbers (map)

local T = VS.types
local static = VS.static

local Scores = T.map(T.string255, T.float32)

local buf = static.serialize(Scores, {
    ["arena_1"] = 99.5,
    ["arena_2"] = 12.0,
}, nil, nil)
local out = static.deserialize(Scores, buf, nil, nil)

void, nothing, and any

void is an empty table {} marker (zero payload bytes). nothing is Luau nil with zero bytes. any stores a one-byte type id plus the inner value — useful when one field can hold several shapes.

void and nothing are niche

They exist mainly so the dynamic serializer can represent empty tables and nil without extra tag bytes in those cases — saving bandwidth when you pack values that inference picks as void or nothing. They are not aimed at typical static schemas; prefer optional(T) or a concrete type when you control the layout.

local Ping = T.struct({ kind = T.string255, payload = T.any })
local buf = static.serialize(Ping, { kind = "int", payload = 42 }, nil, nil)
local back = static.deserialize(Ping, buf, nil, nil)

serInstance: register schema, then serialize

Run registerInstanceSchema once per class name (usually at module load). Then use types.serInstance("ClassName") in your static schema. Deserialization creates a new Instance and assigns properties from the buffer.

local VS = require(game.ReplicatedStorage.VoidSentry2)
local T = VS.types
local static = VS.static

VS.registerInstanceSchema("Part", {
    Name = T.string255,
    Anchored = T.boolean,
})

local PartSnapshot = T.struct({
    template = T.serInstance("Part"),
})

local src = Instance.new("Part")
src.Name = "Floor"
src.Anchored = true

local buf = static.serialize(PartSnapshot, { template = src }, nil, nil)
local copy = static.deserialize(PartSnapshot, buf, nil, nil)
-- copy.template is a new Part with Name / Anchored restored

Mixing instance (by reference) and static structs

types.instance writes a numeric id and resolves the same object on the peer via your instanceMap and _VSID — use for live object graphs. serInstance snapshots property bags into new instances — use for templates or reconstructed props.

-- instance: same Part reference round-trips if both sides share instanceMap + _VSID
local RefPayload = T.struct({ target = T.instance })

-- serInstance: wire carries property values; deserialize builds a fresh Instance
local ClonePayload = T.struct({ cloneOfPart = T.serInstance("Part") })

Benchmarks

What this is

A short summary of typical hot-path cost (≈ average µs per serialize→deserialize in the static rows; dynamic rows match the schemaless API). It is not the full voidSentry2_Test matrix.

The results highly depend on the hardware.

Useful tips

Short habits that save time.

Build schemas once

Define local MySchema = T.struct({ ... }) at module scope. Creating new struct tables inside hot loops creates garbage and costs CPU.

Use smaller types when the data allows

If a number never exceeds 255, uint8 beats uint32. If position does not need full float precision, vector3f24 or vector3f16 saves bytes versus vector3.

Measure compression on real traffic

Zstd helps repetitive blobs; tiny messages may not shrink enough to pay for CPU.

Changing your schema breaks old bytes

Caution: adding/removing/reordering struct fields or switching node kinds changes the wire layout. Old clients or old save files will mis-read data if you do not migrate.

Tip: if you care about backwards compatibility, use a non-zero offset on static.serialize so the buffer has room at the front for your own header. Write metadata there (for example a schema version), then parse that header first and call static.deserialize with the matching offset so the typed payload aligns with the version you expect. Branch reads or migrate once based on that version instead of stuffing a version field inside the root struct.

nil from optional vs nil from failure

An absent optional(T) decodes as nil on success. In lax mode, failures can also yield nil. Use strictMode, outer framing (length + checksum), or protocol context to tell the cases apart when it matters.

Pick instance vs serInstance deliberately

Use types.instance when the peer already holds the same live object (replicated parts, player characters). Use types.serInstance when you want a property snapshot that reconstructs a fresh Instance on the other side. Mixing them in the same packet is fine — pick per field.

Prefer struct over multiple dynamic values

When a message's shape is stable, a single struct through the static API is smaller and faster than sending the same fields via dynamic.serialize. Reserve dynamic for genuinely variable argument lists.

Custom types

Most games only need to compose built-in nodes. Forking the library is for brand-new wire encodings.

1. Compose built-in nodes (recommended)

Describe the wire shape by combining existing nodes: struct, array, map, optional, union, numbers, strings, vectors, enum, any. No package changes. Use this for almost every gameplay, RPC, or save-blob shape.

local T = VS.types
local static = VS.static

local InventorySlot = T.struct({
    itemId = T.uint16,
    count = T.uint8,
})

local PlayerSave = T.struct({
    coins = T.uint32,
    slots = T.array(InventorySlot),
})

local buf = static.serialize(PlayerSave, saveTable)

2. serInstance: snapshot Roblox instances

types.serInstance(className) serializes a declared set of properties for a given class. Flow:

  1. Register once per class. VoidSentry2.registerInstanceSchema(className, { PropName = typeNode, ... }). Keys are property names as strings; values are type nodes. Call at startup, before any serialize.
  2. Reference in schemas. Use T.serInstance(className) inside a struct or collection.
  3. Serialize. Pass a real Instance of that class; the library reads the registered properties and writes them.
  4. Deserialize. The library constructs a new Instance.new(className) and assigns properties from the buffer. Object identity is not preserved.

Versus types.instance. instance writes a numeric id (from instanceIdAttribute) and resolves the same live object on the peer via the shared instance map. Use instance for live references, serInstance for templates or fresh clones.

3. Extending union with a custom variant

Once you have a union use-case that the built-in variants don't cover, you can add your own. See union details for how dispatch works; this section focuses on the extension steps.

value  →  extypeof(value)  →  exTypeMap[exType]  →  variant typeNode  →  write / read

Checklist

  1. Write the variant node. A frozen table { write, read, exType }. The idiomatic home for a reusable node is a new file under src/types/<name>.luau (matching the convention of every built-in type and allowing T.<name> access) — use any existing file there as a template. Defining the node inline with table.freeze({ ... }) next to the union that uses it is acceptable for a genuine one-off where promoting it to a first-class type would be noise. Either way, exType must be unique within the union and must not appear in config.unsupportedUnionExTypes (union, any, void, optional).
  2. Make extypeof return that string for your values. If your variant's values already fall into an existing category ("int", "float", "array", "map", "Enum.<T>", or a raw typeof string), set exType to that category and remember only one variant per category may appear in a union. If your variant handles a shape the defaults don't distinguish, add a branch at the top of src/shared/extypeof.luau that returns your custom tag.
  3. Use it. Pass the variant to T.union(variantA, variantB, yourVariant). Writes look up your node by exType and emit a 1-byte variant index; reads use the same index to pick the variant.

Example: routing a tagged Luau table to a dedicated variant

Two shapes both arrive as non-sequential tables but must serialize differently. The defaults map both to "map". Extend extypeof to look at a tag field and return a distinct string for one of them.

-- 1. Your variant node. Prefer src/types/myTagged.luau for reuse;
--    the inline form below is fine for a single-call-site one-off.
local MyTaggedNode = table.freeze({
    write = function(value) --[[ ... ]] return 0 end,
    read = function() --[[ ... ]] return 0, value end,
    exType = "myTagged",
})

-- 2. Patch src/shared/extypeof.luau (simplified):
--    if vType == "table" and rawget(value, "_tag") == "MY_TAGGED" then
--        return "myTagged"
--    end

-- 3. Compose a union that can hold the custom variant alongside plain maps:
local Packet = T.union(T.map(T.string255, T.int32), MyTaggedNode)

4. Forking: adding a brand-new wire type

Only needed when the byte layout you want does not exist in any built-in node. Two layers are involved.

4a. Buffer primitives — src/buffer/writer.luau and src/buffer/reader.luau

Low-level helpers that advance the cursor and write/read raw bytes (U8, U16, writef32, NEED, …). Not typed, no dispatch. Add symmetric write* / read* helpers here if you need a new byte pattern.

4b. Type node — src/types/<name>.luau

Exports a frozen { write, read, exType }. Validates input, calls NEED(n), then writes using the buffer primitives. On read, checks the status and returns (flag, value). Use src/types/uint8.luau or src/types/float32.luau as a template.

4c. Registration — src/shared/config/typeSigns.luau

Add a unique 1-byte id: myformat = 48. The key must match the filename. src/init.luau iterates typeSigns and loads each matching src/types/<name>.luau into signMap and types.

4d. Optional: dynamic inference — src/shared/inferValueType.luau

Only needed if the dynamic serializer should automatically pick your type from plain Luau values. Static schemas reference nodes directly and do not need this step.

After adding all four pieces, the new type is available as T.myformat, works in struct/array/map, and (if added to inferValueType) can be chosen by the dynamic path.

Update logs

VoidSentry2 v0.1.1

  • New types: union (discriminated variants, up to 255 branches) and boolPacked (eight booleans in one byte).
  • New integer widths: uint40, int40, uint48, int48.
  • New CFrame layouts: cframef16 and qcframef16.
  • Color3, CFrame family, Luau vector, and Vector2/Vector3 (including compact float variants) now share fast paths in the buffer reader/writer layer.
  • Manual serialization: writeStart, writeFinish, readStart, readFinish, plus getWriteCursor and getReadCursor.
  • Type export fix: getItemType works correctly with the Luau LSP.
  • Documentation refresh, general code quality, and small performance tweaks.
  • Benchmarks are now more concise.

VoidSentry2 v0.1.0

  • Initial release

Credits

Author

IAMNOTULTRA3

License

Apache-2.0

Packages

Wally: elentium/voidsentry2