Cleanup

Turning things off. One call drops a carrier and everything it subscribed to, and a dropped carrier goes quiet rather than erroring on you.

Destroy Shared

carrier:Destroy() -> ()

Takes no parameters and returns nothing. Works the same on the server and on the client.

Server
local shop = Pigeon.new("Shop")

shop:On("Buy", function(player, itemId)
	print(player.Name, "bought", itemId)
end)

shop:Broadcast("Open", true)

shop:Destroy()

That single call does all of this:

WhatResult
Transport bindingsEvery subscription the carrier made is disconnected. Nothing arrives any more.
Listeners and respondersCleared. Your On listeners and your When responders are both dropped.
RoomsCleared. Every room this carrier held is gone.
HandshakeThe guard is removed and the list of admitted players is emptied.
MiddlewareBoth chains are emptied, so the closures you passed are released.
Staged tablesReplication stops. See below for the detail.
Engine connectionsThe PlayerRemoving hook the carrier keeps on the server is disconnected.
SendingThe carrier is inert. Nothing it sends leaves the machine.
The name cacheThe carrier is evicted, so the next Pigeon.new builds a live one.
Its transformerDestroyed, but only if the carrier made it itself.

Middleware is cleared for a reason worth saying out loud. A middleware function is a closure you wrote, and it can hold a reference to anything at all. Leaving those on a dead object would keep whatever they captured alive.

Calling it twice is safe

The second call does nothing. Teardowns run once and only once, so you never have to guard it or track whether you already did it.

shop:Destroy()
shop:Destroy()   -- fine, does nothing

A dropped carrier goes quiet

Sending on a dead carrier does not throw. It just does not happen. Teardown often races with work already in flight, and an error there would spread into code that did nothing wrong.

Call on a dropped carrierWhat happens
Emit, Broadcast, BroadcastTo, BroadcastExceptReturns immediately. Nothing sent.
Call, CallToReturns nil straight away. Does not yield.
HandshakeReturns false.
InitReturns the carrier and does nothing else.
PingReturns a number near zero. The round trip never happened.
On, WhenDoes nothing. Nothing is registered and your callback is not kept.
OffDoes nothing. The listener list and the responder slot are already empty.
DestroyDoes nothing.
Client
local shop = Pigeon.new("Shop")
shop:Destroy()

shop:Emit("Buy", "sword")             -- nothing happens, no error
local answer = shop:Call("Anything")  -- nil, returns at once, does not yield

Three calls do not stay silent.

Call on a dropped carrierWhat happens
SendToRoomWarns ERR_NO_ROOM and gives up. Destroy cleared the rooms, so the name it is given no longer exists. Nothing is sent.
RequestTableThrows ERR_CAPTURE_DENIED. The request underneath returns nil, which reads as a refusal.
ApprovedReturns every player you passed it, or every player in the game if you passed none. Destroy removes the guard, and no guard means no filter. See Known Issues.

The wrong-machine checks still fire. Calling a server method on the client throws ERR_NOT_SERVER whether the carrier is alive or not, and the same the other way round. Being dead does not excuse calling Broadcast from a LocalScript.

It leaves the name cache

Pigeon.new("Shop") with no options hands back a cached carrier. Destroy removes that entry, so the next lookup builds a fresh live one instead of handing you the corpse.

local a = Pigeon.new("Shop")
print(a == Pigeon.new("Shop"))   --> true, same object

a:Destroy()

local b = Pigeon.new("Shop")
print(a == b)                    --> false, b is a new live carrier

Careful

That cache is shared. Every part of your code that asks for "Shop" holds the same object, so destroying it turns the channel off for all of them, not just for you. Only destroy a cached carrier when you own the whole channel. If you want a carrier of your own to destroy, build it with options, which never gets cached. See Carriers.

Destroying a transformer drops every carrier on it

A transformer is the thing that picks which remote a carrier's traffic rides on. Every carrier that rides on one registers its own teardown there, so destroying the transformer destroys all of them.

Server
local combat = Pigeon.Transformer("Combat")

local damage = Pigeon.new("Damage", { Transformer = combat })
local status = Pigeon.new("Status", { Transformer = combat })
local effects = Pigeon.new("Effects", { Transformer = combat })

-- One call drops all three, with every listener on them.
combat:Destroy()

damage:Broadcast("Hit", 10)   -- goes nowhere, the carrier is inert

This is the tidiest way to turn off a whole feature. Put its channels on one transformer when you build them and you have a single switch later. See Transformers.

Ownership decides which way it runs. A carrier that made its own transformer takes it down with it. A transformer you passed in belongs to you, so carrier:Destroy() leaves it alone.

