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.

Server
local staged = Pigeon.StagedTable({ coins = 0 })
local carrier = Pigeon.new("Stats")

carrier:CaptureTable("stats", staged)

staged:GetTable().coins = 50
Client
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.

Client
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.

Server
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.

Client
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.

Client
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

carrier:RequestTable("stats") __pigeon_table_request
capture guards run server sends a full snapshot
client builds the mirror and caches it RequestTable returns

Patch, every time you write

view.coins = 50 path and value queued end of frame, one batch
__pigeon_table_patch to the subscribers applied to the mirror

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.

Server
-- 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.

Client
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 ReleaseTableWhat 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.

Server
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

ReleaseReleaseTableRevoke
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 false to 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

Server
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.

GroupAllowed
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 wroteError
An Instance, or any other type not listed aboveERR_STAGE_UNSUPPORTED
A function or a threadERR_STAGE_BEHAVIOUR
A table with a metatable, which includes class instances and staged table objectsERR_STAGE_METATABLE
A table that refers back into itselfERR_STAGE_CYCLE
A table used as a keyERR_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.

Client
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.

Client
-- 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.

Server
view.coins = view.coins + 50
carrier:BroadcastTo({ player }, "CoinsChanged")
Client
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.

Server
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
Client
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

SituationWhat 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