EventBus v1.1
Hierarchical pub/sub event bus for Roblox. Trie-based routing with pattern matching, middleware, namespaces, and optional cross-server distribution via MessagingService.
Trie Routing
O(depth) dispatch with exact, *, and ** pattern matching
Middleware Pipeline
Transform or block events before they reach subscribers
Priority Ordering
Subscribers fire high-to-low within each matched pattern
Namespaces
Scoped sub-buses with automatic prefix on all events
Dead Letters
Events with no subscribers are recorded for inspection
Distributed
Optional cross-server fanout via MessagingService batching
Why EventBus?
Roblox's built-in BindableEvent works fine for simple cases, but falls apart once you need pattern matching, priority ordering, or the ability to add/remove listeners cleanly. EventBus gives you a proper pub/sub layer with a routing table that scales to hundreds of event types without lists of if statements.
The middleware system lets you add logging, validation, or payload transformation in one place instead of duplicating it across every subscriber. Namespaces let separate systems own their own event prefixes without colliding.
EnableDistributed = true.
Getting Started
Installation
Drop EventBus.lua in ReplicatedStorage.
Basic Usage
local EventBus = require(game.ReplicatedStorage.EventBus)
local bus = EventBus.new()
-- subscribe
local disconnect = bus:On("player.joined", function(payload, meta)
print("player joined:", payload.userId)
print("event:", meta.Event) -- "player.joined"
end)
-- emit
bus:Emit("player.joined", { userId = 123 })
-- unsubscribe
disconnect()
payload (whatever you passed to Emit) and meta (a table with Event, Time, Server, IsRemote).
Pattern Matching
Event names are dot-separated strings. Patterns use the same format with optional wildcards.
Exact
Matches one specific event name only.
bus:On("player.joined", handler)
-- fires on: "player.joined"
-- does not fire on: "player.left", "player.joined.extra"
Wildcard * (one segment)
* matches exactly one path segment. It does not cross dot boundaries.
bus:On("player.*", handler)
-- fires on: "player.joined", "player.left", "player.died"
-- does not fire on: "player.team.changed" (two segments deep)
-- does not fire on: "other.event"
Wildcard ** (zero or more segments)
** matches zero or more path segments from that point forward.
bus:On("player.**", handler)
-- fires on: "player" (zero remaining segments)
-- fires on: "player.joined" (one segment)
-- fires on: "player.team.changed" (two segments)
-- does not fire on: "other.event"
Subscribing
On
local disconnect = bus:On("event.name", function(payload, meta)
-- handler
end)
disconnect() -- unsubscribe
Returns a disconnect function. Calling it removes the subscriber. Safe to call multiple times.
Once
bus:Once("event.name", function(payload, meta)
-- fires exactly once, then auto-disconnects
end)
Priority
Handlers fire in descending priority order. Default priority is 0.
bus:On("combat.hit", handlerA, { Priority = 10 }) -- fires first
bus:On("combat.hit", handlerB, { Priority = 5 }) -- fires second
bus:On("combat.hit", handlerC) -- fires last (priority 0)
Priority sorting happens across all matched patterns. If a player.* handler has priority 10 and a player.joined handler has priority 5, the player.* handler fires first when player.joined is emitted.
Filter
A filter function runs before the handler. If it returns false, the handler is skipped for that emit.
bus:On("shop.purchase", function(payload, meta)
print("big purchase:", payload.amount)
end, {
Filter = function(payload)
return payload.amount > 100
end,
})
Emitting
Emit
bus:Emit("player.joined", { userId = 123 })
bus:Emit("server.shutdown") -- payload is optional
Synchronous. All matching handlers run before Emit returns. If a handler throws, the error is passed to OnError and the remaining handlers still run.
EmitDeferred
bus:EmitDeferred("event.name", payload)
Schedules the emit for the next frame via task.defer. Use this when you need to emit from inside a handler without incrementing the depth counter, or to avoid tight loops causing stack buildup.
EmitDistributed
bus:EmitDistributed("event.name", payload)
Emits locally and queues the event for cross-server broadcast via MessagingService. Requires EnableDistributed = true and must be called from the server. See Distributed Mode for details.
Middleware
Middleware functions run in registration order before any subscriber receives the event. Each middleware can transform the payload, block the event entirely, or pass through.
bus:Use(function(event, payload, next)
-- called with every event before dispatch
-- call next(payload) to continue the chain
-- omit next() call to block the event entirely
print("event:", event, "payload:", payload)
next(payload)
end)
Transforming Payload
bus:Use(function(event, payload, next)
-- add a timestamp to every payload table
if type(payload) == "table" then
payload.timestamp = os.time()
end
next(payload)
end)
Blocking Events
bus:Use(function(event, payload, next)
-- block all events during shutdown
if game:IsShuttingDown() then
return -- don't call next()
end
next(payload)
end)
Namespaces
A namespace is a scoped sub-bus that automatically prefixes all event names. Useful for giving separate systems their own event space without coordinating string prefixes manually.
local bus = EventBus.new()
local combat = bus:Namespace("combat")
local social = bus:Namespace("social")
-- these subscribe to "combat.kill" and "social.kill" respectively
combat:On("kill", function(payload, meta)
print(meta.Event) -- "combat.kill"
end)
social:On("kill", function(payload, meta)
print(meta.Event) -- "social.kill"
end)
-- only fires the combat.kill handler
combat:Emit("kill", { victim = "player1" })
-- namespaces can be nested
local pvp = combat:Namespace("pvp")
pvp:Emit("kill") -- emits "combat.pvp.kill"
Namespaces support On, Once, Emit, EmitDeferred, EmitDistributed, and Namespace. They do not have their own middleware or metrics - they share the parent bus.
Dead Letters
When an event is emitted but no subscriber matches it, the event is recorded as a dead letter. This is useful for catching typos and finding events nobody is listening to.
local bus = EventBus.new({ MaxDeadLetters = 50 })
bus:Emit("orphan.event", { data = 1 })
local dead = bus:GetDeadLetters()
for _, entry in ipairs(dead) do
print(entry.Event, entry.Reason, entry.Timestamp)
end
Dead letters are capped at MaxDeadLetters (default 100). When the cap is reached, the oldest entries are dropped. GetDeadLetters returns a deep clone.
Distributed Mode
EventBus can broadcast events across all servers in a place using MessagingService. This is off by default and only works on the server.
local bus = EventBus.new({
EnableDistributed = true,
DistributedTopic = "MyGame_Events", -- MessagingService topic name
BatchSize = 25, -- max events per publish
BatchIntervalMs = 60, -- flush interval in ms
})
-- emits locally AND to all other servers
bus:EmitDistributed("leaderboard.updated", { scores = {...} })
How Batching Works
Events queued with EmitDistributed are collected and published as a JSON batch. The batch flushes when either the batch reaches BatchSize or the BatchIntervalMs timer fires. Servers ignore their own published batches to prevent echo.
Rate Limiting
MessagingService has a publish rate cap. EventBus tracks publishes per minute and applies backoff when approaching the limit. If the limit is hit, pending events are requeued up to 4x the batch size and retried after a delay.
BatchSize.
API Reference
EventBus.new
EventBus.new(config: EventBusConfig?): EventBus
Creates a new EventBus. Config is optional.
On
bus:On(pattern: string, handler: HandlerFn, opts: SubscribeOptions?): () -> ()
Subscribe to a pattern. Returns a disconnect function.
| Option | Type | Default | Description |
|---|---|---|---|
| Priority | number | 0 | Higher fires first |
| Once | boolean | false | Auto-disconnect after first match |
| Filter | function | nil | Called with payload, return false to skip |
Once
bus:Once(pattern: string, handler: HandlerFn, opts: SubscribeOptions?): () -> ()
Same as On with Once = true. Auto-disconnects after the first matching emit.
Emit
bus:Emit(event: string, payload: any?)
Emit an event synchronously to all matching local subscribers.
EmitDeferred
bus:EmitDeferred(event: string, payload: any?)
Schedule an emit for the next frame. Does not increment the current depth counter.
EmitDistributed
bus:EmitDistributed(event: string, payload: any?)
Emit locally and queue for cross-server broadcast. Server only. Requires EnableDistributed = true.
Use
bus:Use(fn: MiddlewareFn)
Register a middleware function. Middleware runs in registration order. Signature: (event, payload, next) -> (). Call next(payload) to continue.
Namespace
bus:Namespace(prefix: string): Namespace
Returns a scoped sub-bus. All events emitted or subscribed through it are prefixed with prefix.. Namespaces can be chained.
RemoveAll
bus:RemoveAll(pattern: string)
Removes all subscribers registered to the exact pattern string. Does not affect subscribers on patterns that would match the same events.
GetMetrics
bus:GetMetrics(): Metrics
Returns a snapshot of current metrics:
| Field | Description |
|---|---|
| TotalEmitted | Total Emit / EmitDistributed calls |
| TotalDispatched | Total handler invocations |
| TotalBlocked | Events that hit no subscribers (dead letters) |
| RemoteSent | Events published cross-server |
| RemoteReceived | Events received from other servers |
| DeadLetters | Total dead letter count (cumulative) |
| Subscribers | Current active subscriber count |
GetDeadLetters
bus:GetDeadLetters(): {DeadLetter}
Returns a deep clone of the dead letter queue. Each entry has Event, Payload, Timestamp, and Reason.
Destroy
bus:Destroy()
Clears all subscribers and pending distributed events. Any subsequent Emit or On call will throw.
Configuration
| Field | Type | Default | Description |
|---|---|---|---|
| EnableDistributed | boolean | false | Enable cross-server MessagingService fanout |
| DistributedTopic | string | "EventBus_v1" | MessagingService topic name |
| BatchSize | number | 25 | Max events per MessagingService publish |
| BatchIntervalMs | number | 60 | Batch flush interval in milliseconds |
| MaxDeadLetters | number | 100 | Max dead letter entries to retain |
| MaxDepth | number | 8 | Max recursive emit depth before error |
| OnError | function? | warn | Called with error string on handler throws, depth exceeded, etc. |
Examples
Game Event System
local bus = EventBus.new()
local combat = bus:Namespace("combat")
local player = bus:Namespace("player")
-- subscribe to all combat events for logging
bus:On("combat.**", function(payload, meta)
log:record(meta.Event, payload)
end, { Priority = -10 }) -- low priority, fires after game handlers
-- subscribe to specific events
combat:On("kill", function(payload)
updateKillFeed(payload.killer, payload.victim)
end)
player:On("levelup", function(payload)
grantRewards(payload.userId, payload.newLevel)
end)
-- emit
combat:Emit("kill", { killer = "player1", victim = "player2" })
player:Emit("levelup", { userId = 123, newLevel = 10 })
Middleware Logging
bus:Use(function(event, payload, next)
local start = os.clock()
next(payload)
local elapsed = os.clock() - start
if elapsed > 0.01 then
warn(string.format("slow handlers on '%s': %.3fs", event, elapsed))
end
end)
Dead Letter Monitoring
task.spawn(function()
while true do
task.wait(60)
local dead = bus:GetDeadLetters()
if #dead > 0 then
warn("unhandled events in last minute:")
for _, entry in ipairs(dead) do
warn(" -", entry.Event)
end
end
end
end)
One-Time Setup Events
-- fires once when the first player joins, then auto-disconnects
bus:Once("player.joined", function(payload)
initializeWorld()
end)
Troubleshooting
Handler not firing
Check the pattern. player.* does not match player.team.changed - use player.** for that. Also check that the event name and pattern use the same casing. Event names are case-sensitive.
Handler firing too many times
If you have both player.* and player.joined subscriptions, a player.joined emit hits both. This is by design. Use more specific patterns if you need exclusivity, or use a filter.
Recursive emit causing issues
Emitting from inside a handler increments the depth counter. The default max depth is 8. If you need to emit from a handler and can afford to wait one frame, use EmitDeferred instead of Emit. It resets the depth counter since it runs next frame.
Dead letters for events I know are subscribed
Check that the subscriber pattern actually matches the emit string. bus:On("player.*", ...) uses the exact string "player.*" as the trie key, not a regex. If you emitted "player.joined", the * node should match it. If you are using a namespace, the full prefixed name is what matters.
Distributed mode not broadcasting
Make sure you used EmitDistributed and not Emit. Also confirm the bus was created with EnableDistributed = true and that you are calling it from the server, not a LocalScript.
Getting help
- Check
GetMetrics()for TotalEmitted vs TotalDispatched - Check
GetDeadLetters()for unmatched events - Add an
OnErrorhandler to catch silent failures - Contact @chi0sk on Discord