The Rule Engine
Note:
AI assisted with restructuring and grammar.— 0MAR280
Overview
Every Roblox developer has been there.
You start with a simple shop and a couple of badges (Achievements). A few items, a few if statements checking player`s cash or level.
But as the game grows—events, bundles, time-limited items, level requirements, prerequisites—your codebase turns into a maze of nested conditional statements.
Adding a new item becomes a nightmare! And AI ends up burning through massive amounts of tokens for such a simple task.
Well, I`ve been there, too.
So, I took the time to think for a solution, once and for all!
Moving from Imperative to Declarative
Luckily, I was learning React.luau back at that time, and it had a shifting effect on my engineering mentality: instead of writing custom logic for every single product, treat rules and rewards as data.
If we take a closer look at the problem with a bit of abstraction, we'll reach a conclusion: both shop products and badges are 'evaluate-then-reward' problems.
Which means we need two separate systems—or one comprehensive system—to handle these tasks efficiently in one place, instead of repeating ourselves every time we need to add a new product.
Want to skip the implementation details? Head straight to the Conclusion & Takeaways
Engine Interface
So, how do we do that?
Well, remember those two systems?
One to evaluate, the second to reward.
I merged them into one comprehensive system: The Rule Engine!
export type RuleEngine = {
checkAndApplyRule(player: Player, rule: RuleData): (boolean, string?)
applyRewards(player: Player, rule: RuleData): (boolean, string?)
checkRules(player: Player, rule: RuleData): (boolean, string?)
}The engine utilizes a custom type
RuleData.
I won't dive too much into the code, but to keep you up to speed, I'll give a brief explanation.
You can look at the documented types atsrc\shared\Modules\Types\Types.luau
-- A table that contains the evaluation and reward data
export type RuleData = {
-- Where this rule must be evaluated — Server, Client or Both
context: ContextType,
-- A table of conditions—or a function—to evaluate.
conditions: ({ Condition } | ((Player) -> (boolean, string?)))?,
-- A table of rewards—or a function—to reward if the conditions passed.
rewards: ({ Reward } | ((Player) -> (boolean, string?)))?,
}local price = 10
return {
id = 1,
ruleData = {
context = ENUMS.ContextTypes.Server, -- Custom Enum
conditions = {
{
name = "Check Coins Balance",
value1 = { param = BALANCES.coins }, -- User coins balance
value2 = price, -- Sword Price
opType = ENUMS.ConditionOpTypes.GreaterThanOrEqual,
onFalse = `Not Enough Coins!`,
},
},
rewards = {
-- Deduct the coins
{
name = "Coins Payment",
path = dataSignal,
value = price,
opType = ENUMS.RewardOpTypes.Decrement,
params = {
userDataPaths.coins,
},
},
-- Fire the custom signal
{
name = "Give Sword",
path = toolSignal,
params = {
"Sword", -- Will be received as "toolName"
"Supppp, This is an extra param!!!", -- Will be received as ...
},
unpackParams = true,
opType = ENUMS.RewardOpTypes.FireSignal,
}
},
},
attributes = {
name = `Sword Purchase`,
description = `Get a Sword for {coinsCost} Coins & {pointsCost} Points`,
},
}End-to-End Execution Flow
So, what actually happens in the background?
- The lifecycle of a transaction:
- The Shop UI calls the custom MarketplaceService with the item ID.
- The service pulls the item data.ruleData and passes it to the
RuleEngine. - The engine evaluates conditions, applies rewards, and returns the results.
- The service updates state, fires signals, and the UI updates seamlessly.
Conclusion & Takeaways
The Code-Free Workflow
Now, adding a shop product, badges, daily logins, or quest rewards no longer requires touching core systems anymore!
Instead, it only requires creating a new module with its main data, metadata, and a ruleData property packed with clean, JSON-like tables instead of complicated conditional statements.