Forest Logo
search
package_2

bindable

By @iamkleon

Roblox

Mirrored

Bindable

A high-performance, pure-Luau event and callback system. It provides two primitives — EventBindable and FunctionBindable — that operate entirely in Luau, with strict runtime guards and aggressive performance optimizations.

Features

  • Pure Luau: No Instance objects, no hidden BindableEvents, no memory leaks.
  • Zero-Allocation Fire: Event:Fire(...) iterates handlers backwards (LIFO) with deferred in-place compaction and no temporary tables or thread creation.
  • Instant Wait: Event:Wait() resumes waiting threads directly via coroutine.resume() without scheduler indirection, while still surfacing errors thrown by the resumed thread.
  • Synchronous Invoke: Function:Invoke(...) calls the assigned callback on the same thread and returns results immediately.
  • O(1) Lazy Disconnect: :Disconnect() writes a single boolean flag. Disconnected handlers are swept and compacted on the next outermost :Fire().
  • Error Isolation: A crashing handler does not stop the rest of the handlers from running; stack traces are preserved and re-thrown via task.defer. Errors raised after :Wait() returns are likewise reported, not swallowed.
  • Re-entrancy Guard: Nested :Fire() calls are detected and capped at a depth of 100. Compaction is deferred to the outermost fire so re-entrant calls never corrupt the parent iteration.
  • Once Safety: :Once() connections are disconnected before their callback runs, so a re-entrant :Fire() cannot fire them a second time.
  • Waiter Safety: :Destroy() automatically resumes any threads waiting with :Wait() so they never hang.
  • Strict Metatables: Accessing or assigning invalid members throws immediately, catching typos at development time. Internal state is protected via rawset whitelisting.

Installation

Wally

