OmniSentry

Multi-Cursor Oriented fork of VoidSentry2.

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. OmniSentry 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: per-cursor buffer growth, 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.
  • Explicit multi-cursor API (writeStart / writeFinish, readStart, alloc) if you want several independent serialize sessions or to interleave OmniSentry writes with your own bytes.

Differences from VoidSentry2

OmniSentry is a fork oriented around multi-cursor serialization. The wire format and type nodes are largely the same; the buffer layer and manual API are not.

Single global cursor vs explicit cursors

VoidSentry2 keeps one shared write cursor and one shared read cursor inside the library. Every primitive, type node, and serializer advances that global position. That design is lean — no cursor object is threaded through calls — but only one serialize or deserialize operation can safely run at a time. Nested or concurrent writes/reads would stomp each other's offset.

OmniSentry passes a cursor table through the whole stack:

  • writeStart(offset?) -> cursor — creates a private growable buffer for this session.
  • readStart(buf, offset?) -> cursor — binds a read cursor to an existing buffer.
  • Type nodes use write(cursor, value) and read(cursor, lastFlag?).
  • alloc(cursor, bytes) — reserves space before writing (replaces VoidSentry2's NEED).
  • writeFinish(cursor) -> buffer — trims and returns the written bytes.

You can hold multiple write cursors at once (e.g. build two payloads in parallel) or mix OmniSentry type-node writes with your own buffer.write* calls on the same cursor. The trade-off is a small amount of extra per-call overhead (passing the cursor, per-session buffers that grow on demand) compared to VoidSentry2's single reused scratch buffer.

NEEDalloc

VoidSentry2's NEED(n) checked whether n bytes fit in a fixed global scratch buffer capped by maxBufferSize; it returned false on overflow. OmniSentry's alloc(cursor, n) grows the cursor's private buffer (starting at startingBufferSize, multiplied by bufferExpandMultiplier when full). There is no global byte cap — growth stops only when memory runs out.

Removed / changed surface API

  • Gone: readFinish, getWriteCursor, getReadCursor, updateBigBuffer, maxBufferSize.
  • Read position: use cursor.offset on the object returned by readStart.
  • Write position: use cursor.offset on the object returned by writeStart.
  • New: alloc(cursor, bytes) on the library root (same as writer.ALLOC).

static.serialize / dynamic.serialize still manage cursors internally — you only touch the cursor API for custom/manual serialization.

Quick start

Static

--!strict
local VS = require(path.to.OmniSentry)
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.OmniSentry)
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]
OmniSentry = "elentium/omnisentry@0.1.3"

Then run wally install and require the module from your tree (for example game.ReplicatedStorage.Packages.OmniSentry 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/OmniSentry

Package page

GitHub

Clone github.com/Elentium/OmniSentry 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/OmniSentry.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

startingBufferSize

Default: 64 bytes. Effect: initial capacity of each write cursor's private buffer when writeStart is called. The buffer grows automatically via alloc when a write would exceed capacity.

Change when: most of your payloads are large — raising this avoids a few early reallocations. Keep it small if you serialize many tiny messages and want to minimize idle allocation.

bufferExpandMultiplier

Default: 1.5. Effect: when a write cursor runs out of room, its capacity is multiplied by this factor (integer division) until the pending write fits.

Change when: tuning the time/memory trade-off of buffer growth. Higher values mean fewer reallocations but more wasted tail space; lower values tighten memory at the cost of more copies.

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 OmniSentry.

API reference

Library properties

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

.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.

.setConstant(constant, value)

setConstant<T>(constant: omniSentryConstants, value: T) -> ()

Updates a runtime configuration value without editing constants.luau. See the Configuration page for what each option does.

Editable constants: startingBufferSize (number), bufferExpandMultiplier (number), strictMode (boolean), dynamicVectorType (string), dynamicInstanceType (string), unsupportedUnionExTypes (table).

  • For scalar constants, value replaces the current setting.
  • For unsupportedUnionExTypes, keys from value are merged into the existing table (shallow).
  • Invalid constant names or wrong value types error (strict mode) or warn and no-op (lax mode).
local VS = require(path.to.OmniSentry)

VS.setConstant("strictMode", false)
VS.setConstant("startingBufferSize", 256)

Manual cursor API

Low-level hooks for advanced users who want several independent serialize sessions, or to mix OmniSentry type-node writes with their own bytes on the same buffer.

  • writeStart(offset: number?) -> cursor — create a new write cursor with a private growable buffer. cursor is { buf, offset, len }.
  • alloc(cursor, bytes) -> () — ensure at least bytes of free space remain at the current write position (grows cursor.buf when needed). Type nodes call this internally before writing.
  • writeFinish(cursor) -> buffer — copy bytes [0, cursor.offset) into a new exact-sized buffer.
  • readStart(buf: buffer, offset: number?) -> cursor — bind a read cursor to buf; cursor.len is buffer.len(buf).

Current byte position on either side is always cursor.offset. Unlike VoidSentry2, there is no global cursor and no readFinish — drop the cursor when you are done.

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

local VS = require(path.to.OmniSentry)
local T = VS.types

local cursor = VS.writeStart()
T.uint16.write(cursor, 42)
VS.alloc(cursor, 4)
buffer.writef32(cursor.buf, cursor.offset, 3.14)
cursor.offset += 4
local buf = VS.writeFinish(cursor)

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 OmniSentry.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 OmniSentry 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 OmniSentry.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 gives you an explicit cursor object per serialize/deserialize session. Call a type node's write(cursor, value) / read(cursor) between writeStart and writeFinish to interleave your own buffer.write* calls (remember to advance cursor.offset yourself for raw writes, or call alloc first). Multiple cursors can exist at the same time — VoidSentry2 could not do that safely. Prefer the static API for anything 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.OmniSentry)
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

Benchmarks for OmniSentry are not ready yet. Though OmniSentry is expected to be a little bit slower than VoidSentry2.

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. OmniSentry.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(cursor, value) --[[ VS.alloc(cursor, n) ... ]] return 0 end,
    read = function(cursor, _lastFlag) --[[ ... ]] 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 take cursor first and advance cursor.offset (U8, U16, F32, …). Buffer growth is not handled here — callers (type nodes) must call ALLOC first. Add symmetric write/read helpers if you need a new byte pattern.

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

Exports a frozen { write, read, exType }. Validates input, calls writer.ALLOC(cursor, n), then writes using the buffer primitives with the same cursor. 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

OmniSentry v0.1.3

  • OmniSentry initial release (forked from VoidSentry2 v1.0.3).

Credits

Author

IAMNOTULTRA3

License

Apache-2.0

Packages

Wally: elentium/OmniSentry