Skip to content

Types

This page covers every type BlinkBlox supports: what it becomes in Luau, how to constrain it, and how many bytes it costs to send. If anything is missing or wrong, please open an issue.

Every type below is written into a buffer, except Instance and unknown, which travel beside it in the remote’s instance list, and cost an unreliable payload 6 bytes each. The compiler measures these sizes itself – it is how it refuses an unreliable event that cannot fit in MaxUnreliableSize.

Type Bytes
u8, i8 1
u16, i16, f16 2
u24, i24, f24 3
u32, i32, f32 4
f64 8
boolean 1 bit, shared with neighbouring booleans
T? 1 bit, plus T when present. Instance? costs nothing extra
string(N), buffer(N) N – an exact length has no prefix
string, buffer length prefix (1 to 4) + the bytes
T[N] N elements – no prefix
T[], T[A..B] length prefix + each element
boolean[] length prefix + one byte per eight elements
map 2 (the entry count) + each key and value
set 1 for up to 8 flags, 2 for up to 16, 4 for up to 32; another group per further 32
enum the bits that tell its values apart – 2 for four – shared like a boolean’s
tagged enum the bits that tell its variants apart, shared the same way, + the variant’s fields
struct, type pack the sum of the fields
vector 3 components: 12 as f32 (the default)
CFrame 3 position + 3 rotation components: 24 by default
CFrame<quat> 3 position components + 7: 19 by default
Color3 3
BrickColor 2
DateTime, DateTimeMillis 8
Vector2, UDim, NumberRange 8
UDim2, Rect 16
ColorSequence 1 + 7 per keypoint: 15 to 141
NumberSequence 1 + 12 per keypoint: 25 to 241
TweenInfo 15
PhysicalProperties, Region3, Ray 24
Enum(Name) 2
Instance, unknown 0, and one slot in the instance list

Each event adds one byte for its id, and an OrderedUnreliable event other than a stream two more for its sequence number. A function call adds the same id byte and one more naming the call; its answer carries both and a success flag. The bandwidth guide covers how to spend fewer of them.

A boolean, and the presence flag of an optional (T?), cost one bit. Consecutive ones in the same block share a byte, eight to a byte: a struct with eight boolean fields costs one byte, and an event whose type pack is (boolean, u8?, boolean) spends one byte on its three bits, followed by the u8 when it is present. An enum’s index and a tagged enum’s tag take their bits from the same byte: as few as tell the values apart, and at least one, so a four-value enum beside six booleans costs one byte between them.

A block here is one run of generated code. The fields of a struct share the block they sit in, as do the elements of a type pack. A new block begins inside each array element, each map entry, each tagged-enum variant and the payload of each optional, so bits never share a byte across those boundaries. That is why boolean? costs a bit and then a whole byte when it is present: the value sits in the optional’s payload, not beside its flag. Arrays of plain booleans are the exception – they are packed eight elements to a byte.

A variable length – of a string, a buffer or an array – is sent as a prefix, in the smallest unsigned integer that holds the span of its range, and relative to its minimum:

Span (max - min) Prefix
0 (an exact length) none
up to 255 1 byte
up to 16383 a varint: 1 byte under 128, 2 from there
up to 65535 2 bytes
up to 16777215 3 bytes
more 4 bytes

A varint is seven bits a byte, the lowest first, the top bit saying another byte follows, so a span that needs two bytes never takes more than it would have and a short length takes one. So string(300..400) spends one byte on its hundred possible lengths rather than the two that 400 alone would need, and a length below the minimum cannot be written at all. An unbounded string, buffer or array is treated as 0..65535 and always takes a varint: one byte under 128, two under 16384, three to 65535, and a value longer than 65535 is refused on send. A map’s entry count is always a 2-byte u16.

A half-open length runs to the limit of the type. For an array that is 65535, but for a string or a buffer it is 4294967295, so string(1..) takes a 4-byte prefix where a bare string usually takes one. Give lengths an upper bound.

On receipt, a length is checked against its range, and against the bytes left in the packet, before anything is read or allocated for it – so a hostile length prefix buys neither. A varint in more bytes than its value needs, or past 65535, is refused as it is read.