How you built itcarrier:Destroy()transformer:Destroy()
Pigeon.new("Shop")Drops the carrier and its transformerDrops the carrier
Pigeon.new("Shop", { Transformer = t })Drops the carrier, leaves t aloneDrops every carrier on t

Staged tables survive

A staged table is not owned by the carrier that published it. The carrier is only a pointer to it. Destroying the carrier stops the replication, but the table object and its contents are untouched.

Server
local scores = Pigeon.StagedTable({ round = 1 })

local old = Pigeon.new("Match")
old:CaptureTable("Scores", scores)

old:Destroy()

-- The data is still there, and a new carrier can publish it again.
print(scores:GetTable().round)   --> 1

local fresh = Pigeon.new("Match")
fresh:CaptureTable("Scores", scores)

What Destroy does to each side:

SideEffect on its staged tables
ServerWrites stop being broadcast, and the subscriber list is emptied. The table keeps its data.
ClientThe local mirror is marked released and stops updating. Its release hook is removed.

Careful

Destroying a carrier on the client does not tell the server about it. The server still counts that player as a subscriber until they leave the game. If you want the server told, call Release() on the mirror before you destroy the carrier. See Staged Tables.

Turning off one listener instead

Destroy is the big hammer. Most of the time you only want one handler gone, and that is Off.

Off Shared

carrier:Off(event: string, Callback: ((...any) -> ...any)?) -> ()
ParameterTypeWhat it is
eventstringThe event name to unbind.
Callback((...any) -> ...any)?The one handler to drop, listener or responder. Leave it out to drop every handler for the event.

Returns nothing.

Server
local shop = Pigeon.new("Shop")

local function onBuy(player, itemId)
	print(player.Name, itemId)
end

shop:On("Buy", onBuy)

shop:Off("Buy", onBuy)   -- drop this one handler
shop:Off("Buy")          -- drop every handler for "Buy"

When the last handler for an event is removed, Pigeon drops the underlying transport binding too. So Off is real cleanup, not just a filter. The carrier itself keeps working.

Players who leave clean up on their own

You do not have to do anything when a player disconnects. On the server, Pigeon removes them from every room on the carrier, from the list of players the handshake admitted, and from the subscriber list of every staged table the carrier holds. Anything the server was still holding for that player is thrown away.

A teardown checklist

Tearing down a feature, in order.

Shutting a system down

your own connections release staged tables Destroy the carrier or transformer drop your references
  1. Disconnect anything you connected yourself. Heartbeat loops, Touched signals, tween completions. Pigeon does not know about those. You can hand them to transformer:Track so they go with the group, which is what Transformer covers.
  2. On the client, call Release() on any staged table mirror you no longer want, so the server stops sending you patches.
  3. Call Destroy. On the carrier if it is one channel, on the shared transformer if the feature is several channels that belong together.
  4. Set your references to nil. The carrier is inert but it is still an object, and something has to let go of it.
  5. Do not expect On to work on it afterwards. It registers nothing. See the note below.

Checking nothing leaked

The transport can tell you what it is still holding on this machine. Useful in tests and when you suspect something is not letting go.

Pigeon.Network.Diagnostics() -> {listeners: number, watchers: number, orphaned: number, pending: number, readySeats: number, bufferedPackets: number, readyChannels: number, bufferedChannels: number, outbound: number}

Takes no parameters. Returns one table of counts.

local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Pigeon = require(ReplicatedStorage.Pigeon)

local counts = Pigeon.Network.Diagnostics()
print(counts.listeners, counts.pending, counts.bufferedPackets)
FieldWhat it counts
listenersEvent names with at least one transport binding.
watchersChannel watchers, added up across every channel. One per live carrier.
orphanedPackets held because nothing was listening yet.
pendingRequests still waiting for an answer.
readySeatsPlayer and channel pairs that have called Init.
readyChannelsChannels with at least one ready client.
bufferedPacketsMessages queued for clients that are not ready.
bufferedChannelsChannels with something queued.
outboundPackets the client is holding until the remote pool replicates. Always 0 on the server.

These are counts for the whole machine, not for one carrier. After you tear a system down they should settle back to where they were. Queues and pending requests expire on their own, so give it a few seconds before you decide something is stuck.

On after Destroy

Note

On and When both return straight away on a dropped carrier. No listener and no responder is registered, nothing is bound underneath, and the callback you passed is not kept, so it cannot hold anything alive. That holds whether or not the transformer you passed in is still alive. There is no way to bring a dropped carrier back, so build a new one when you need the channel again. See Known Issues.

What to read next