Lua Scripting

BindableEvent: telling several scripts without any of them knowing the others

BindableEvent links behaviour between scripts on the same side of the boundary. The classic use is a round-based game — and the documentation warns: when what you want is to share data, a module is usually better.

by Gustavo Cantino9 min readIntermediate

Produced with AI from the sources cited below, and checked by automated verification against those same sources. No person read this text before publication. Editorial Policy

Typographic plate with the Lua Scripting category in large type, the intermediate level below it, and a field of vertical lime green bars on a dark background.

Every game with rounds reaches the same point: when the match starts, five things have to happen in five different scripts. The timer, the scoreboard, the spawning, the music, the interface.

The naive solution is one script that knows the other four and calls each one. It works, and it creates a script that has to be edited every time somebody adds a system.

The BindableEvent solves that by inverting who knows whom.

The problem

The definition: BindableEvent and BindableFunction link behaviour between scripts on the same side of the client-server boundary. That last part matters — they do not cross the boundary. Remotes exist for that.

And the documentation names the main use case: the most common is a game with a round structure. The example given is exactly the one at the start of this text — a match-started event that makes other scripts start the timer and show the scoreboard, with a corresponding match-ended event that signals when to take the players back to the lobby.

They are usually used on the server, but they work on the client too.

And here comes a caveat the documentation itself makes, and which saves a lot of unnecessary code: events help make code more modular, but for sharing data between scripts a module is usually the better alternative.

It is a distinction worth internalising. A BindableEvent is for announcing that something happened. A module is for reading a value. Anyone using events to carry state ends up with a game where the truth is in transit, and nobody knows the current value without waiting for the next fire.

Step by step

1. Choose where the object lives. The recommendation is ServerScriptService for communication between server scripts, and ReplicatedStorage between client scripts.

2. Create the object and give it a name that describes the fact, not the reaction: MatchStarted, not StartTimer. Whoever fires it should not know what happens next.

3. Connect and fire. The usage is connecting a function to the event in one script and firing it from another.

Two properties of Fire() define the behaviour. The first: on firing, the firing script does not wait. It carries on in the same frame, and the listeners run on their own.

The second: like every event, it creates threads for each connected function — so if one errors, the others carry on. It is resilience for free: a broken system does not bring down the other four.

4. Use BindableFunction when you need an answer. It allows synchronous, two-way communication: you define a callback of your own, called manually through the Invoke() method.

The operational difference is large. The invoking code waits until the corresponding callback is found. And there is a severe consequence: if the callback was never defined, the invoking script never resumes execution.

It is not an error, it is not nil, it is not a timeout. It is a thread stopped forever.

A detail about Fire that changes how you write everything else. Since firing does not wait, the announcing script continues immediately — and that means it cannot assume the listeners have finished. Code written as "I announce the round started and now count the living players" reads a state the listeners have yet to build.

When the sequence genuinely matters, the tool is a different one: BindableFunction, which waits. Using an event and then hoping the listeners were quick is a race you lose on a full server, and win in every test.

The artifact

The module below applies the pattern the documentation itself suggests: a BindableEvent inside a module, with cleaner syntax.

The real gain is not syntax. It is that the game's events end up declared in one place — and a wrong name raises an error immediately, instead of creating a perfectly valid connection to an object that does not exist, that will never fire, and that will not complain about it.

Its second half solves the ordering problem, and that is the next section.

It is worth recording a vocabulary difference that confuses people coming from other languages: here, an "event" is not a message in a queue. It is a list of functions the engine calls when somebody fires. There is no guaranteed delivery, no history, no first-come — whoever connects after the fire simply does not receive that one.

That has a practical consequence at start-up: a script that fires "game ready" before another connects leaves the second waiting forever for an announcement that already passed. For a fact that has already happened, an event is the wrong tool — a module with a boolean solves it, and whoever arrives late can still read it.

Before the mistakes, it is worth fixing the division in one sentence, because it decides the tool in almost every case: one side announces and does not wait; the other asks and waits. BindableEvent is the first, BindableFunction is the second, and swapping them produces either a game that carries on too early or a thread stopped forever.

Common mistakes

Depending on the order of connections. The documentation is explicit: you can connect several functions to the same BindableEvent, but Luau executes them in an unpredictable order.

It is the most treacherous mistake on the subject because it works in testing. With two or three connections, the order is usually stable enough for the game to look right. It breaks when the game grows, when one script takes longer to load, or on a server under load — that is, in production, now and then.

The official recommendation: to guarantee order, combine the functions into one and connect that.

The artifact does that while preserving the readability of separate steps — and with one care: inside a single function, an error would stop the following steps, the opposite of independent events' behaviour. A pcall on each step gives the resilience back without giving back the disorder.