Ranges can be used with numbers, strings, buffers, vectors and arrays to limit the values they can represent.

A full range has both a minimum and a maximum: 0..100.

A half range gives only one side: 0.. or ..100. The open side is the limit of the type – 255 for a u8, 65,504 for an f16, 4,294,950,912 for an f24, and unbounded for an f32 or f64.

An exact range has a single value, such as 0 or 100. On a length it means exactly that length.

Range Min Max
0..100 0 100
0.. 0 the type’s maximum
..100 the type’s minimum 100
0 0 0
100 100 100

A range must fit its type (u8(0..300) is refused), and a range on an integer, or on a length, must use whole numbers. Floats accept negative and fractional bounds: f32(-0.5..0.5).

The receiving side always checks a value against its range, and abandons the packet at that event if it fails; a ranged float refuses NaN too. An f16, f24 or f32 arrives rounded to its precision, so the receiver checks it against the bounds rounded the same way: f24(0.1..0.7) accepts a value sent as exactly 0.1 or 0.7, and refuses anything sent outside them. The sending side checks only under WriteValidations – except for lengths, where a value outside the range would corrupt the packet, so those are always checked.

BlinkBlox supports every number type the buffer library implements, plus a half-precision float and three 24-bit types.

Number types start with a prefix – u, i or f – followed by the number of bits used to represent the number. The number of bits is also what it costs to send.

Whole numbers greater than or equal to zero.

Name Size Min Max
u8 1 byte 0 255
u16 2 bytes 0 65,535
u24 3 bytes 0 16,777,215
u32 4 bytes 0 4,294,967,295

Whole numbers, positive or negative.

Name Size Min Max
i8 1 byte -128 127
i16 2 bytes -32,768 32,767
i24 3 bytes -8,388,608 8,388,607
i32 4 bytes -2,147,483,648 2,147,483,647

Floating points represent numbers with a fractional part.

The bit size of a float does not set a hard limit on its value so much as its precision. The table lists the largest integer each type represents exactly.

Name Size Exact integers up to Largest value
f16 2 bytes 2,048 65,504
f24 3 bytes 262,144 about 4.29e9
f32 4 bytes 16,777,216 about 3.4e38
f64 8 bytes 9,007,199,254,740,992 about 1.8e308

A range on a float may go past the exact-integer limit. An open side of one takes the type’s largest value: f32(0..) and f64(0..) are not bounded above at all, f16(0..) stops at 65,504 and f24(0..) at 4,294,950,912, so a value that arrived as infinity is refused there. A value past 65,504 sent as an f16 arrives as infinity. Every float type carries NaN, both infinities and -0, though a ranged float refuses NaN.

f24 has a sign, six bits of exponent and seventeen of mantissa, so it holds 18 significant bits where f16 holds 11. It rounds to the nearest value it can hold, a tie away from zero, where f16 truncates. Its smallest normal value is 2^-30, about 9.3e-10, and below that it keeps subnormals down to 2^-47, about 7.1e-15. Between 512 and 1024 its step is about 0.004, so a position a thousand studs from the origin arrives within 0.002 of where it was sent. A value that rounds past its largest finite value, 4,294,950,912, arrives as infinity.

Bound a number by placing a range in parentheses after the type.

type Health = u8(0..100)
type Damage = u8(..100)
type Aim = f32(-1..1)
type UserId = f64

A number with a fractional part does not need a float when its precision is fixed. Put a scale – the step between the values it carries – in angle brackets after an integer type, and BlinkBlox sends the nearest whole number of steps in that integer and reads it back as that many steps.

type FrameTime = u16<0.1>(0..6553.5)
type Angle = i16<0.01>(-180..180)
type Cost = u8<2.5>

