Staged Tables
A staged table is a normal looking table whose changes replicate. The server writes to it, and every client holding it sees the write. There is no sync code and nothing to compare.
The one line version
The server writes. The clients read.
local staged = Pigeon.StagedTable({ coins = 0 })
local carrier = Pigeon.new("Stats")
carrier:CaptureTable("stats", staged)
staged:GetTable().coins = 50
local carrier = Pigeon.new("Stats")
carrier:Init()
local mine = carrier:RequestTable("stats")
print(mine:GetTable().coins) -- 50
That is the whole idea. The rest of this page is the detail.
Making one
Pigeon.StagedTable(initial: {[any]: any}?) -> StageTable
Pigeon.StagedTable builds one. Pass a starting table if you have one, or
nothing for an empty one. Whatever you pass is deep copied in, so changing your
original afterwards does nothing to the staged table.
The copy is checked as it happens. If your starting table holds anything that cannot replicate, this call errors right there. See What can go in one further down.
local staged = Pigeon.StagedTable({
coins = 0,
level = 1,
unlocked = { "starter_sword" },
})
GetTable
staged:GetTable() -> {[any]: any}
The staged table object is not the table. GetTable hands you the
view, and the view is the thing you read and write.
local view = staged:GetTable()
view.coins = 100 -- a write, this replicates
print(view.coins) -- a read, 100
print(#view.unlocked) -- 1
The view is a proxy
A proxy is an object that pretends to be a table. Reads go straight through to the real data underneath. Writes go through too, but Pigeon records each one first, and that record is what gets sent to the clients.
Reading a nested table gives you another view, not the raw table. So a write deep inside is recorded just as precisely as one at the top.
view.stats = { health = 100, armour = 0 }
view.stats.health = 60 -- recorded as the path stats.health
That second line sends only stats.health. It does not resend the whole
stats table.
Careful
The view is a userdata, not a real table. table.insert(view, x) and
table.remove both error on it, and neither pairs(view) nor
ipairs(view) works. Append with view[#view + 1] = x
instead. #view and for k, v in view do both work.
One more thing about iterating. for key, value in view do hands you the
raw values, not views. A nested table you get that way is the real table
underneath, so writing to it changes the data locally and replicates nothing.
-- Wrong. This changes the data but no client hears about it.
for name, entry in view.players do
entry.score = 0
end
-- Right. Index back through the view so the write is recorded.
for name in view.players do
view.players[name].score = 0
end
A nested view follows its slot
A nested view is tied to a key, not to the table that happens to sit under that key right now. Every read looks the table up again through its parent. So keeping one in a variable is fine, even if the server later replaces the whole table.
local view = mine:GetTable()
local stats = view.stats
print(stats.health) -- 100
-- The server does: view.stats = { health = 40, shield = 5 }
print(stats.health) -- 40
print(stats.shield) -- 5, a key that was not there a moment ago
That holds at any depth, so view.a.b.c follows a replacement made at
a. It holds for a full snapshot too, which clears and refills the whole
store. #stats and for key, value in stats do read the
current table as well. Nested views are cached, so view.stats is the
same object each time you ask for it.
If the table is removed altogether, the view you kept still works and simply has
nothing to show: reads give nil, the length is 0 and a loop
over it runs zero times. Writing through it is the one thing that errors.
-- The server did: view.stats = nil
print(stats.health) -- nil
print(#stats) -- 0
stats.health = 60
-- ERR_STAGE_DETACHED | The table at root.stats is no longer part of
-- this staged table.
Put a table back in that slot and the view you were already holding picks it up again.
Note
Each level of nesting is looked up when you touch it, so a very deep read does a little more work than a shallow one. It is not an amount a normal game notices.
Publishing it
carrier:CaptureTable(id: string, Table: StageTable) -> ()
A staged table on its own goes nowhere. CaptureTable puts it on a
carrier under an id, which does two things: clients may
now ask for it by that id, and every write from now on is shipped to whoever holds
it.
local Players = game:GetService("Players")
local carrier = Pigeon.new("Stats")
Players.PlayerAdded:Connect(function(player)
local staged = Pigeon.StagedTable({ coins = 0 })
carrier:CaptureTable("stats:" .. player.UserId, staged)
end)
The id is separate from the channel name. The channel name is what the two machines agree on to find each other. The id picks one table out of the many that channel may be carrying.
Calling this on the client errors with ERR_NOT_SERVER.
Careful
A staged table holds one write hook, and capturing sets it. Capture the same staged table again, on another carrier or under another id, and the new capture replaces the old hook. Only the most recent one sends patches.
Capture on a reliable carrier
Snapshots and patches are ordinary fire and forget broadcasts, so they take whatever
lane the carrier is set to. On a carrier built with
{ Unreliable = true } that is the lossy lane, and a dropped patch is
never resent, so the mirror is wrong from then on and nothing corrects it.
Warning
Do not capture a staged table on an unreliable carrier. Keep the table on a
reliable one and use a separate unreliable carrier for the traffic that can
afford to be lost. The same goes for flipping
carrier.Unreliable to true on a carrier that is already
carrying a table. See Unreliable Sending.
Getting it
carrier:RequestTable(id: string) -> StageTable
On the client, RequestTable asks the server for the table under an id
and yields until the first snapshot lands. After that the mirror is cached on the
carrier, so asking again is free and hands back the same live object.
local Players = game:GetService("Players")
local carrier = Pigeon.new("Stats")
carrier:Init()
local mine = carrier:RequestTable("stats:" .. Players.LocalPlayer.UserId)
local view = mine:GetTable()
print(view.coins)
-- Somewhere else, later. No round trip, no yield.
local same = carrier:RequestTable("stats:" .. Players.LocalPlayer.UserId)
print(same == mine) -- true
It errors with ERR_CAPTURE_DENIED if the server does not
hand the table over. The usual causes:
- No table is published under that id. The server also warns with
ERR_NO_TABLE. - A capture guard said no.
- No reply arrived before the timeout, which is 10 seconds unless you changed it.
Wrap it in pcall if a refusal is a normal outcome for you.
local ok, mine = pcall(function()
return carrier:RequestTable("stats:" .. someOtherUserId)
end)
if not ok then
print("not allowed to hold that one")
end
Calling this on the server errors with ERR_NOT_CLIENT.
Warning
You must call carrier:Init() on the client. The first snapshot
arrives either way, because it is a reply and replies are never queued. Every
update after that is a normal broadcast, so it sits in the server's queue until
you call Init and is thrown away after 30 seconds. Without
Init you get one snapshot and then silence. See
Startup and Buffering.
How it travels
Capture, once
Patch, every time you write
Note
Those reserved names all start with __pigeon_. Do not use that
prefix for your own events.
Pushing it out
carrier:ForceTable(id: string, Table: StageTable, Targets: ({Player} | Player)?) -> ()
ForceTable sends a table to clients who never asked for it. Pass one
player, a list of players, or nothing at all for everyone in the game.
-- Everyone gets the world state whether they asked or not.
carrier:ForceTable("world", worldTable)
-- Just this player gets their own row.
local function pushRow(player, staged)
carrier:ForceTable("stats:" .. player.UserId, staged, player)
end
If the table is not published under that id yet, ForceTable captures it
for you first, so you do not need a separate CaptureTable call.
Capture guards are not consulted. They exist to vet clients that ask for a table. This is the server deciding, so there is nothing to vet.
Recipients get the table cached under that id straight away, so a later
RequestTable for the same id on their side returns it with no round trip
and no yield.
Calling this on the client errors with ERR_NOT_SERVER.
Giving it up
There are three ways to let go, and they do very different things. One is the client handing the table back, one is the server dropping its own pointer, and one is the server taking the table off a client.
Release Shared
staged:Release() -> ()
Called on a mirror you got from RequestTable or ForceTable,
this stops the subscription. The carrier drops its cached mirror and tells the server
to take you off the list, so no more patches are sent for that table. A release guard
can refuse that last part, which is covered under Guards.
It does not error on the server, but it does almost nothing there. A staged table on
the server has no release hook, so Release only flips a flag: it does not
stop replication and it does not drop subscribers. Use ReleaseTable or
destroy the carrier for that.
local other = carrier:RequestTable("stats:" .. friendUserId)
showFriendPanel(other:GetTable())
-- Panel closed. Stop paying for updates we no longer look at.
other:Release()
The object you hold keeps the data it had, but it stops changing. A later
RequestTable for the same id fetches a fresh mirror with a fresh
snapshot. Calling Release twice does nothing the second time.
ReleaseTable Server
carrier:ReleaseTable(id: string) -> ()
This is not the opposite of CaptureTable. It drops the carrier's pointer
to the table and nothing else. Read that twice, because it surprises people.
After ReleaseTable | What happens |
|---|---|
A new RequestTable for that id |
Refused. The server warns ERR_NO_TABLE and the client errors. |
| Clients that already hold it | Keep holding it and keep getting patches. |
| You write to the table again | The write still goes out to those clients. |
A client calls Release |
The server can no longer find the table, so it is not taken off the list. |
If you want replication to actually stop, drop the carrier with
carrier:Destroy(). That clears the write hook and the subscriber list on
every table the carrier was pointing at. The staged tables themselves survive, so any
of them can be captured again by another carrier. See Cleanup.
Calling ReleaseTable on the client errors with
ERR_NOT_SERVER.
Revoke Server
carrier:Revoke(Targets: {Player} | Player) -> number
This is the server taking the table back off a client. Pass one player or a list of them. Each one loses their place in the carrier's admission list, and comes off the subscriber list of every staged table the carrier holds. The full reference is on the Carrier page.
If it took them off at least one table, they are sent a reserved
__pigeon_table_revoke message naming those ids. Their mirrors let go when
it lands: the entry leaves the carrier's cache, the release hook is cleared so the
client does not tell the server something it already knows, and the mirror is marked
released.
Returns how many of the players you named had actually been admitted. A player who never passed the handshake, and a player you revoked a moment ago, both count for nothing.
local match = Pigeon.new("Match")
-- They left the match, so they let go of every table this carrier holds.
local removed = match:Revoke(player)
print(removed) -- 1 if they were admitted, 0 if not
That player is now shut out in both directions on this carrier. Every
Broadcast, BroadcastTo, BroadcastExcept and
SendToRoom skips them, CallTo returns nil without
asking, and anything they send is dropped before your handlers see it. No more patches
reach their mirror either, because they are off the subscriber list.
Careful
Revoking only blocks sending on a carrier that has a
handshake installed, because that filter is the only
thing that reads the admission list. On a carrier with no guard, Revoke
still takes them off every staged table, but broadcasts keep reaching them.
Warning
Revoking cuts off the future, it cannot undo the past. The data their mirror already
holds stays in their memory, and it is still there for them to read. Do not treat
Revoke as a way of making something you already sent secret again.
Revoking also takes the player out of every room on the carrier, so there is no
corner of the channel left open to them. None of it is permanent though. The
client can call Handshake again and be judged afresh, then ask for
the table again and get a new mirror with a new snapshot. Rooms are the one thing
that does not come back by itself, so put them back with JoinRoom.
Which one you want
Release | ReleaseTable | Revoke | |
|---|---|---|---|
| Who calls it | The client | The server | The server |
| What it is for | Giving up a table you no longer read | Dropping the carrier's pointer to a table | Taking a table off one client |
| Named by | The mirror itself | One id | One or more players, every table on the carrier |
| Who stops getting patches | Just the caller | Nobody, subscribers carry on | Just the players you named |
| Can it be refused | Yes, by a release guard | No | No |
Effect on later RequestTable |
Fetches a fresh mirror | Refused, the id is gone | Refused while the handshake keeps them out |
| Returns | Nothing | Nothing | How many were admitted |
Guards
A guard is a function that decides whether a client may do something. Staged tables take two kinds.
staged:UseCapture(Callback: (player: Player, id: string) -> boolean?) -> ()
staged:UseRelease(Callback: (player: Player, id: string) -> boolean?) -> ()
UseCapture runs when a client calls RequestTable.
UseRelease runs when a client calls Release. Both hand your
function the player and the id the table was captured under.
The rules are short:
- Return
falseto block. - Return anything else, including nothing at all, to allow.
- A guard that errors blocks, silently. There is no warning.
- You may add as many as you like. They run in the order you added them, and the first refusal stops the rest from running at all.
Only let a player capture their own data
local Players = game:GetService("Players")
local carrier = Pigeon.new("Stats")
Players.PlayerAdded:Connect(function(player)
local staged = Pigeon.StagedTable({ coins = 0, level = 1 })
local id = "stats:" .. player.UserId
-- The only person allowed to hold this one is its owner.
staged:UseCapture(function(who)
return who == player
end)
carrier:CaptureTable(id, staged)
end)
Anyone else asking for stats:123 gets an error out of
RequestTable and never sees a byte of it.
Careful
Blocking a release is a strange thing to do. The client has already dropped its
local mirror by the time your guard runs, so the server keeps sending patches to
a client that ignores them. Use UseRelease for bookkeeping, not to
hold someone hostage.
What can go in one
Only plain Roblox data. A staged table replicates state, so everything in it has to be rebuildable on the far side from the wire alone.
| Group | Allowed |
|---|---|
| Basics | nil, boolean, number, string, buffer |
| Space and shape | Axes, CFrame, Faces, NumberRange, Ray, Rect, Region3, Region3int16, UDim, UDim2, Vector2, Vector2int16, Vector3, Vector3int16 |
| Looks | BrickColor, Color3, ColorSequence, ColorSequenceKeypoint, Font, NumberSequence, NumberSequenceKeypoint, TweenInfo |
| The rest | CatalogSearchParams, DateTime, EnumItem, OverlapParams, PathWaypoint, PhysicalProperties, Random, RaycastParams |
| And | Tables made only of the above, nested as deep as you like. |
Anything else is rejected with an error the moment you write it. The write does not land, so a bad value never leaves half a change behind.
| What you wrote | Error |
|---|---|
| An Instance, or any other type not listed above | ERR_STAGE_UNSUPPORTED |
| A function or a thread | ERR_STAGE_BEHAVIOUR |
| A table with a metatable, which includes class instances and staged table objects | ERR_STAGE_METATABLE |
| A table that refers back into itself | ERR_STAGE_CYCLE |
| A table used as a key | ERR_STAGE_TABLE_KEY |
Every message names the exact path it choked on, counted from root.
view.stats = { health = workspace.Part }
-- ERR_STAGE_UNSUPPORTED | Staged tables carry plain Roblox data only;
-- got Instance at root.stats.health.
Instances are out because a reference means nothing on the other machine. Functions and threads are out because behaviour cannot cross a wire. Metatables are out because the far side would only ever get the plain data, never the class. If you need to point at an Instance, store something you can look it up with, like a name or an attribute.
Keys
Keys are checked the same way, so an Instance key or a function key is an error too. What you gain is that any value type works as a key, and mixing string and number keys in one table is safe. Pigeon sends tables as two parallel arrays of keys and values rather than as a keyed table, which is what makes that work.
Note
Keys that are userdata, such as CFrame or Color3, arrive
with the right contents but as a fresh object. Luau looks those up by identity, so
a freshly built Color3.new(1, 0, 0) will not find the entry on the
client. Strings, numbers, booleans, Vector3 and EnumItem
keys are all fine.
Batching
Writes are not sent one packet each. Everything written in the same frame is gathered up and goes out as one patch message at the end of it.
-- One message, not five.
view.coins = 100
view.level = 3
view.stats.health = 60
view.stats.armour = 12
view.lastSeen = os.time()
So filling a table field by field costs one send, and you never have to batch by hand or build a change list yourself. The order you wrote in is the order the patches are applied in, so writing the same key twice ends with the second value.
If nobody is holding the table, the batch is dropped rather than saved. That is fine. The next client to capture gets a full snapshot of the current contents anyway.
The client copy is a mirror
The client's view is backed by the same proxy machinery, so it reads and writes like a table. But its write hook is left unset on purpose. A local write updates the local view and goes nowhere near the server.
local view = mine:GetTable()
view.coins = 999999
print(view.coins) -- 999999, on this client only
-- The server never hears about it, and the next patch that
-- touches coins wipes it out.
Local writes are still type checked, so writing an Instance into a mirror errors just as it does on the server. Treat a mirror as read only. If a client needs to change something, send a normal message and let the server write.
-- The right shape. Ask, do not write.
carrier:Emit("SpendCoins", 50)
There is no change signal
Pigeon does not tell you when a patch lands. There is no Changed event
on a staged table. It keeps the data current and that is all.
So read the value when you need it, or have the server send a normal message alongside the write when something needs to react.
view.coins = view.coins + 50
carrier:BroadcastTo({ player }, "CoinsChanged")
carrier:On("CoinsChanged", function()
label.Text = tostring(view.coins)
end)
-- Nothing inbound arrives until this is called.
carrier:Init()
A full example: player stats
One staged table per player, owned by that player, with the whole life cycle wired up.
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Pigeon = require(ReplicatedStorage.Pigeon)
local stats = Pigeon.new("Stats")
local tables = {}
local function idFor(player)
return "stats:" .. player.UserId
end
Players.PlayerAdded:Connect(function(player)
local staged = Pigeon.StagedTable({
coins = 0,
level = 1,
unlocked = { "starter_sword" },
combat = { health = 100, armour = 0 },
})
-- Nobody may hold another player's row.
staged:UseCapture(function(who)
return who == player
end)
stats:CaptureTable(idFor(player), staged)
tables[player] = staged
end)
Players.PlayerRemoving:Connect(function(player)
stats:ReleaseTable(idFor(player))
tables[player] = nil
end)
-- Call this from anywhere. The client sees it without another line of code.
local function award(player, coins)
local staged = tables[player]
if not staged then
return
end
local view = staged:GetTable()
view.coins = view.coins + coins
-- Both writes ride in the same message.
if view.coins >= 1000 then
view.level = view.level + 1
end
stats:BroadcastTo({ player }, "StatsChanged")
end
local function unlock(player, itemId)
local view = tables[player]:GetTable()
view.unlocked[#view.unlocked + 1] = itemId
stats:BroadcastTo({ player }, "StatsChanged")
end
local Players = game:GetService("Players")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Pigeon = require(ReplicatedStorage.Pigeon)
local stats = Pigeon.new("Stats")
local view
stats:On("StatsChanged", function()
if view then
redrawHud(view)
end
end)
-- Handlers first, then open the inbound side, then ask for the table.
stats:Init()
local mine = stats:RequestTable("stats:" .. Players.LocalPlayer.UserId)
view = mine:GetTable()
print(view.coins, view.level)
print(view.combat.health)
for index, itemId in view.unlocked do
print(index, itemId)
end
redrawHud(view)
Nothing in there copies data by hand. The server writes to a table and the client reads a table.
Things to watch out for
| Situation | What happens |
|---|---|
The client never calls Init |
The first snapshot arrives, then nothing. Patches queue and expire after 30 seconds. |
| The server replaces a whole nested table | Clients see the new contents, and a nested view read before the swap follows it. Writing a leaf still sends less data than replacing the table around it, so patch leaves where you can. |
| The same staged table is captured twice | The newest capture replaces the write hook. Only it sends patches. |
Writing nil to a key |
Deletes the key, and the deletion replicates like any other write. |
| The carrier has a handshake | A client that has not passed the guard never reaches the request handler, so RequestTable waits out the timeout and then errors. Patches are broadcasts, so they are filtered out too. |
ForceTable to a client that has not passed the handshake |
They are marked as a subscriber but the snapshot is filtered out, so they get nothing. |
| A player leaves | They are taken off the subscriber list of every table the carrier still points at. |
| The carrier is dropped on the client | Its mirrors are marked released. The server is not told, so it keeps them on the list until they leave. |
| The carrier is unreliable | Snapshots and patches take the lossy lane and a dropped one is never resent, so the mirror goes stale for good. Capture on a reliable carrier. |
What to read next
- StagedTable for the full method list.
- Carrier for
CaptureTable,RequestTable,ForceTableandReleaseTable. - Carrier: Revoke for taking a table back off a client.
- Startup and Buffering for why
Initmatters so much here. - Known Issues for the sharp edges.