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.

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

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

ParameterTypeWhat it is
callback(player, id) -> boolean?Gets the player asking and the id the table was published under.

Returns nothing.

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

ParameterTypeWhat it is
callback(player, id) -> boolean?Gets the player releasing and the published id.

Returns nothing.

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

ParameterTypeWhat it is
playerPlayerThe 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.

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

ParameterTypeWhat it is
playerPlayerThe player to test the guards against.

Returns true when every guard allowed it. A guard that errors makes it false.

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

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

ParameterTypeWhat it is
snapshotanyWire 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.

ParameterTypeWhat 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 stats if 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

view.stats.speed = 24 check and copy store it
queue patch {"stats", "speed"} one packet at end of frame
client ApplyPatches client sees 24

The proxy is a newproxy userdata with four metamethods on it.

MetamethodWhat 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, changing t later does nothing to the staged table.
  • Writing nil deletes 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.

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

CodeWhat triggers itExample 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, ReleaseTable and RequestTable.
  • Pigeon for Pigeon.StagedTable.
  • Types for the Luau types.