FrameTime travels in two bytes. 12.34 is sent as 123 and read back as 12.3 – exactly the number the literal 12.3 is, since a step whose reciprocal is a whole number is sent as round(v * 10) and read as n / 10. Any other step, like 2.5, multiplies and divides by the step.

  • Every integer takes a scale: u8, u16, u24, u32, i8, i16, i24 and i32. A float takes one only with a closed range, and is quantized. A scale of zero or less is refused (E3032).
  • Rounding is to nearest, a tie away from zero, where a bare integer truncates. u16<1> is the way to round an integer rather than truncate it.
  • The range is written in real units, and each bound must be a whole number of steps that fits the integer: u16<0.1>(0..0.05) and u8<0.5>(0..200) are both refused (E3009). Without a range, the bounds are the integer’s scaled: u16<0.1> holds 0 to 6553.5.
  • Past its range, a value is refused on send under WriteValidations, with the bounds in real units. Without it, it wraps as the integer would: 6553.6 in a u16<0.1> is 65536 tenths and arrives as 0. Add saturate to clamp instead. The receiver checks a ranged value in whole steps before scaling it, so the check is exact.
  • It costs the integer’s size in the size analysis, and is a number in Luau and TypeScript.
  • The scale is part of the schema signature, since the same bytes mean something else at another scale.

A float with a scale and a closed range is sent as whole steps above its minimum, in the narrowest unsigned integer that holds the span. You say how precise the value is and where it can be; BlinkBlox picks the bytes.

type Aim = f32<0.01>(-1..1)
type Height = f32<0.25>(100..200)
type Distance = f64<0.001>(0..2048)
Type Steps Sent as Bytes, where the float took
f32<0.01>(-1..1) 200 u8 1 of 4
f32<0.25>(100..200) 400 u16 2 of 4
f64<0.001>(0..2048) 2 048 000 u24 3 of 8

Everything above holds for them too: rounding to nearest, bounds that are whole steps, the check in whole steps on receipt, saturate – which sends NaN as the value nearest zero in the schema’s units, so f32<0.5, saturate>(10..20) sends it as 10. The offset from the minimum is what makes a range far from zero cheap: f32<0.25>(100..200) is two bytes where u16<0.25> could not reach 200 at all.

Both bounds are required (E3032 otherwise), since the span is what sizes the integer, and a span of more than 4 294 967 295 steps is refused. A float without a scale is unchanged: its range is a check, and it travels at its own width.

saturate in a number’s angle brackets clamps what is sent into its range, where a value outside it would otherwise be refused under WriteValidations or wrap without it.

type Count = u8<saturate>(1..255)
type Tenths = u16<0.1, saturate>
type Aim = f32<saturate>(-1..1)
  • An integer is rounded to nearest first, then clamped: 300 sends 255, -4 sends 1, and 254.5 sends 255. A scaled one is clamped in whole steps, so it stops exactly at its bound.
  • A float is clamped to its range, or to the type’s largest finite value where it has one: an f16 stops at 65,504 instead of arriving as infinity. An open side of f32 or f64 clamps nothing.
  • NaN is sent as the value in range nearest zero: 0 when the range holds it, otherwise the bound closer to it – 1 for u8<saturate>(1..255). The alternatives were worse: sending NaN makes a ranged receiver refuse the event, refusing it on send defeats the point of opting in, and the lower bound is arbitrary for a range like -5..5. Zero is the value arithmetic that went wrong most often meant, and it is what Rust’s saturating float-to-integer casts choose.
  • It changes what an honest sender sends and nothing a receiver accepts: a modified client that does not saturate is still refused by the range. It works with or without WriteValidations, which still refuses a value that is not a number. It is not part of the signature, since no byte’s meaning changes.
  • The modifiers go in either order, once each, and a trailing comma is accepted: u16<saturate, 0.1>. saturate is not a keyword, so a field or type may still be called that.

A game that repeats its schema’s bounds before sending can hand them to the schema instead. Before:

event Probe {
From: Client,
Type: Unreliable,
Call: SingleSync,
Data: struct { FrameTime: u16, Count: u8(1..255) }
}
-- tenths of a millisecond, clamped by hand to fit a u16
Net.Probe.Fire({
FrameTime = math.clamp(math.round(Milliseconds * 10), 0, 65535),
Count = math.clamp(Count, 1, 255),
})
-- and on the server
Net.Probe.On(function(Player, Probe)
local Milliseconds = Probe.FrameTime / 10
end)

