Skip to content

Rules

Declarative reactions to Bus topics with per-key where, count, once and cooldown gates, in place of hand-kept counters and timestamps.

Realm
Shared
Require
ReplicatedStorage.ChloeKernel.Rules
Since
v0.7.0
Source
Rules.luau

Kits and services publish facts on the Bus: Inventory.Granted, your own Combat.Kill, and so on. As soon as a reaction needs “only every 10th time”, “only once” or “at most once every 5 seconds, per player”, a plain Bus:subscribe handler grows a counter table, an os.clock() timestamp and an “already fired for this player” set, none of which is what the rule is about. Rules is that bookkeeping built once. engine:on(topic) starts a chain of gates and :run(action) arms it.

Rules.attach(kernel, options?) returns an engine. engine:on(topic) returns an unarmed rule, a table that records the gates you ask for. where, count, once, cooldown and key store settings and return the rule, in any order. Nothing subscribes until run(action).

run subscribes to the topic on kernel.Bus. Each publish walks a fixed pipeline, whatever order the chain was written in:

  1. where predicates. Each is called through pcall with the event’s arguments (the topic is stripped, so a Combat.Kill predicate receives (killerSession, victimSession)). A falsy result or a throw drops the event. Repeated where calls must all pass. An event that passes counts in Stats.Matched.
  2. key. The counting identity is key(fn) called with the same arguments, or the first argument when no key is set (the session, in every kit’s publish convention). A key function that throws drops the event, like a throwing predicate. Since 0.9.0; before, the throw escaped into the Bus. A nil key goes into one shared bucket.
  3. once. If this key already fired, the event is dropped.
  4. cooldown. If this key fired within the last cooldown(seconds) by the engine’s clock (os.clock by default), the event is dropped.
  5. count. The key’s counter goes up; unless it is a multiple of count(n), the event is dropped. Because this runs after once and cooldown, “every nth” means every nth event that survived them. An event dropped by the cooldown never advances the count.
  6. Fire. The key’s fired flag and timestamp are set, Stats.Fired goes up, and task.spawn(action, ...) runs the action with the event’s arguments.

The counters, fired flags and timestamps live in tables with weak keys (__mode = "k"). When the key is a session or player object that nothing else references any more, its entries can be collected, so state for departed players does not pile up. String and number keys are never collected.

task.spawn puts the action on its own thread. An action that throws fails there, and the publisher, the other subscribers and the rule keep working. A rule is single use: any chain method called after run raises this rule already ran run(); build a new rule instead.

src/Server/Game/Services/RewardService.luau
--!strict
-- RewardService: streak bonuses and first-legendary announcements.
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local ServerScriptService = game:GetService("ServerScriptService")
local Items = require(ServerScriptService.Game.Config.Items)
local Rules = require(ReplicatedStorage.ChloeKernel.Rules)
local RewardService = {
Name = "RewardService",
}
function RewardService.init(self: any, kernel: any)
self.Kernel = kernel
end
function RewardService.start(self: any)
local Kernel = self.Kernel
local Engine = Rules.attach(Kernel)
self.Engine = Engine
-- CombatService publishes Combat.Kill(killerSession, victimSession).
-- Every 3rd kill per player pays a streak, at most once every 5 seconds.
Engine:on("Combat.Kill")
:where(function(killer, victim)
return killer ~= victim
end)
:count(3)
:cooldown(5)
:run(function(killer)
Kernel.Bus:publish("Reward.Streak", killer)
end)
-- InventoryKit publishes Inventory.Granted(session, itemId, count).
-- The first legendary each player gets on this server is announced once.
Engine:on("Inventory.Granted")
:where(function(_session, itemId: string)
local Item = Items[itemId]
return Item ~= nil and Item.Rarity == "Legendary"
end)
:once()
:run(function(session, itemId: string)
Kernel.Bus:publish("Reward.FirstLegendary", session, itemId)
end)
end
function RewardService.stop(self: any)
if self.Engine then
self.Engine:destroy()
end
end
return RewardService

Both rules key on the first argument, the session, so one player’s streak never touches another’s. run returns a handle with live stats:

local Handle = Engine:on("Combat.Kill"):count(3):run(function(killer)
Kernel.Bus:publish("Reward.Streak", killer)
end)
print(Handle.Stats.Matched, Handle.Stats.Fired) -- kills seen, streaks paid
Handle.disconnect() -- unsubscribes and removes the rule from the engine
Member Description
Rules.attach(kernel, options?) -> Engine options.Clock: (() -> number)? is the clock for cooldown, os.clock by default.
engine:on(topic: string) -> Rule Starts a rule on a Bus topic. Subscribes nothing yet.
engine:destroy() Disconnects every rule this engine armed.
rule:where(predicate) -> Rule Adds a filter over the event’s arguments. A throw counts as false. Repeated calls must all pass.
rule:count(every: number) -> Rule Fires on every everyth surviving event per key. Asserts an integer of at least 1.
rule:once() -> Rule Fires at most once per key for the life of the rule.
rule:cooldown(seconds: number) -> Rule Drops events within seconds of that key’s last fire. Asserts a number above 0.
rule:key(keyOf) -> Rule Sets the counting identity. keyOf(...) receives the event’s arguments; a throw drops the event. Default: the first argument. A nil result shares one bucket.
rule:run(action) -> Handle Arms the rule: subscribes and starts matching. Chain methods called afterwards raise.
handle.Stats.Matched Events that passed every where, including ones a throwing key then dropped.
handle.Stats.Fired Events that ran the action.
handle.disconnect() Unsubscribes from the Bus and removes the handle from its engine.
  • Since 0.9.0 disconnect() also removes the handle from the engine, so an engine that arms and drops many short-lived rules no longer keeps every old handle until destroy().
  • Reach for Rules when a handler would otherwise reimplement one of its gates: a counter checked with % n, a timestamp compared against a window, a set of players already rewarded. For logic that does not fit those gates (branching, effects that depend on more than matched or not), a direct kernel.Bus:subscribe(topic, fn) keeps the whole behavior in one visible function.
  • Foundations.spec.luau covers where, per-key count, once, cooldown and disconnect. The core regression runner covers the throwing key and the handle removal.

ChloeKernel v0.9.1. Access to the framework is private and granted individually by Chloe. Games built with it credit CryptedChloes in their description.