Add the package to your wally.toml (https://wally.run/package/iamkleon/bindable), then require it:

local Bindable = require(game.ReplicatedStorage.Packages.Bindable)

Manual

  1. Download the latest .rbxm model from the Releases page.
  2. Insert the model into your place (e.g., ReplicatedStorage.Bindable).
  3. Require the ModuleScript:

local Bindable = require(game.ReplicatedStorage.Bindable)

API Reference

Constructors

Bindable.Event() -> EventBindable Creates a new event bindable.

Bindable.Function() -> FunctionBindable Creates a new function bindable.

EventBindable

:Connect(fn: (...any) -> ()) -> Connection Connects a function that will be called every time the event fires.

:Once(fn: (...any) -> ()) -> Connection Connects a function that will be called only on the next fire, then automatically disconnects. The disconnect happens before the callback runs, so re-entrant fires cannot trigger it twice.

:Wait() -> ...any Yields the current coroutine until the next fire, then returns the arguments passed to :Fire(...). The waiting thread is resumed directly via coroutine.resume; any error raised after :Wait() returns is reported through task.defer rather than swallowed. If the event is destroyed while waiting, it throws "EventBindable was destroyed while waiting".

:Fire(...any) Synchronously invokes all connected handlers in LIFO (Last-In, First-Out) order. Disconnected handlers are skipped. Compaction of dead handlers is deferred to the outermost fire so re-entrant calls are safe. If a handler errors, the error is captured and deferred so the remaining handlers still run.

:FireDeferred(...any) Schedules :Fire(...) on the next resumption cycle using task.defer.

:DisconnectAll() Disconnects every handler but leaves the event usable for future connections.

:Destroy() Disconnects all handlers, clears internal state, resumes any waiting threads, and marks the event as destroyed. Future :Connect calls return dead connections.

:IsDestroyed() -> boolean Returns whether the event has been destroyed.

FunctionBindable

:OnInvoke(fn: ((...any) -> ...any)?) -> ((...any) -> ...any)? Assigns the callback invoked by :Invoke(...). Returns the previous callback, if any.

:Invoke(...any) -> ...any Calls the set callback synchronously and returns its results directly. Callback errors propagate to the caller.

:Destroy() Clears the callback and marks the bindable as destroyed. Future :Invoke calls will error.

:IsDestroyed() -> boolean Returns whether the function bindable has been destroyed.

Connection

.Connected: boolean (read-only) Whether the connection is still active. Writing to this property will throw.

:Disconnect() Lazily disconnects the handler. O(1) and safe to call from inside a handler.

:IsConnected() -> boolean Returns the value of .Connected.

Usage Examples

1. Basic Event Wiring

Connecting a function to an event and firing it with arguments.

local Bindable = require(game.ReplicatedStorage.Bindable)

local OnScoreChanged = Bindable.Event()

local conn = OnScoreChanged:Connect(function(newScore, playerName) print(playerName .. " scored! New total: " .. newScore) end)

OnScoreChanged:Fire(100, "Player1") -- Output: Player1 scored! New total: 100

2. One-Shot Events (Once)

Firing an event that should only be handled a single time, such as an initialization or loading phase.

local OnGameLoaded = Bindable.Event()

OnGameLoaded:Once(function(mapName) print("Game loaded on map: " .. mapName) end)

OnGameLoaded:Fire("Desert") -- Output: Game loaded on map: Desert OnGameLoaded:Fire("Forest") -- Does nothing, the connection was already removed.

3. Waiting for an Event (Wait)

Yielding a thread until an event fires. This is useful for creating asynchronous flows without polling.

local OnDoorOpened = Bindable.Event()

task.spawn(function() print("Waiting for door to open...") local doorId, openedBy = OnDoorOpened:Wait() print(openedBy .. " opened door #" .. doorId) end)

task.wait(2) -- Simulate some time passing OnDoorOpened:Fire(42, "Alice") -- Output: Alice opened door #42

4. Synchronous Function Binding (FunctionBindable)

Using a FunctionBindable to synchronously request and receive data from another system.

local GetPlayerData = Bindable.Function()

-- Set the callback that will handle the request GetPlayerData:OnInvoke(function(userId) -- Simulate fetching data if userId == 1 then return "Alice", 100 end return "Unknown", 0 end)

-- Invoke the callback and get results immediately local name, score = GetPlayerData:Invoke(1) print(name, score) -- Output: Alice 100

5. Disconnecting and Cleanup

Properly managing memory and preventing memory leaks by disconnecting when done.

local OnRoundEnd = Bindable.Event() local connection = OnRoundEnd:Connect(function() print("Round ended!") end)

-- Later, when you no longer need to listen: connection:Disconnect()

-- Or disconnect all listeners at once: OnRoundEnd:DisconnectAll()

-- When the event is completely done being used, destroy it: OnRoundEnd:Destroy()

Design Notes & Best Practices

Synchronous Fire and Yielding

Because :Fire() runs handlers on the calling thread, if a handler yields, the entire fire loop yields with it. If you need to perform yielding work inside a handler without blocking the caller, move that work into task.spawn or task.defer inside the handler.

Re-entrancy

Calling :Fire() from inside a handler is allowed and recurses synchronously. The handler array is not compacted during nested fires — compaction runs only when the outermost fire unwinds (_fireDepth == 0) — so a parent loop's array positions stay stable while a child fire iterates. A depth limit of 100 prevents accidental infinite loops. Use :FireDeferred() to break a synchronous chain.

Once Semantics

:Once() disconnects the connection before invoking its callback. This guarantees exactly one invocation even if the callback (or a sibling handler) triggers a re-entrant :Fire().

Error Isolation

Each handler is wrapped in pcall; failures are captured with a stack trace and re-thrown via task.defer so the remaining handlers still run. The same error-reporting path is used for threads resumed by :Wait(): if code executed after :Wait() returns throws, the error is reported rather than silently captured by coroutine.resume.

Thread Safety

  • Disconnecting during :Fire() is always safe; the flag flip is visible to the current and any nested fire.
  • Connecting during :Fire() appends to the end of the handler array; the new handler is not invoked until the next :Fire() due to backward iteration.
  • Destroying during :Fire() is safe: the array is cleared and the loop stops at the next nil entry; the destroyed flag prevents new connections.
  • Re-entrant :Fire() does not double-invoke :Once() handlers and does not skip or duplicate handlers due to compaction.

Memory

  • :Destroy() clears the internal handler table and wakes up any waiters. Connection objects held elsewhere become harmless dead objects.
  • Always :Destroy() bindables when done to release references.

Package Details

Install command (Click to copy)


Version

1.0.1

License

MIT

check_circle

Safe for commercial use

infoLicense identified from the packaged LICENSE file; the manifest declared none.

Automated license review — not legal advice.