After, the schema says it once, and both sides send and read milliseconds:

event Probe {
From: Client,
Type: Unreliable,
Call: SingleSync,
Data: struct { FrameTime: u16<0.1, saturate>, Count: u8<saturate>(1..255) }
}
Net.Probe.Fire({ FrameTime = Milliseconds, Count = Count })
Net.Probe.On(function(Player, Probe)
local Milliseconds = Probe.FrameTime
end)

A bound the game needs to read – an array’s length before it fills one – comes from option ExportLimits rather than a copy of the number:

for _, Head in Heads do
if #Out == Net.Limits.CrawlPacket.Heads.Length.Max then
break
end
table.insert(Out, Head)
end

Luau’s text container, declared as string.

Bound a string’s length, in bytes, by placing a range in parentheses after the type.

type UUID = string(36)
type Username = string(3..20)

string(36) is exactly 36 bytes and sends no length at all. A bounded length takes the prefix described above – one byte for 3..20 – and an unbounded string takes two and refuses anything past 65535 bytes.

true or false, declared as boolean. A boolean costs one bit; see the bitfield.

type Success = boolean

Declared as buffer. A buffer lets you pass your own serialised data while still taking advantage of BlinkBlox’s batching.

Bound a buffer’s size, in bytes, by placing a range in parentheses after the type. Sizes are sent like string lengths.

type BinaryBlob = buffer
type Chunk = buffer(..800)

A vector in 3D space, most often a position. Declared as vector, it is a Vector3 in Luau.

Bound a vector’s length (magnitude) by placing a range in parentheses after the type. For example, a direction whose length is between 0 and 1:

type Direction = vector(0..1)

A magnitude is never negative, so the range cannot be either.

Pass a number type in angle brackets to choose how each of the three components is sent. The default is f32, 12 bytes; vector<f24> is 9 and vector<i16> is 6.

type Position = vector
type NearPosition = vector<f24>
type GridCell = vector<i16>
type CompactOffset = vector<f16>

Make a type optional by appending ? after the entire type:

type Username = string(3..20)?
type MaybeTarget = Instance(Player)?

An absent value costs one bit; a present one costs that bit plus the value. An optional Instance costs nothing extra: its absence is read from the instance list itself.

unknown cannot be optional – it has no way to encode absence – and neither can the key or the value of a map.

A list of values of one type, written as the type followed by square brackets. An array of strings:

type Names = string[]

Bound an array’s length by placing a range inside the brackets:

type Party = string(3..20)[1..8]
type RecentIds = f64[..50]
type Corners = vector[4]

[4] is exactly four elements and sends no length. Like an exact-length string, an array of any other length is refused on send under WriteValidations. Without it, a longer array is cut to its first elements and a shorter one fails to write. In an array of optionals, u8?[4], only an element past the fourth is refused, since the absent ones are holes. A variable length is sent like a string’s, and an array holds at most 65535 elements. Before allocating, the receiver checks the length against what the rest of the packet can hold, so a length prefix of 65535 on a two-byte packet buys nothing.

In an array of optionals, an absent element arrives in its own place: u8?[] sent as {1, nil, 3} arrives as {1, nil, 3}, not closed up.

An array of plain booleans is packed eight elements to a byte, after its length: boolean[1000] costs 125 bytes rather than 1000. An array of optional booleans, boolean?[], is not packed, since each element carries a presence flag of its own as well as its value.

An array whose elements send no bytes and no instances – an empty struct, say – gives the decoder nothing to run out of, so a two-byte count would buy 65535 iterations. Such an array must have a range; without one it is refused (E3021). The same holds for a map whose keys and values both cost nothing.

Key-value tables, with keys of one type and values of the same or another type. Declared with the map keyword. A map of string keys to f64 values:

map StringToNumber = { [string]: f64 }
map UserIdToUsername = { [f64]: string }

A key has to survive the round trip as a key. string, the number types, boolean and a plain enum all decode to the same value the sender had, so any of them works.

