Skip to content

Compress

local Compress = require("@game/ReplicatedStorage/Twill").Compress
local packed = Compress.Encode(bigTable)
MessagingService:PublishAsync("Topic", packed)
local original = Compress.Decode(packed)

What comes out is text, so it survives every transport that carries JSON: DataStores, MessagingService, MemoryStore, attributes, remotes.

Underneath, a serializer turns the value into bytes and an entropy coder shrinks them, both of them Twill’s own. Their output is not valid UTF-8 and is refused by every one of those transports, which is the trap this module exists to close.

Roblox values are handled natively, so Serialize is not needed first.

Encode always builds the packed form, then reads the value as plain JSON where the value can survive it. Plain JSON wins on a tie, and the packed form wins otherwise.

The result is therefore never longer than the JSON of the same value. Small values come back as plain JSON, because packing eight bits into a character JSON accepts costs more than the byte it replaced, whatever alphabet is used.

The packed form starts with ~, a character no JSON value can begin with, which is how Decode tells the two apart.

Compression itself is tried only once the serialized bytes reach 128, and kept only when it comes out smaller. Below that the compressor cannot win, so it is not run at all.

The byte count travels with the payload, in four bytes ahead of it, so text that was cut short is refused rather than read as a smaller value.

A truncated save that looks complete is worse than one that fails, and transports with a size ceiling do cut payloads.

It is a length and not a checksum, so bytes altered in place still read. That is the right trade for transports that lose the tail but never rewrite the middle.

This matters most for saved data.

ValueWhat survives
Color3, ColorSequenceQuantised to 8 bits per channel.
CFramePosition and axis angle at f32.
DateTimeWhole seconds.
InstanceTravels as a path. Resolves to nil where it does not exist.
Functions, threads, buffersBecome nil.
Cyclic branchesBecome nil.
A branch nested past 96 levelsRefused, on the way in and the way out.

Where any of that matters, use Serialize instead, which is exact.

[Server] | [Client]

Turns a value into compact text that every text transport will carry.

function Compress.Encode(value: any): string

Parameters

NameTypeDescription
valueanyAnything. What cannot be represented travels as nil, per the table above.

Returns

string - Text safe to store and to send, never longer than the plain reading of the same value.

This is lossy by design: it makes a value small and opaque rather than exact and readable. Where a person or another system reads the field, or where precision matters, encode it for storage instead of compressing it.

[Server] | [Client]

Rebuilds the value behind text that was encoded here.

function Compress.Decode(text: string): any

Parameters

NameTypeDescription
textstringText from Encode, in either of the two forms it produces.

Returns

any - The original value, or nil when the text cannot be read.

This never throws. Text that was truncated, altered at its length header, carried an unknown form flag, or was never a Compress payload at all comes back as nil, so a corrupted field is a missing value rather than a broken caller.

The reader never reads past the length the payload declares, and refuses a cyclic or over-deep branch on the way back out, so untrusted input costs a nil rather than a fault.

Every transport imposes its own limit on the result, and the result is text. Check the limit for the one you are sending through before assuming it fits. See Platform limits.

LimitValue
Bytes before compression is attempted128
Length header4 bytes
Deepest branch96 levels
Packed form marker~