Relay Examples
This page demonstrates every API and method in the Relay library through practical, scenario-based examples. Each scenario illustrates a distinct usage pattern with inline comments explaining what happens at runtime.
Global Relay APIs
Relay.Inject
Registers an external library into Relay. Call this before any system that relies on :BindTo() or a custom scheduler.
Scenario A — Injecting both dependencies at startup
local Relay = require(path.to.Relay)
local Reaper = require(path.to.Reaper)
local Assignment = require(path.to.Assignment)
-- Reaper is now linked. :BindTo() will work on all signals and connections.
Relay.Inject("Reaper", Reaper)
-- Relay's internal scheduler now routes through Assignment instead of the native task library.
Relay.Inject("Assignment", Assignment)
Scenario B — Injecting only Reaper (Assignment optional)
local Relay = require(path.to.Relay)
local Reaper = require(path.to.Reaper)
Relay.Inject("Reaper", Reaper)
-- Relay works correctly. :BindTo() is now available.
-- Native task.spawn, task.defer, etc. are still used for scheduling.
Scenario C — Using :BindTo() without injecting Reaper (Studio error path)
local Relay = require(path.to.Relay)
local signal = Relay.Create("MySignal")
-- In Studio: throws a descriptive error asking you to inject Reaper first.
-- In production: no error, but the bind is silently skipped — the connection will leak.
signal:BindTo(workspace.SomePart)
Relay.Create
Creates a new signal or retrieves the existing one for a given ID. The registry is global — any script calling Create with the same ID gets the same object.
Scenario A — Creating a new signal
local combatSignal = Relay.Create("Combat_PlayerDamaged")
-- A new signal is created and stored in the global registry under "Combat_PlayerDamaged".
-- combatSignal now has :Connect(), :Fire(), :Wait(), and all other methods available.
Scenario B — Retrieving an existing signal across scripts
-- In Script A (ServerScriptService/CombatSystem):
local signal = Relay.Create("Combat_PlayerDamaged")
signal:Fire(player, 50)
-- In Script B (ServerScriptService/QuestSystem):
local signal = Relay.Create("Combat_PlayerDamaged")
-- Relay finds "Combat_PlayerDamaged" already registered.
-- Returns the same signal — no new object is created.
signal:Connect(function(player, damage)
-- This fires when CombatSystem fires the signal.
updateQuestProgress(player, damage)
end)
Scenario C — Signal ID collision (intentional registry sharing)
-- Both calls return the exact same SignalObject reference.
local a = Relay.Create("SharedEvent")
local b = Relay.Create("SharedEvent")
print(a == b) -- true
Relay.Exists
Returns whether a signal is currently alive in the registry. Useful for system readiness checks.
Scenario A — Checking before acting
if Relay.Exists("Combat_PlayerDamaged") then
-- The signal is live in the registry right now.
local signal = Relay.Create("Combat_PlayerDamaged")
signal:Connect(onDamage)
end
-- If Exists() returns false, nobody has created this signal yet.
Scenario B — Using Exists as a system readiness check
-- A subsystem checks if a coordinator signal has been established
-- before trying to hook into it.
if not Relay.Exists("DungeonSystem_Ready") then
warn("DungeonSystem has not initialised yet. Skipping hook.")
return
end
Relay.Create("DungeonSystem_Ready"):Once(onDungeonReady)
SignalObject Methods
Connect
Registers a persistent callback that runs every time the signal fires, until explicitly disconnected.
Scenario A — Persistent listener
local signal = Relay.Create("Player_Scored")
local connection = signal:Connect(function(player, points)
-- Executes every time "Player_Scored" is fired, for the lifetime of this connection.
print(player.Name .. " scored " .. points .. " points!")
end)
-- Later, when cleanup is needed:
connection:Disconnect()
-- The callback will no longer execute on future fires.
Scenario B — Connecting multiple independent listeners to the same signal
local signal = Relay.Create("Round_Started")
-- Listener 1: UI system updates the scoreboard
signal:Connect(function()
scoreboardUI.Visible = true
end)
-- Listener 2: AI system spawns enemies
signal:Connect(function()
spawnEnemyWave()
end)
-- When signal:Fire() is called, BOTH callbacks run in their own coroutines.
-- Neither blocks the other.
Once
Registers a one-shot listener. The connection is severed before the callback runs, so the callback fires exactly once no matter how many times the signal fires afterward.
Scenario A — Observing the disconnect-before-callback order
local connection
connection = Relay.Create("DataStore_Loaded"):Once(function(data)
-- By the time this line runs, connection is already disconnected.
-- This is safe to call and will return false.
print(connection:IsConnected()) -- false
-- Connecting again from inside the callback creates a fresh listener —
-- not a conflict with the one that just finished.
Relay.Create("DataStore_Loaded"):Connect(onSubsequentLoad)
initializeUI(data)
end)
Scenario B — Early cancellation before fire
local connection = Relay.Create("Match_Ended"):Once(function()
showEndScreen()
end)
-- The match was cancelled before it ended. Cancel the one-shot listener too.
if matchCancelled then
connection:Disconnect()
-- showEndScreen will never be called, even if "Match_Ended" fires later.
end
Wait
Suspends the calling coroutine until the signal fires. Returns whatever arguments were passed to :Fire().
Scenario A — Waiting for a signal in a task coroutine
task.spawn(function()
print("Waiting for the boss to spawn...")
-- This coroutine is suspended here. Other code continues running normally.
local bossModel, bossName = Relay.Create("Boss_Spawned"):Wait()
-- Execution resumes when Fire("Boss_Spawned", model, name) is called elsewhere.
print("Boss spawned: " .. bossName)
trackBossHealth(bossModel)
end)
Scenario B — Signal destroyed before it fires
task.spawn(function()
local a, b, c = Relay.Create("Countdown_Tick"):Wait()
-- If the signal is Destroy()ed before it ever fires,
-- this coroutine is resumed immediately with no arguments.
print(a, b, c) -- nil nil nil
end)
-- Somewhere else, the signal is destroyed early (e.g., round cancelled):
Relay.Create("Countdown_Tick"):Destroy()
Fire
Delivers arguments to all waiting coroutines and active connection callbacks. Each recipient runs in its own independent coroutine.
Scenario A — Firing with multiple arguments
local signal = Relay.Create("Enemy_Died")
signal:Connect(function(enemyName, killer, reward)
print(enemyName .. " was killed by " .. killer.Name .. " for " .. reward .. " XP")
end)
-- All connected callbacks and waiting coroutines receive all three arguments.
signal:Fire("Goblin", game.Players.BedQuest, 100)
Scenario B — Firing with no arguments
local signal = Relay.Create("Shop_Closed")
signal:Connect(function()
closeShopUI()
end)
-- Firing with no arguments is valid. Callbacks simply receive nothing.
signal:Fire()
Scenario C — Firing in Studio after Destroy (error path)
local signal = Relay.Create("Temp_Signal")
signal:Destroy()
-- In Studio: throws a descriptive error.
-- In production: silently does nothing.
signal:Fire("data")
Scenario D — Connecting mid-fire (deferral behaviour)
local signal = Relay.Create("Phase_Changed")
local secondConnection
signal:Connect(function(phase)
print("First listener: phase =", phase)
-- This new connection is registered during an active fire cycle.
-- It will NOT be called for this fire — only for the next one.
secondConnection = signal:Connect(function(p)
print("Second listener: phase =", p)
end)
end)
signal:Fire("A")
-- Prints: "First listener: phase = A"
-- "Second listener" does NOT print here.
signal:Fire("B")
-- Prints: "First listener: phase = B"
-- Prints: "Second listener: phase = B"
DisconnectAll
Cancels every active listener on the signal in a single call. The signal itself remains usable — new connections can be added afterward.
Scenario A — Clearing all listeners at the end of a game phase
local roundSignal = Relay.Create("Round_Event")
roundSignal:Connect(onPlayerScored)
roundSignal:Connect(onEnemyKilled)
roundSignal:Connect(updateLeaderboard)
-- Round ends. Remove all listeners in one call.
roundSignal:DisconnectAll()
-- All three callbacks are cancelled. The signal still exists and accepts new connections.
-- OnAbandoned fires here because the listener count reached zero.
Scenario B — Triggering OnAbandoned intentionally
local activeSignal = Relay.Create("Zone_Active")
activeSignal.Signals.OnAbandoned:Connect(function()
-- This fires because DisconnectAll() was called below.
shutdownZoneLoop()
end)
activeSignal:Connect(handleZoneEvent)
activeSignal:DisconnectAll()
-- handleZoneEvent is disconnected, then OnAbandoned fires → shutdownZoneLoop() runs.
GetConnectionCount
Returns how many live listeners are currently attached to the signal. Useful for skipping expensive work when nobody is listening.
Scenario A — Logging listener count for debugging
local signal = Relay.Create("GlobalTick")
signal:Connect(updateLeaderboard)
signal:Connect(tickAI)
signal:Connect(tickWeather)
print(signal:GetConnectionCount()) -- 3
signal:DisconnectAll()
print(signal:GetConnectionCount()) -- 0
Scenario B — Conditionally firing only when listeners exist
local radarSignal = Relay.Create("RadarUpdate")
if radarSignal:GetConnectionCount() > 0 then
-- At least one script is listening — the result of this call will be used.
radarSignal:Fire(getNearbyEnemies())
else
-- Nobody is listening. Skip the expensive work entirely.
print("Radar has no active listeners. Skipping update.")
end
Destroy
Permanently removes the signal from the registry, cancels all connections, and unblocks any coroutines suspended on :Wait().
Scenario A — Destroying a signal at the end of a match
local matchSignal = Relay.Create("Match_1_Event")
matchSignal:Connect(onMatchEvent)
-- Match ends. Fully remove the signal and release all associated resources.
matchSignal:Destroy()
-- "Match_1_Event" is no longer in the registry.
-- Any coroutines waiting on this signal are resumed immediately with nil arguments.
-- The OnAbandoned companion is also destroyed.
-- A fresh signal can be created for the next match:
local newMatchSignal = Relay.Create("Match_2_Event")
Scenario B — Calling Destroy twice (idempotent)
local signal = Relay.Create("OneTime_Event")
signal:Destroy()
-- Destroy is safe to call more than once. The second call is silently ignored.
signal:Destroy()
BindTo (Signal)
Ties the signal's lifetime to a Reaper-tracked object. When the object is destroyed, the signal is destroyed automatically.
Scenario A — Binding a signal to a model's lifetime
local dungeonModel = workspace:WaitForChild("Dungeon_Instance_1")
local dungeonSignal = Relay.Create("Dungeon_1_BossKilled")
-- When Reaper detects that dungeonModel has been destroyed,
-- Relay automatically calls :Destroy() on this signal.
dungeonSignal:BindTo(dungeonModel)
Scenario B — Chaining BindTo with Create
-- Create the signal and bind it to the map folder in a single expression.
Relay.Create("Map_Hazard_" .. mapId):BindTo(mapFolder)
-- The signal is automatically destroyed when mapFolder is removed.
ConnectionObject Methods
Disconnect
Severs the connection from its parent signal. Safe to call multiple times.
Scenario A — Manual cleanup
local connection = Relay.Create("Player_Chat"):Connect(function(msg)
filterProfanity(msg)
end)
-- Player muted. Stop the listener.
connection:Disconnect()
-- Safe to call again — already disconnected, nothing happens.
connection:Disconnect()
Scenario B — Disconnecting inside the callback
local connection
connection = Relay.Create("Reward_Ready"):Connect(function(item)
-- Disconnect before doing work to prevent double-triggers on re-entrant fires.
connection:Disconnect()
givePlayerItem(item)
end)
-- This is equivalent to :Once(), but gives you explicit control over
-- when the disconnect happens relative to the rest of the callback logic.
IsConnected
Returns whether this specific connection handle is still active.
Scenario A — Guarding against stale connection references
local connection = Relay.Create("Damage_Event"):Connect(onDamage)
-- Some time later, somewhere in the codebase:
if connection:IsConnected() then
-- Still active. Safe to disconnect or inspect.
connection:Disconnect()
end
Scenario B — Polling connection state in a loop
task.spawn(function()
local connection = Relay.Create("Heartbeat"):Connect(onHeartbeat)
while connection:IsConnected() do
-- As long as this listener is alive, do some bookkeeping.
task.wait(5)
end
print("Heartbeat listener was disconnected. Exiting poll loop.")
end)
BindTo (Connection)
Ties the connection's lifetime to a Reaper-tracked object. When the object is destroyed, the connection is disconnected automatically.
Scenario A — Binding a UI connection to the character
local character = player.Character or player.CharacterAdded:Wait()
-- When the character is removed from the game, this connection is
-- disconnected automatically — no manual cleanup needed.
Relay.Create("Combat_DamageTaken"):Connect(function(amount)
updateHealthBar(amount)
end):BindTo(character)
Scenario B — Binding multiple connections to the same target
local character = player.Character
Relay.Create("Round_Tick"):Connect(function()
healOverTime(character)
end):BindTo(character) -- Disconnects when character is destroyed
Relay.Create("Zone_Entered"):Connect(function(zone)
applyZoneEffect(character, zone)
end):BindTo(character) -- Also disconnects when character is destroyed
-- No manual cleanup needed. Both connections are tied to the character's lifetime.
Miscellaneous
SignalObject.Signals.OnAbandoned
A companion signal that fires automatically when the parent signal's listener count drops to zero. The primary tool for pausing background work when it has no audience.
Scenario A — Pausing a background loop when no listeners remain
local radarSignal = Relay.Create("RadarPing")
local radarRunning = false
local function startRadar()
if radarRunning then return end
radarRunning = true
task.spawn(function()
while radarRunning do
radarSignal:Fire(getNearbyEnemies())
task.wait(1)
end
end)
end
radarSignal.Signals.OnAbandoned:Connect(function()
-- The last listener disconnected. Stop the expensive loop immediately.
radarRunning = false
end)
-- When a player equips the radar item:
local conn = radarSignal:Connect(updateRadarUI)
startRadar()
-- When the player unequips it (and it was the only listener):
conn:Disconnect()
-- Listener count hits zero → OnAbandoned fires → radarRunning = false → loop exits.
Scenario B — One-shot teardown when a signal is abandoned