A struct, a tagged enum, another map, a set, an array or a type pack does not. Each decodes to a fresh table for every entry, and in Luau a table is its own identity, so the receiving side ends up holding [Table(0x...)] = value – entries it can reach only by iterating, never by looking anything up. Every byte round-trips correctly, which is what makes it so easy to miss.

BlinkBlox refuses those keys at compile time (E3001):

Refused
-- Each decoded key is a table nothing can match.
map Grid = { [struct { X: u8, Y: u8 }]: u8 }

If you need a composite key, put the parts in the value and key the map by something simple:

map Grid = { [u16]: struct { X: u8, Y: u8, Tile: u8 } }

Unlike arrays, a map takes no range. Its entry count is written as an unconditional u16, so every map is unbounded by construction – up to 65535 entries – and a decoder will loop over whatever count the packet names.

For an inbound event whose size you need to reason about, prefer a bounded array of key-value structs. It costs the same bytes, states its ceiling in the schema, and the decoder checks the length before it allocates.

Maps support generics, which are a tool for reuse. A map template:

map Map<K, V> = { [K]: V }
map StringToNumber = Map<string, f64>

A fixed set of named flags, each true or false. Declared with the set keyword:

set Flags = {
FeatureA,
FeatureB,
FeatureC
}

In Luau a set is a table with every flag as a key: { FeatureA: boolean, FeatureB: boolean, ... }. On the wire the flags are packed into groups of up to 32 – one byte for up to 8 flags, two for up to 16, four for up to 32 – so the three flags above cost one byte. A flag named twice is refused (E3004).

BlinkBlox supports two kinds of enum: unit enums and tagged enums.

Either kind travels as a one-byte index, so an enum holds between 1 and 256 values or variants. An empty one, or one past 256, is refused at compile time (E3029); a repeated value or variant is refused as a duplicate (E3004).

A set of possible values, declared with the enum keyword. The state of a character:

enum CharacterStatus = { Idling, Walking, Running, Jumping, Falling }

In Luau each value is a string: "Idling" | "Walking" | .... Unit enums do not take generics.

A set of variants, each with data attached. Declare one with enum, then a string naming the tag field, then the variants. Each variant is a name followed by the fields of a struct:

enum MouseEvent = "Type" {
Move {
Delta: vector,
Position: vector,
},
Drag {
Delta: vector,
Position: vector,
},
Click {
Button: enum { Left, Right, Middle },
Position: vector
}
}

A variant may not have a field named after the tag (E3005).

BlinkBlox has no union type; a tagged enum is how you write one:

enum Union = "Type" {
Number {
Value: f64
},
String {
Value: string
}
}

This results in the following Luau type:

type Union =
| { Type: "Number", Value: number }
| { Type: "String", Value: string }

Like maps, tagged enums support generics. A tagged union template:

enum Union<A, B> = "Type" {
A {
Value: A
},
B {
Value: B
},
}
enum NumberStringUnion = Union<f64, string>

A fixed set of named fields. Declared with the struct keyword, with no = before the braces. A theoretical game entity:

struct Entity {
Health: u8(0..100),
Position: vector,
Rotation: u8,
Animations: struct {
First: u8?,
Second: u8,
Third: u8
}
}

A field name that is not an identifier can be quoted: ["Display Name"]: string. Fields are sent in order with no names on the wire, so a struct costs exactly the sum of its fields.

A trailing comma is accepted after the last field, and after the last item of every other list in a schema: set flags, enum values and variants, generic parameters and arguments, type packs, and an event’s or function’s fields.

A struct can merge the fields of other structs into itself, the equivalent of a table union in Luau. A merge is two dots followed by the struct’s name:

struct foo {
foo: u8
}
struct bar {
bar: string
}
struct foo_bar {
..foo,
..bar
}

The resulting Luau type for foo_bar:

type foo_bar = { foo: number, bar: string }

A merge that would repeat a field is refused.

Structs, like maps and tagged enums, support generics. A packet fragment typed with a generic struct:

