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.CoinsInRange

in 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.Function goes 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 boolean or (boolean, string?). Return false and a short text with the values that broke it ("UserId 1001 has 1800 coins"); shot run prints it.
  • The function only reads. shot check refuses a rule that assigns to anything but its own locals (a field, a file local), calls table.insert/remove/clear/sort/move on 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.CoinsInRange

Wallet/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]
end

shot 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 coins

The 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 doc

A 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 own

The 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?).