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.
Size on the wire
Section titled “Size on the wire”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.
Booleans and optionals share a bitfield
Section titled “Booleans and optionals share a bitfield”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.
Lengths are offset-encoded
Section titled “Lengths are offset-encoded”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
Section titled “Ranges”Ranges can be used with numbers, strings, buffers, vectors and arrays to limit the values they can represent.
Full Ranges
Section titled “Full Ranges”A full range has both a minimum and a maximum: 0..100.
Half Ranges
Section titled “Half Ranges”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.
Exact Ranges
Section titled “Exact Ranges”An exact range has a single value, such as 0 or 100. On a length it means exactly that length.
Examples
Section titled “Examples”| 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.
Numbers
Section titled “Numbers”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.
Unsigned Integers
Section titled “Unsigned Integers”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 |
Signed Integers
Section titled “Signed Integers”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
Section titled “Floating points”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.
Bounding Numbers
Section titled “Bounding Numbers”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 = f64Fixed-point numbers
Section titled “Fixed-point numbers”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,i24andi32. 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)andu8<0.5>(0..200)are both refused (E3009). Without a range, the bounds are the integer’s scaled:u16<0.1>holds0to6553.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.6in au16<0.1>is 65536 tenths and arrives as0. Addsaturateto 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
numberin Luau and TypeScript. - The scale is part of the schema signature, since the same bytes mean something else at another scale.
Quantized floats
Section titled “Quantized floats”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.
Saturating on send
Section titled “Saturating on send”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:
300sends255,-4sends1, and254.5sends255. 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
f16stops at65,504instead of arriving as infinity. An open side off32orf64clamps nothing. NaNis sent as the value in range nearest zero:0when the range holds it, otherwise the bound closer to it –1foru8<saturate>(1..255). The alternatives were worse: sendingNaNmakes 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>.saturateis not a keyword, so a field or type may still be called that.
Replacing hand-written clamps
Section titled “Replacing hand-written clamps”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 u16Net.Probe.Fire({ FrameTime = math.clamp(math.round(Milliseconds * 10), 0, 65535), Count = math.clamp(Count, 1, 255),})-- and on the serverNet.Probe.On(function(Player, Probe) local Milliseconds = Probe.FrameTime / 10end)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.FrameTimeend)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)endStrings
Section titled “Strings”Luau’s text container, declared as string.
Bounding Strings
Section titled “Bounding Strings”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.
Booleans
Section titled “Booleans”true or false, declared as boolean. A boolean costs one bit; see
the bitfield.
type Success = booleanBuffers
Section titled “Buffers”Declared as buffer. A buffer lets you pass your own serialised data while still taking advantage of
BlinkBlox’s batching.
Bounding Buffers
Section titled “Bounding Buffers”Bound a buffer’s size, in bytes, by placing a range in parentheses after the type. Sizes are sent like string lengths.
type BinaryBlob = buffertype Chunk = buffer(..800)Vectors
Section titled “Vectors”A vector in 3D space, most often a position. Declared as vector, it is a Vector3 in Luau.
Bounding Vectors
Section titled “Bounding Vectors”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.
Specifying Encoding
Section titled “Specifying Encoding”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 = vectortype NearPosition = vector<f24>type GridCell = vector<i16>type CompactOffset = vector<f16>Optionals
Section titled “Optionals”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.
Arrays
Section titled “Arrays”A list of values of one type, written as the type followed by square brackets. An array of strings:
type Names = string[]Bounding Arrays
Section titled “Bounding Arrays”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.
Arrays of booleans
Section titled “Arrays of booleans”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.
Elements that cost nothing
Section titled “Elements that cost nothing”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 }What can be a key
Section titled “What can be a key”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):
-- 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 } }Maps cannot be bounded
Section titled “Maps cannot be bounded”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.
Generics
Section titled “Generics”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).
Unit Enums
Section titled “Unit Enums”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.
Tagged Enums
Section titled “Tagged Enums”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 }Generics
Section titled “Generics”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>Structs
Section titled “Structs”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.
Merging
Section titled “Merging”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.
Generics
Section titled “Generics”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.
Naming types
Section titled “Naming types”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.
Unknowns
Section titled “Unknowns”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}Instances
Section titled “Instances”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 = InstanceSpecifying Class
Section titled “Specifying Class”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.
CFrames
Section titled “CFrames”A position and a rotation about it in 3D space. Declared as CFrame.
type Location = CFrameBy default a CFrame is 24 bytes: the position as three f32s, and the rotation as three Euler angles,
also f32.
Specifying Encoding
Section titled “Specifying Encoding”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.
Quaternion Rotation
Section titled “Quaternion Rotation”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).
Other Roblox Types
Section titled “Other Roblox Types”| 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
Section titled “PhysicalProperties”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.
Region3
Section titled “Region3”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.
Roblox enums
Section titled “Roblox enums”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.
Exports
Section titled “Exports”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,}Usage in Luau
Section titled “Usage in Luau”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,}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.