struct Entity {
Health: u8(0..100),
Position: vector
}
struct Fragment<T> {
Index: u8,
Sequence: u16,
Fragments: u8,
Data: T
}
struct EntitiesFragment {
..Fragment<Entity[..32]>
}
event Replicate {
From: Server,
Type: Reliable,
Call: ManyAsync,
Data: Fragment<Entity[..32]>
}

A generic struct is used where a type is expected, as in the event above, or merged into a named one.

Each composite keyword – struct, map, set, enum – declares a named type of its kind, and map and enum may also name an instance of a generic of their kind. The type keyword names anything built from a primitive: a number, string, vector, CFrame and so on, with its range, optional mark and array brackets.

struct Item {
Id: u16
}
type Coins = u32(..1000000)
type Inventory = Coins[..64]

type cannot name a struct, map, set or enum, even as an array: type Items = Item[] is refused (E3001). Write Item[] where it is used instead, or wrap it in a struct.

The unknown type holds any value that cannot be known until runtime. For unions, use tagged enums instead.

event Debug {
From: Server,
Type: Reliable,
Call: ManyAsync,
Data: unknown
}

Roblox instances, declared as Instance. Like unknown, an instance costs no buffer bytes: it travels in the remote’s instance list, and counts towards MaxInstancesPerPacket.

type AnInstance = Instance

Narrow an instance to a class by naming it in parentheses:

type Target = Instance(Player)
type Part = Instance(BasePart)

A declared type is exported to Luau under its own name, so it cannot take the name of a built-in Luau type such as number, and a top-level type cannot take the name of a Roblox type the module uses – Player, Vector3, a class it names in parentheses (E3005). A type inside a scope is exported under its scope’s name, so the Roblox names are free there. Names BlinkBlox itself uses for a type, such as Instance, CFrame or string, are refused earlier, as a primitive where a name was expected (E2002).

The receiver checks the class with IsA, so subclasses are accepted: an Instance typed as BasePart also accepts a Part. An instance of the wrong class fails the packet like any other bad value.

A position and a rotation about it in 3D space. Declared as CFrame.

type Location = CFrame

By default a CFrame is 24 bytes: the position as three f32s, and the rotation as three Euler angles, also f32.

Pass up to two number types in angle brackets. The first is used for the rotation and the last for the position, so a single type is used for both. A CFrame with an f16 rotation and an f32 position:

type MyCFrame = CFrame<f16, f32>
type SmallCFrame = CFrame<f16>

The first is 18 bytes, the second 12.

Writing quat as one of the components encodes the rotation as a unit quaternion instead of three Euler angles, in 7 bytes rather than 12: a byte naming the largest of its four components, and the other three as i16. The other component, if there is one, is the position’s type, whichever order the two are written in:

type Pose = CFrame<quat>
type SmallPose = CFrame<quat, f16>

Pose is 19 bytes (an f32 position and the quaternion), SmallPose 13.

The rotation is quantised, so it comes back within about 0.002 degrees of what was sent rather than exactly. That suits characters, projectiles and cameras. It does not suit anything that compares rotations for equality or snaps to a grid, which is why it is not the default.

quat is only a CFrame component. It is refused anywhere else – on its own, in a vector, or twice in one CFrame (E3023).

