chi0sk
github.com/chi0sk

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.

Zero dependencies. EventBus is self-contained. Distributed mode requires MessagingService and only activates when 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()
Handler signature: Every handler receives two arguments: 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"
Combining patterns: Multiple subscriptions can match the same event. All matching handlers fire, sorted by priority across all matched nodes.

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)
Middleware is synchronous. Do not yield inside middleware. Calling next() inside task.spawn or task.defer breaks the chain ordering and depth tracking.

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.

Note: Dead letters are only recorded when there are zero matching subscribers. An event that matches a filter-rejecting subscriber still registers as having a subscriber and does not become a dead letter.

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.

MessagingService limits: ~150 publishes/minute per topic. EventBus's internal cap is 120/minute with backoff. For high-frequency cross-server events, use batching or raise 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.

OptionTypeDefaultDescription
Prioritynumber0Higher fires first
OncebooleanfalseAuto-disconnect after first match
FilterfunctionnilCalled 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:

FieldDescription
TotalEmittedTotal Emit / EmitDistributed calls
TotalDispatchedTotal handler invocations
TotalBlockedEvents that hit no subscribers (dead letters)
RemoteSentEvents published cross-server
RemoteReceivedEvents received from other servers
DeadLettersTotal dead letter count (cumulative)
SubscribersCurrent 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

FieldTypeDefaultDescription
EnableDistributedbooleanfalseEnable cross-server MessagingService fanout
DistributedTopicstring"EventBus_v1"MessagingService topic name
BatchSizenumber25Max events per MessagingService publish
BatchIntervalMsnumber60Batch flush interval in milliseconds
MaxDeadLettersnumber100Max dead letter entries to retain
MaxDepthnumber8Max recursive emit depth before error
OnErrorfunction?warnCalled 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 OnError handler to catch silent failures
  • Contact @chi0sk on Discord