Write your game's rules down, and ShotScript checks them
ShotScript

Write your game's rules down, and ShotScript checks them

By ShotScript Team4 min

Some of the worst bugs in a Roblox game never print anything. A daily quest adds coins straight to a player's purse, and the coin count on the player list stops matching what they have. Someone changes how pets are saved, and everyone who played before the update loads a save the new code reads wrong. Nothing errors. You hear about it from players.

Last week ShotScript got a way to write rules like these down. Each one is a line or two in a system's Types.shot, and none of them adds code to the game. They give shot check and shot run something to hold every other script to.

We tried them on a small pet game with four systems. Coins keeps every player's purse, Leaderboard shows coins on the player list, Quests pays daily rewards and Eggs hatches pets. Every message below is what shot printed for that project.

Only Coins changes coins

In our game coins go through one function. CoinsServer.Grant stops a purse at 1,000 coins and publishes an event the leaderboard listens to, and a quest that sets coins itself skips both. So the Coins system says so in its Types.shot:

doc { Grant keeps a purse under the cap and updates the coin count on the player list; a write to Coins does neither }
owns Purse.Coins via CoinsServer.Grant

Then we wrote the quest reward the quick way, and shot check stopped it at the line:

The owns line in Coins/Types.shot, a quest reward that adds to purse.Coins directly, and shot check's error at that line telling you to use CoinsServer.Grant and why

The part in brackets is the doc from the owns line. Whoever runs into the rule months from now reads why it's there, so write that doc for them.

Code inside the Coins folder can still write the field, since that's where Grant and Spend live. Everywhere else the check catches =, += and the other compound writes, writes to entries when the field is a table, and table.insert and the other table calls on it.

Who sends an event

That event is declared in the same file:

event CoinsGiven(player : Player, amount : number)

Every publish and every listener is checked against that line. The event belongs to Coins, so only files in Coins publish it. We added a publish to the quest anyway, right after the Grant, the kind of line someone adds to "make sure the leaderboard hears about it":

CoinsServer.Grant(player, coins)
CoinsGiven.Publish(player, coins)
Pets/Quests/Quests.shot:13:3   CoinsGiven is Coins's event (Coins/Types.shot:4): only files in Coins publish it

To see what it would have done, we played our week of quests on it anyway. shot run plays scenarios in a mock Roblox, with no Studio open, and this one finishes seven quests of 120 coins for a player called Alice:

Alice has 840 coins and the player list shows 1680

Grant had already published the event, so the leaderboard counted every reward twice. When a second system really should publish an event, you add it to the line with published by Coins, Quests, and the check passes.

A rule checked after every frame

Owning a field says who may write it. Some rules are about what has to stay true no matter who writes. In our game a purse never holds less than 0 or more than 1,000 coins, so that goes into Types.shot too:

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

CoinsInRange is a plain function in CoinsServer. It loops over the purses and returns false, with a short message, when one is out of range. Nothing in the game calls it. shot run calls it after every frame of every scenario it plays.

Then a weekend bonus went into CoinsServer itself:

function WeekendBonus(player : Player)
	local purse = purseOf(player)
	purse.Coins += 900
end

It lives inside Coins, so the owns line lets it through, and shot check printed 0 errors. Our Weekend scenario plays the week of quests and then the bonus.

shot run Weekend: Alice has 840 coins after the week and 1740 after the bonus, then the broken rule with its doc, first at frame 427 after log line 2, and the scenario marked FAILED

The report gives the rule in your own words, the first frame that broke it (frame 427, seven seconds into the scenario), the scenario line that ran that frame, and what the function said was wrong. We took the invariant line out and ran it again. Same two log lines, and the scenario passed. That's how a bug like this usually gets shipped.

A rule only reads. shot check refuses one that writes to anything but its own locals, fires a signal or waits, because it runs between the frames of the game it's checking.

Saves that old players can still load

This one loses data. Eggs saves how many eggs a player has hatched and a list of their pets:

public type EggsSave = {
	Hatched : number,
	Pets : {string},
}

When a version ships, you run shot savelock, and it writes the shape of every saved section into Saved.lock, a text file at the project root. A month later we wanted counts instead of a list, Pets : {[string] : number}. It's a two-line change and the code is fine. The save it breaks is the one already sitting in a player's data store.

Saved.lock with the Eggs section at version 1 and Pets as a list, the EggsSave type changed to a map of counts, and shot check's error telling you to bump the Version and add a Migrate step

Bump the Version without a Migrate step and the check names the step that's missing. So we wrote one:

local function migrate(old : any, fromVersion : number) : any
	if fromVersion == 1 then
		local counts : {[string] : number} = {}
		for _, pet in old.Pets do
			counts[pet] = (counts[pet] or 0) + 1
		end
		return { Hatched = old.Hatched, Pets = counts }
	end
	return old
end

With Version = 2 and Migrate = migrate in the section, the check passes. The next shot savelock puts version 2 in the file and keeps version 1 below it.

It reads what a step gives back, too. A step that returns { Pets = {} } and forgets the hatch count gets this:

Pets/Eggs/EggsServer.shot:19   section Eggs: the Migrate step from 1 returns no Hatched, so a version-1 save loads with Hatched nil where version 2 expects number; add Hatched = 0 (as Default has it) to that return

shot check never updates the lock for you. Only you know which saves are out there, so you run shot savelock yourself when a version ships.

Ids you can't misspell

Last one, and it's small. id PetEgg in Eggs/Types.shot makes a kind of string id. To Luau it's still a string. The ids are the PetEgg strings the Eggs system itself writes, so there's no separate list to keep up to date. Anywhere else, a typo is an error that names the real id:

Pets/Quests/Quests.shot:14:28   "goldn_egg" is not a PetEgg (nearest: "golden_egg"): the ids are the PetEgg literals in Eggs; shot ids PetEgg lists them

shot ids PetEgg lists every id, where Eggs defines it, and every use outside Eggs.

A project that declares none of these checks and runs the same as before, so you can add them one at a time, starting with the field that bit you last.