Type Bytes Encoding
Color3 3 One byte per channel. Each is rounded to the nearest of 256 steps and clamped.
BrickColor 2 The colour’s Number, as a u16.
DateTime 8 UnixTimestamp as an f64: whole seconds.
DateTimeMillis 8 UnixTimestampMillis as an f64: milliseconds. Decodes to a DateTime.
Vector2 8 X and Y as f32.
UDim 8 Scale as an f32, Offset as an i32.
UDim2 16 Two UDims, X then Y.
NumberRange 8 Min and Max as f32. A received range with Max below Min, or NaN, is refused.
ColorSequence 15 to 141 A byte for the count of keypoints, 2 to 20, then each keypoint’s Time as an f32 and its colour as a Color3. A received sequence that does not run from 0 to 1 in order is refused.
NumberSequence 25 to 241 A byte for the count of keypoints, 2 to 20, then each keypoint’s Time, Value and Envelope as f32. A received sequence that does not run from 0 to 1 in order, or with a NaN value or an envelope below 0, is refused.
TweenInfo 15 Time, RepeatCount (i32) and DelayTime; EasingStyle and EasingDirection by their Value, a byte each; Reverses as a byte.
PhysicalProperties 24 Density, Friction, Elasticity, FrictionWeight, ElasticityWeight and AcousticAbsorption as f32, which is how Roblox stores them, so nothing is lost. A received field outside its range – Density 0 to 100, Friction 0 to 2, Elasticity 0 to 1, the weights 0 to 100, AcousticAbsorption 0 to 1 – or NaN is refused. See below.
Rect 16 Min and Max, each X and Y as f32.
Region3 24 Its two corners, each as three f32. See below.
Ray 24 Origin and Direction, each as three f32.
Enum(Name) 2 An item of the Roblox enum Name, as its Value in a u16. See Roblox enums.
struct Appearance {
Tint: Color3,
Team: BrickColor,
JoinedAt: DateTime,
LastSeen: DateTimeMillis
}
struct Panel {
Size: UDim2,
Anchor: Vector2,
Fade: ColorSequence,
Open: TweenInfo
}
struct Placement {
Material: PhysicalProperties?,
Trail: NumberSequence,
Clip: Rect,
Zone: Region3,
Aim: Ray
}

PhysicalProperties.new keeps each field in its range by clamping it, and lets a NaN through unchanged. A NaN density or friction that a client sends would reach the part the server applies it to, so every field outside its range is refused on arrival, NaN included. A value an honest sender read off a part is always inside. Density’s floor is 0 here rather than the constructor’s 0.0001, because 0.0001 as an f32 is a little less than 0.0001, and the constructor raises anything below it anyway.

A part’s CustomPhysicalProperties is nil until it is set, so send it as PhysicalProperties?. All six fields go, because one built from a material cannot be told from one built from numbers: PhysicalProperties.new(Enum.Material.Plastic) has an AcousticAbsorption of 0.3, and PhysicalProperties.new(0.7, 0.3, 0.5) one of 1.

A Region3 has no constructor but Region3.new(Min, Max), which works out the centre and size from the corners and rounds both to f32. So a region arrives exactly when its corners are whole numbers, and otherwise may differ from the one sent in the last bit of its centre or size: less than a millionth of its largest component. Sending the centre and size instead differs as often.

Enum(Name) is an item of one Roblox enum, Enum.Name in Luau and in TypeScript:

struct Surface {
Material: Enum(Material),
Font: Enum(Font)?
}

An item travels as its Value and is looked up by it on arrival. Not by its position in GetEnumItems(): that list can differ between a client and a server while Roblox rolls out an engine change, and a position would then decode as a neighbouring item without any error. A Value the enum has no item for is refused. The value is checked on send too, whatever WriteValidations says: an item whose Value does not fit in a u16 would arrive as a different one. With WriteValidations, an item of another enum is refused on send.

The name must follow Enum directly: Enum(Material), not Enum (Material). Written alone, Enum is still an ordinary name – a declaration called Enum in an older schema keeps working. The compiler cannot tell whether Name is a real Roblox enum; a misspelt one fails in Luau, at the first send or read that uses it.

An exported type gets its own Read and Write functions, which turn a value into a buffer and back without an event. Exports are a way to reuse schema types elsewhere: saving to a datastore, or replicating ECS components by hand. Prefix a type’s declaration with export:

export struct MyInterface {
field: u8,
}

Exports appear in the server and client modules and, when TypesOutput is set, in the types module. Their shape:

type MyInterfaceExport = {
Read: (buffer) -> MyInterface,
Write: (MyInterface) -> buffer,
}
Example.luau
local Net = require(path.to.Client)
local Serialized = Net.MyInterface.Write({ field = 5 })
local Deserialized = Net.MyInterface.Read(Serialized)

Write returns a buffer of exactly the value’s size, and Read decodes from the start of the buffer it is given. Either throws if it fails, and leaves the module’s own outgoing batch and incoming packet untouched when it does.