StagedTable
A staged table is a plain table whose changes replicate. Write to it on the server
and every client holding it sees the write. Create one with
Pigeon.StagedTable.
The Staged Tables guide covers the day to day use. This page is the method by method reference, plus the rules about what may go inside one.
Getting one
The server makes the table and a carrier publishes it under an id. The client asks for that id and gets a live mirror back.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Pigeon = require(ReplicatedStorage.Pigeon)
local staged = Pigeon.StagedTable({ health = 100, coins = 0 })
local data = Pigeon.new("PlayerData")
data:CaptureTable("Stats", staged)
-- This write goes out to everyone holding the table.
staged:GetTable().coins = 25
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Pigeon = require(ReplicatedStorage.Pigeon)
local data = Pigeon.new("PlayerData")
data:Init()
local staged = data:RequestTable("Stats")
print(staged:GetTable().coins) --> 25
The starting contents are copied in, and checked as they are copied. If you pass
something a staged table cannot carry, Pigeon.StagedTable errors on that
line. See Write errors below for the exact messages.
Note
None of the methods on this page check which side you are on. Nothing here
raises ERR_NOT_SERVER or ERR_NOT_CLIENT. The badges say
where each one is worth calling.
Methods
GetTable Shared
stagedTable:GetTable() -> {[any]: any}
Returns the live view of the table. This is the thing you read and write. Writing to it is what makes replication happen.
Returns the view. It takes no arguments.
local view = staged:GetTable()
view.health = 80
view.stats = { speed = 16 }
view.stats.speed = 24
print(view.health) --> 80
Reading a key that holds a table gives you another view. That nested view points at the key it came from, not at the table that was sitting there, and it asks its parent for the table again every time you touch it. So it keeps working when the table under it is replaced. See A nested view follows the slot.
What a write does on each side
| Side | What happens when you write |
|---|---|
| Server | The write lands locally and is queued as a patch. Everything written in the same frame goes out as one packet to the clients holding the table. |
| Client | The write lands locally and stops there. Nothing is sent back. The next patch from the server can overwrite it. |
On the server, patches only go to clients that captured the table, either by asking
for it with RequestTable or by being handed it with
ForceTable. A client that never captured it hears nothing.
Writes made before a carrier captures the table are kept. They are in the store, so the first snapshot a client receives already includes them.
Careful
The view is not a real table. It is a userdata proxy, so
typeof(view) is "userdata" and anything that needs a
genuine table, such as table.insert, table.remove or
table.concat, will not take it. Read and write keys directly
instead. See The view proxy.
UseCapture Server
stagedTable:UseCapture(callback: (player: Player, id: string) -> boolean?) -> ()
Adds a guard that runs before a client is allowed to capture this table. Return
false to refuse. Anything else lets them through.
| Parameter | Type | What it is |
|---|---|---|
callback | (player, id) -> boolean? | Gets the player asking and the id the table was published under. |
Returns nothing.
local staged = Pigeon.StagedTable(profile)
staged:UseCapture(function(player, id)
return player.UserId == ownerUserId
end)
Guards are added, not replaced. Call UseCapture twice and both run, and
both have to agree. They run in the order you added them and the first refusal stops
the rest.
Careful
A guard that errors counts as a refusal, and the error is swallowed. Nothing is
printed. If your guard does something that can fail, wrap it in your own
pcall so you can see what went wrong.
When guards are consulted
Only on the server, and only for a client asking with RequestTable. A
server side ForceTable skips them, because that is the server deciding
rather than a client asking. Adding a guard later does not take the table away from
clients that already hold it.
UseRelease Server
stagedTable:UseRelease(callback: (player: Player, id: string) -> boolean?) -> ()
The same thing for a client giving the table up. Return false to keep
that client on the subscriber list.
| Parameter | Type | What it is |
|---|---|---|
callback | (player, id) -> boolean? | Gets the player releasing and the published id. |
Returns nothing.
staged:UseRelease(function(player, id)
print(player.Name, "let go of", id)
return true
end)
Careful
Refusing a release does not give the table back to the client. By the time the server hears about it, the client has already dropped its own mirror. All a refusal does is keep the server sending patches that the client will ignore. Use this for bookkeeping, not for pinning a client to a table.
CanCapture Server
stagedTable:CanCapture(player: Player) -> boolean
Runs every capture guard for a player and says whether they all allowed it. Pigeon calls this itself when a client requests the table, so you rarely need to. It is public so you can check the same rule before pushing a table out yourself.
| Parameter | Type | What it is |
|---|---|---|
player | Player | The player to test the guards against. |
Returns true if every guard allowed it, or if there are
no guards at all. false as soon as one refuses.
if staged:CanCapture(player) then
data:ForceTable("Stats", staged, player)
end
Note
Guards are handed the id the table was captured under. That id is only set once
CaptureTable or ForceTable has published the table, so
a guard called before then gets nil.
CanRelease Server
stagedTable:CanRelease(player: Player) -> boolean
The same for the release guards. Pigeon calls it when a client tells the server it has let go of the table.
| Parameter | Type | What it is |
|---|---|---|
player | Player | The player to test the guards against. |
Returns true when every guard allowed it. A guard that
errors makes it false.
print(staged:CanRelease(player)) --> true when nothing objects
Release Shared
stagedTable:Release() -> ()
Gives up this view of the table. It runs once. Calling it again does nothing.
Returns nothing. It takes no arguments.
local data = Pigeon.new("PlayerData")
data:Init()
local staged = data:RequestTable("Stats")
-- Done with it. Stop the server sending patches for this table.
staged:Release()
-- A later request fetches it again, with a fresh round trip.
local again = data:RequestTable("Stats")
On the client this does three things:
- Marks the mirror released.
- Drops it from the carrier's cache, so the next
RequestTablefetches a new one. - Tells the server, which runs the release guards and drops you from the subscriber list if they allow it.
Careful
On the server this does far less than the name suggests. A server side staged
table has no release hook attached, so Release only flips a flag. It
does not stop replication and it does not drop subscribers. Server side
replication stops when the carrier that captured the table is destroyed.
carrier:ReleaseTable(id) is a different thing again. It drops the
carrier's pointer to the table, so no new client can request that id, but clients
already holding it keep receiving patches.
Lower level methods
Pigeon calls these three for you. The server takes a snapshot when a client captures the table, and the client applies snapshots and patches as they land. You only reach for them when you are moving a staged table around by hand, say into a test or through some other transport.
Snapshot Shared
stagedTable:Snapshot() -> any
Returns the whole table in wire form. This is what the server sends to a client that has just captured it.
Returns the encoded contents. It takes no arguments.
local staged = Pigeon.StagedTable({ health = 100, [1] = "first" })
local snapshot = staged:Snapshot()
-- One array of keys and one of values, paired by position. The order is
-- whatever the table iterated in, so it may come out the other way round.
--> { K = { "health", 1 }, V = { 100, "first" } }
Why the wire form looks like that
Roblox only serialises tables keyed by strings or by integers, and it quietly mangles a table that mixes the two. A staged table allows any value as a key, so no table ever travels as a keyed table. Each one travels as two plain arrays, one of keys and one of values, which are always safe. Leaves pass through untouched, so every table on the wire is one of these envelopes and decoding is never ambiguous.
The order of the two arrays is whatever order the table happened to iterate in. It is not sorted and it is not stable between runs.
Note
A snapshot is not a readable copy of your data. Do not try to index it like the
original. Hand it to ApplySnapshot to get the table back.
ApplySnapshot Shared
stagedTable:ApplySnapshot(snapshot: any) -> ()
Empties the table and refills it from a wire form snapshot. This is what a client does when the first snapshot arrives.
| Parameter | Type | What it is |
|---|---|---|
snapshot | any | Wire form contents, as produced by Snapshot. |
Returns nothing.
local copy = Pigeon.StagedTable()
copy:ApplySnapshot(staged:Snapshot())
print(copy:GetTable().health)
Careful
This writes straight into the store, under the view rather than through it. So it replicates nothing and it checks nothing. On the server that means your clients are now holding different data from you, and whatever you passed in is sitting in the table whether a staged table could carry it or not.
Give it Snapshot output. Anything else is read as an envelope, and a
table with no K and no V decodes to an empty table rather
than erroring.
ApplyPatches Shared
stagedTable:ApplyPatches(patches: {{path: {any}, value: any}}) -> ()
Folds a list of changes into the table, in order. Each patch is a path from the root and a wire form value. This is what a client does with every patch the server broadcasts.
| Parameter | Type | What it is |
|---|---|---|
patches | {{path: {any}, value: any}} | The changes to apply, oldest first. |
Returns nothing.
staged:ApplyPatches({
{ path = { "health" }, value = 80 },
{ path = { "stats", "speed" }, value = 16 },
})
print(staged:GetTable().stats.speed) --> 16
Details worth knowing
- Missing links in a path are created. The second patch above builds
statsif it is not there. - Anything in the way that is not a table is replaced by an empty table.
- Every path needs at least one key. An empty path has nothing to write to and errors.
- Values are decoded, so a table value must be a
{K = ..., V = ...}envelope. Pass a plain{1, 2, 3}and you get an empty table. - Like
ApplySnapshot, this checks nothing and replicates nothing.
The view proxy
GetTable does not hand back your table. It hands back a proxy that stands
in front of it. That is how Pigeon sees every write without you having to call a
setter.
One write
The proxy is a newproxy userdata with four metamethods on it.
| Metamethod | What it does |
|---|---|
__index |
Reading a key. The view resolves its own table through its parent first. A plain value comes straight out. A table value comes back as another view of that key, built on demand and held weakly. If there is no table to resolve to, the read gives nil. |
__newindex |
Writing a key. The view resolves its table, then the value is checked and deep copied, then stored, then reported as a patch with the full path from the root. If there is no table to resolve to, the write raises ERR_STAGE_DETACHED. |
__len |
#view is the length of whatever table is in that slot right now, or 0 if there is none. |
__iter |
for k, v in view walks whatever table is in that slot right now. If there is none, the loop runs zero times rather than erroring. |
Iterating gives you the raw value
This is the sharp edge. __iter hands you what is actually stored, not a
view of it. So a nested table you get out of a loop is the real inner table, and
writing to it changes the server's copy without telling anyone.
local view = staged:GetTable()
view.stats = { speed = 16 }
-- Replicates. The path {"stats", "speed"} goes out.
view.stats.speed = 24
for key, value in view do
if key == "stats" then
-- Does not replicate. `value` is the raw table, not a view.
value.speed = 99
end
end
Reading in a loop is fine. When you want to write, index down from the view instead.
Appending to a list
table.insert will not take the view, but # works, so the
long hand version does the job and replicates properly.
local view = staged:GetTable()
view.items = {}
local items = view.items
items[#items + 1] = "sword"
Other things the proxy does
- A write copies the value. After
view.stats = t, changingtlater does nothing to the staged table. - Writing
nildeletes the key, and the deletion replicates like any other write. - A rejected write leaves nothing behind. The check runs before the store, so an error means the table is untouched.
- Nested writes report the full path, so changing one field deep inside sends that field and not the whole table.
- Child views are held weakly. One that nothing keeps a reference to is collected and rebuilt on the next read.
- Reading the same key twice gives you the same view back, so
view.a == view.a.
A nested view follows the slot
A nested view does not hold the inner table. It holds the key it sits under, and asks
its parent for the table again on every read, every write, every length check and
every loop. local stats = view.stats points at the stats
key, not at the table that happened to be there when you read it.
So a view you keep around stays correct when the whole table under it is replaced,
whether that came from a patch, from ApplySnapshot, or from a plain
write. It shows the new contents, including keys that were not there before. This
holds at any depth: view.a.b.c follows a replacement made at
a.
local view = staged:GetTable()
view.stats = { speed = 16 }
local stats = view.stats
-- The whole stats table is replaced.
view.stats = { speed = 24, jump = 50 }
print(stats.speed) --> 24
print(stats.jump) --> 50
view.items = { "sword" }
local items = view.items
view.items = { "sword", "shield", "bow" }
print(#items) --> 3
If the table is taken away, the view you are holding reads as empty. Every key gives
nil, # is 0 and a loop runs zero times. None of
that errors. Writing does, with
ERR_STAGE_DETACHED. Put a table back in
that key and the view you were already holding picks it up.
Every level of nesting is resolved when you touch it, so a read four tables deep does a little more work than one at the top. It is not something a normal game notices.
What you may store
A staged table carries data, not objects. Every value has to be a plain Roblox value type, or a table with no metatable holding more of the same. The rule is checked on the starting contents and on every write.
| Group | Types |
|---|---|
| Basics | nil, boolean, number, string, buffer |
| Shape and position | CFrame, Ray, Rect, Region3, Region3int16, UDim, UDim2, Vector2, Vector2int16, Vector3, Vector3int16 |
| Colour and look | BrickColor, Color3, ColorSequence, ColorSequenceKeypoint, Font, NumberRange, NumberSequence, NumberSequenceKeypoint, TweenInfo |
| Physics and queries | Axes, Faces, OverlapParams, PathWaypoint, PhysicalProperties, RaycastParams |
| Odds and ends | CatalogSearchParams, DateTime, EnumItem, Random |
| Tables | Any table with no metatable whose keys and values follow the same rules. |
Everything else is refused. In particular:
- Instances. An Instance is a reference, not a value, and the far side cannot resolve it.
- Tables with metatables. Class instances, OOP objects and proxies are all tables with metatables, and the behaviour on them cannot cross the wire.
- Functions and threads. Same reason.
- Any other userdata, including another staged table's view.
Keys
Keys follow the same list, with one extra rule: a key may not be a table. Keys with
value semantics, such as strings, numbers, booleans and EnumItem, are
addressable again on the far side. A key that is userdata, such as a
CFrame or a Color3, survives the trip structurally, but Luau
looks those up by identity, so the rebuilt key will not match a freshly made one on
the other machine.
Write errors
A write can raise six errors. Five of them come from the same check on the value, and
all five are raised before anything is stored. Each message names the path to the
value that caused it, written as root for the top level or
root.stats.health further down. The sixth,
ERR_STAGE_DETACHED, is about where you
are writing rather than what.
| Code | What triggers it | Example that raises it |
|---|---|---|
ERR_STAGE_UNSUPPORTED |
A value or key whose type is not on the allowed list. Instances and other userdata land here. | view.model = workspace.Part |
ERR_STAGE_BEHAVIOUR |
A function or a thread, used as a value or as a key. | view.onHit = function() end |
ERR_STAGE_METATABLE |
A table with a metatable, at any depth. | view.profile = setmetatable({}, Profile) |
ERR_STAGE_CYCLE |
A table that leads back to itself down the same branch. | local t = {}; t.self = t; view.t = t |
ERR_STAGE_TABLE_KEY |
A table used as a key. | view.lookup = { [{}] = true } |
The messages themselves read like this:
ERR_STAGE_UNSUPPORTED | Staged tables carry plain Roblox data only; got Instance at root.model.
ERR_STAGE_BEHAVIOUR | Staged tables carry data, not behaviour; got function at root.onHit.
ERR_STAGE_METATABLE | Staged tables carry plain Roblox data only; got a table with a metatable at root.profile.
ERR_STAGE_CYCLE | Staged tables cannot contain cycles; root.t.self refers back into itself.
ERR_STAGE_TABLE_KEY | Staged table keys must be values, not tables; got one at root.lookup.
Note
These errors carry no script name or line number. That is deliberate, and it is why the path is in the message. Read the path to find the value that upset it.
ERR_STAGE_DETACHED
This one is not about the value. It fires when you write through a nested view whose table is no longer in the staged table. The view resolves through its parent, finds no table there, and refuses the write rather than putting it somewhere nothing will read it. The message names the path the view sits at.
ERR_STAGE_DETACHED | The table at root.stats is no longer part of this staged table.
local view = staged:GetTable()
view.stats = { speed = 16 }
local stats = view.stats
view.stats = nil
print(stats.speed) --> nil, reading is fine
stats.speed = 24 -- ERR_STAGE_DETACHED
Reads never raise it. A view over a table that has gone gives nil for
every key, a length of 0 and an empty loop. Only writing objects, and
only until something puts a table back in that key.
The same table twice is fine
The cycle check only looks down the branch it is currently walking, so one table used in two places is allowed. It arrives on the other side as two separate copies rather than one shared table.
local shared = { level = 3 }
-- Fine. Two copies land on the client.
view.a = { left = shared, right = shared }
-- Not fine. This branch leads back into itself.
local loop = {}
loop.self = loop
view.b = loop
What to read next
- Staged Tables for how to use one in a game.
- Carrier for
CaptureTable,ForceTable,ReleaseTableandRequestTable. - Pigeon for
Pigeon.StagedTable. - Types for the Luau types.