Rules
Kits publish facts on the Bus: Combat.Kill, Inventory.Granted, and so on. The moment a response to one of those facts needs “only every 10th time,” “only once,” or “not more than once every 5 seconds,” a plain Bus:subscribe handler grows a hand-rolled counter table, a os.clock() timestamp, and a “have I already fired for this player” set — bookkeeping that has nothing to do with what the rule is actually for. Rules is that bookkeeping, built once and gated declaratively: Engine:on(topic) starts a fluent chain of match conditions, and :run(action) arms it.
Mental model
Section titled “Mental model”Rules.attach(kernel, options?) returns an Engine. Engine:on(topic) returns an unarmed Rule — a plain table recording which gates you’ve asked for. Chain methods (where, count, once, cooldown, key) just push settings onto that table and return self; you can call them in any order while building the chain. Nothing subscribes to the Bus until :run(action).
run() does the real work: it subscribes to topic on kernel.Bus, and every time that Signal fires, the handler walks a fixed evaluation pipeline — fixed regardless of the order you called the chain methods in. The module’s own header comment now states this order explicitly (once -> cooldown -> count), rather than it being something you’d only discover by reading run()’s body:
wherepredicates — every registered predicate is called (viapcall) with the event’s args (the topic itself is stripped; a predicate forCombat.Killreceives(killerSession, victimSession), not the topic string). A predicate that returns falsy, or that throws, is treated as a non-match — a throwing predicate silently drops the match rather than erroring the rule. All predicates must pass (wherecalls AND together). Passing this stage incrementsStats.Matched.key— the counting identity is computed: yourkey(fn)function called with the same event args, or, with nokey()set, the first event arg itself (verified in source:select(1, ...)in a single-value context) — the session, by convention, since every kit publishes the session first. Anilkey (custom or default) funnels into one shared bucket, so all “keyless” matches share the same counters.once— if this key has already fired once, the match is dropped here.cooldown— if this key fired within the lastcooldown(seconds)window (per the engine’sClock, defaultos.clock), the match is dropped here.count— the key’s counter increments; if it isn’t an exact multiple ofcount(n), the match is dropped here. Because this stage runs afteronceandcooldown, “every nth surviving match” means survivingwhereandonceandcooldown— not justwhere. A dropped-for-cooldown match never advances the count.- Fire — the key’s once-flag and last-fired timestamp are stamped,
Stats.Firedincrements, andactionruns viatask.spawn(action, ...).
The counters, once-flags, and last-fired timestamps all live in tables built with setmetatable({}, { __mode = "k" }) — weak keys. Mechanically: each is a table keyed by whatever key() produced (typically a session object). A weak-keyed table doesn’t count as a reference for garbage collection, so once nothing else in the game holds that session (the player left, the session table was dropped), the entry for it can be collected too. Per-player rule state doesn’t accumulate for players who are no longer around.
task.spawn(action, ...) matters for the same reason a Signal handler runs on its own thread (Signal): if action throws, it errors on its own spawned thread, not on the Bus’s publish call stack. The fire loop, any other subscriber of the same topic, and the code that called publish are all unaffected by a broken rule action.
Calling a chain method after run() raises an error ("this rule already ran run(); build a new rule instead") — a Rule is single-use; build a new one from Engine:on(topic) if you need another.
A round-based combat kit publishes Combat.Kill(session, victimSession). This rule awards a bonus every 3rd kill a player lands while in a round, capped to once every 5 seconds so a burst of simultaneous kills can’t double-award:
local Rules = require(game:GetService("ReplicatedStorage").ChloeKernel.Rules)
local Engine = Rules.attach(kernel)
Engine:on("Combat.Kill") :where(function(session: any, victimSession: any) return session.Data.InRound == true end) :count(3) :cooldown(5) :key(function(session: any) return session -- explicit for clarity; this matches the default end) :run(function(session: any, victimSession: any) Currency:grant(session, 50) kernel.Bus:publish("Combat.BonusAwarded", session) end)Every player’s kill count and cooldown timestamp are tracked independently because the key is the session — one player’s streak never affects another’s. Stats.Matched on the returned handle counts every kill landed in a round (regardless of count/cooldown); Stats.Fired counts only the awards actually paid out:
local Handle = Engine:on("Combat.Kill"):run(function(session) end)print(Handle.Stats.Matched, Handle.Stats.Fired)Handle.disconnect() -- tears down the underlying Bus subscription| Member | Description |
|---|---|
Rules.attach(kernel, options?) → Engine |
options.Clock: (() -> number)? overrides the clock used for cooldown (default os.clock) |
engine:on(topic: string) → Rule |
Begins a chain against a Bus topic. Nothing subscribes yet |
rule:where(predicate) → Rule |
Adds an arg filter. predicate(...) receives the event’s args (no topic) and returns a boolean; a throw counts as false. Repeat calls AND together |
rule:count(every: number) → Rule |
Fires only every everyth surviving match, per key. every must be an integer >= 1 |
rule:once() → Rule |
Fires at most once per key, ever |
rule:cooldown(seconds: number) → Rule |
Drops matches within seconds of that key’s last fire. seconds must be > 0 |
rule:key(keyOf) → Rule |
Sets the counting identity. keyOf(...) receives the event args; default is the first event arg (the session, by kit convention). A nil result shares one bucket |
rule:run(action) → Handle |
Arms the rule: subscribes on the Bus and starts matching. Returns { disconnect(), Stats = { Matched: number, Fired: number } }. Any chain method called after this errors |
engine:destroy() |
Disconnects every rule this engine has armed and clears its handle list |
Gotchas / design notes
Section titled “Gotchas / design notes”Rules vs. a plain Bus:subscribe. Reach for Rules when the handler you’d otherwise write would reimplement one of its gates by hand — a local table counting matches to check % n, an os.clock() timestamp compared against a window, a set tracking “already ran for this player.” That bookkeeping is exactly what count/cooldown/once/key replace, weak-keyed and stats-tracked for free. For a one-off handler whose logic doesn’t fit that vocabulary — custom branching, side effects that depend on more than “matched or not,” anything you’d only ever read once — a direct kernel.Bus:subscribe(topic, function(topic, ...) ... end) is more honest: the whole behavior is visible in the handler body instead of split across a fixed pipeline order a reader has to already know.