Invariants
Declare a rule that must always hold with invariant WalletServer.CoinsInRange, and shot run checks it after every frame of every scenario.
What it is
Some rules must hold across the game at every moment: a purse never above its cap, an equipped pet always one the player owns, a pet never above its owner's level. A change that breaks one usually prints nothing different: no log line, no error, until a player notices. A line
doc { A purse never holds less than 0 or more than MAX_COINS: Grant stops at the cap and Spend refuses what is not there }
invariant WalletServer.CoinsInRangein a system's Types.shot declares the rule. The doc is the rule in words; WalletServer.CoinsInRange is a function of one of the system's own modules that returns true when the rule holds. shot run calls every declared rule after every frame of every scenario it plays (world:Step and each frame inside it, and the frames it runs while a scenario waits), and reports the first frame a rule fails, once per rule, as a failed scenario. To Luau the line is nothing: shot build compiles no code for it, and the function is the module's own (never called by the game).
The whole syntax
invariant Module.Functiongoes on its own line in a Types.shot, with a doc above it (required: the rule in words, printed when it breaks; say why the code keeps it true).- Module is a server or shared module of the system's own folder (subfolders included): rules are checked on the server. Function is one of its functions with no parameters, returning
booleanor(boolean, string?). Return false and a short text with the values that broke it ("UserId 1001 has 1800 coins");shot runprints it. - The function only reads.
shot checkrefuses a rule that assigns to anything but its own locals (a field, a file local), callstable.insert/remove/clear/sort/moveon anything but its own local, fires a signal or remote, or waits. - A class's instances: loop over them in the module that keeps them (every player's pets in PetsServer-style modules). A player's data: loop over
Players:GetPlayers()and read it through the module's own getters.
A whole example
Wallet/Types.shot
public type Coins = number
doc { A purse never holds less than 0 or more than MAX_COINS: Grant stops at the cap and Spend refuses what is not there }
invariant WalletServer.CoinsInRangeWallet/WalletServer.shot
types script.Parent.Types
local MAX_COINS = 1000
local purses : {[number] : number} = {}
rootdoc {
The coins of every player, by UserId.
}
server module WalletServer = {
doc {Coins by UserId}
Purses : {[number] : number} = purses
doc {Gives coins, stopping at MAX_COINS; returns how many were really given}
function Grant(player : Player, amount : number) : number
local now = purses[player.UserId] or 0
local given = math.min(amount, MAX_COINS - now)
purses[player.UserId] = now + given
return given
end
doc {Takes coins; false and no change when the player has fewer}
function Spend(player : Player, amount : number) : boolean
local now = purses[player.UserId] or 0
if now < amount then return false end
purses[player.UserId] = now - amount
return true
end
doc {Rule: true when every purse is within 0 and MAX_COINS; else false and the first that is not}
function CoinsInRange() : (boolean, string?)
for userId, coins in purses do
if coins < 0 or coins > MAX_COINS then
return false, "UserId " .. tostring(userId) .. " has " .. tostring(coins) .. " coins"
end
end
return true, nil
end
}Someone adds a daily bonus that writes the purse directly, skipping Grant's cap:
doc {The daily bonus: 900 coins}
function Bonus(player : Player) : number
purses[player.UserId] = (purses[player.UserId] or 0) + 900
return purses[player.UserId]
endshot check passes, and a scenario that gives the bonus twice logs nothing wrong. shot run Bonus fails it:
---- 1 invariant(s) broken: a rule declared in a Types.shot failed between two frames (each once, at its first frame)
invariant broken: A purse never holds less than 0 or more than MAX_COINS: Grant stops at the cap and Spend refuses what is not there (WalletServer.CoinsInRange, Wallet/Types.shot:4)
first at frame 121 (2.02s virtual), after log line 3, scenario Scenarios/Bonus.luau:11: UserId 1001 has 1800 coinsThe summary line says "1 invariant(s) broken". The scenario line is the world:Step (or wait) that ran the frame; the change that broke the rule ran after the log line it names. The fix is in the code that broke the rule (here: give the bonus through Grant), never in the rule, unless the rule itself is no longer true.
When a rule is checked
- Between frames only: at the end of each frame. A rule that breaks inside one function call and holds again before the frame ends (a swap through a moment of two equipped pets) is never reported. Keep a rule to what must hold whenever game code is not running.
- A rule whose module no scenario code has required yet is skipped, so a scenario that never starts the system checks nothing of it. A rule that errors counts as broken ("the rule errored: ...").
- A project that declares nothing builds, checks and runs byte for byte as before.
The errors
Each names what to change. Missing the doc:
Wallet/Types.shot:3 invariant WalletServer.CoinsInRange: a rule needs its words in a doc above the line (doc { a wallet never holds more than its cap }): shot run prints them when the rule breaks, so add the docA rule that writes (here purses[0] = 0 at the top of CoinsInRange):
Wallet/WalletServer.shot:32 invariant WalletServer.CoinsInRange: the rule writes purses[0], and a rule only reads (shot run calls it after every frame, so a write changes the game it checks): keep what it computes in locals of its ownThe others say: invariant goes in the system's Types.shot; Module is another system's (move this line to its Types.shot); no such module or function (with the nearest name); a class instead of a module; parameters (drop them); a return type that is not boolean or (boolean, string?).