Invoking a callback that does not exist. The BindableFunction case described above. The script stops, silently, and everything that depended on that line never happens. The symptom is a system that "does nothing" with no error in the output.

It is especially common at start-up: the invoking script runs before the script that defines the callback. Script execution order is not guaranteed, and that lost race hangs forever.

Using an event where a module belonged. Already mentioned, and the example is worth it: a BindableFunction returning "how many players are alive" is a module with extra steps. The function exists, the data exists, and you have added an indirection that can only go wrong.

The rule: if the answer is a value that is already somewhere, it is a module. If it is work that has to be done now by another system, it is a BindableFunction.

Naming by the effect. An event called ShowScoreboard ties the firer to what happens. When a second system wants to react, the name is wrong — and a wrong name is the beginning of code where nobody trusts the names.

One further care applies to connections that outlive what created them. Connecting inside a function that runs every round accumulates one connection per round, and by the tenth the same announcement fires ten reactions. The symptom is a game that gets progressively stranger over a session and returns to normal when the server restarts — which delays the diagnosis, because it looks like instability.

The rule: connect once, at start-up, or keep the connection and disconnect it along with whatever created it.

Creating the object from a script without guaranteeing it exists first. Anyone creating the BindableEvent at run time and connecting in another script needs the creation to have happened first. WaitForChild solves it; FindFirstChild returns nil on the lost race, and the connection is simply not made — no error, no warning.

Next step

The useful exercise is a coupling audit: open the script that runs your game's round and count how many other scripts or modules it mentions by name.

Every mention is a dependency that has to be edited when that system changes. One event in place of five calls turns that script into what it should be — one that knows when the round starts and nothing about what that sets off.

After that, it is worth writing the list of facts your game has. Match started, match ended, player eliminated, objective captured. It is a short list, and it usually reveals that two systems were detecting the same fact on their own, each in their own way.

And keep the division that organises everything: an event announces, a module holds, a remote crosses. Each tool has a job, and almost every tangle of code comes from using one of them for another's job.

Sources

How we correct mistakes

Ready-to-use artifact

A round event bus, with BindableEvents inside a module (ServerScriptService)
lua
-- ServerScriptService > ModuleScript "Round"
--
-- The pattern the documentation suggests: a BindableEvent INSIDE a module.
-- Scripts do not look for an object in the Explorer and do not need to know
-- where the event lives -- they call a function by name.
--
-- The gain is not syntax. It is that the game's set of events ends up
-- declared in one place, and a wrong name raises an error IMMEDIATELY
-- instead of creating a silent connection that never fires.

local Round = {}

-- The game's events, declared. Adding one is adding a line.
local NAMES = { "started", "ended", "playerEliminated" }

local events = {}
for _, name in NAMES do
	local b = Instance.new("BindableEvent")
	b.Name = name
	b.Parent = script
	events[name] = b
end

local function get(name)
	local e = events[name]
	if not e then
		-- an error RIGHT NOW, rather than a connection that never fires
		error("round event does not exist: " .. tostring(name), 3)
	end
	return e
end

function Round.on(name, callback)
	return get(name).Event:Connect(callback)
end

function Round.announce(name, ...)
	-- Fire does NOT wait: whoever announces carries on in the same frame
	get(name):Fire(...)
end

--[[
═══════════════════════════════════════════════════════════════════════
THE ORDER IS UNPREDICTABLE -- and that is why this part exists
═══════════════════════════════════════════════════════════════════════

Connecting five functions to "started" does NOT guarantee they run in the
order they were connected. For whatever NEEDS order, the documentation
says to combine everything into a single function.

This function builds that "single function" from a list, preserving the
readability of having separate steps.
]]
function Round.inOrder(name, steps)
	return Round.on(name, function(...)
		for i, step in steps do
			local ok, err = pcall(step, ...)
			if not ok then
				-- In a single function, an error WOULD STOP the following
				-- steps -- the opposite of separate events' behaviour. The
				-- pcall gives the resilience back without giving back the
				-- disorder.
				warn("step " .. i .. " of " .. name .. " failed: " .. tostring(err))
			end
		end
	end)
end

return Round

--[[
USE:

-- Script A: whoever runs the round
local Round = require(script.Parent.Round)
Round.announce("started", { duration = 120, map = "Village" })

-- Script B: timer and scoreboard, with NO order between them
Round.on("started", function(info) startTimer(info.duration) end)
Round.on("started", function(info) showScoreboard() end)

-- Script C: cleanup that NEEDS order
Round.inOrder("ended", {
    function() freezePlayers() end,
    function() computeWinners() end,
    function() backToLobby() end,
})
]]
In this article

Keep reading