Scenarios

Test your change by playing real game flows in a mock Roblox, from Luau files that never ship with the game.

A scenario is a Luau file in your project's Scenarios folder. shot run builds the project, plays the scenario outside Studio and prints its log, with every error at its .shot line.

What a scenario is

A scenario is a Luau file that drives the game's real code: it requires modules, adds players, runs the Init scripts, steps virtual time and logs what happened. shot run builds the project into a temporary folder and plays scenarios on it in a mock Roblox (no Studio, no physics, no rendering). An error in game code points at the .shot line; an error in the scenario at its own line.

shot runlists every scenario with its summary
shot run WalletPickupplays one (A,B for several, all for every one)

A project keeps its scenarios in a Scenarios folder at its root:

MyProject/
    Init/ ...
    Wallet/ ...
    Scenarios/
        WalletPickup.luau

The Scenarios folder is never part of the game. shot does not compile, check, map or search anything in it, so a test never reaches Studio. Write a scenario like any file of the project (in your editor, or with shot write Scenarios/WalletPickup.luau), change it with shot edit, delete it with shot delete, and print it with line numbers with shot show Scenarios/WalletPickup.luau (see The shot command). Its name starts with a letter and there are no subfolders. Writing or editing a scenario through shot only checks that it parses; shot run is where it runs.

Writing one

For the Wallet project (Overview):

-- WalletPickup: a player who joins gets an empty wallet, and Bank.Give adds coins to it.
return function(world)
    local Bank = world.Server.Require("Wallet.Bank")
    world:Boot()
    local alice = world:AddPlayer("Alice")
    world:Step(0.1)
    Bank.Give(alice, 25)
    local wallet = Bank.Wallets[alice]
    world.Logf("Alice has %s coins", world.Fmt(wallet.Coins))
    assert(wallet.Coins == 25, "a pickup of 25 should leave Alice with 25 coins")
end

shot run WalletPickup prints

==== WalletPickup
[client:Alice print] client started
Alice has 25 coins

==== summary
  WalletPickup   OK  (2 lines, 0 errors, 0.1s virtual)
1 of 1 scenario(s) passed

and when the assert fails

---- scenario error
  [scenario] Scenarios/WalletPickup.luau:10: a pickup of 25 should leave Alice with 25 coins
  • The comment lines at the top are the summary shot run lists.
  • The file returns function(world). A scenario passes when it ends without an error and game code raised none. assert (or error) fails it at that line; log what you want to see and read the log.
  • It is plain Luau, not ShotScript: no types line, no use, no doc.

The world

world.Server.Require("Wallet.Bank")a module, by its dotted path under ReplicatedStorage.Shot: folders and the file name ("Wallet.Bank"); a folder's entry file is the folder ("Wallet"). A function you call from it runs on that module's side.
world.Server.Find("Wallet.BuyRequest")an instance (an .instance remote, a folder) by the same kind of path
world:Boot()runs every enabled server script (Init/Server), and every enabled client script for each client, now and for players who join later
world:RunScript("Init.Server")one script
world:AddPlayer(name, options?)-> (serverPlayer, client); options { UserId = , DisplayName = }. UserIds go 1001, 1002, ... by join order; give the old one to rejoin
world:RemovePlayer(player | name | userId)the player leaves: PlayerRemoving everywhere, then its client stops
world:Client(name)the client of a player (a name, UserId or Player)
world:Shutdown()shuts the server down: BindToClose callbacks run, players leave
client.Require("Market.MarketClient")the same module on that client (client.LocalPlayer is its player)
world:Step(seconds, dt?)runs frames (dt 1/60): remotes are delivered, waits finish, Heartbeat
world:SkipTime(seconds)moves the clocks without frames (daily rewards, cooldowns)
world:SetPosition(player, Vector3)gives the player a character there, or moves it
world.Log(...), world.Logf(format, ...)a log line; world.Fmt(value) is a value as stable text (tables with sorted keys, numbers with 4 decimals, Instances by full name)
world:Try(fn, ...)pcall, with the error's file:line removed
world:RemoteCountsSorted()what went over the network: each remote and how many times, sorted
world:ReceivedBy(client)the remotes a client received, in order
world:ReadDataStore(name, key)a copy of a stored value, to look at
world:WriteDataStore(name, key, value)writes a value straight into a store (old data for a load to find)
world:DataStoreCounts()how many data store calls of each kind were made
world:SetStudio(false)before a system starts: RunService:IsStudio() answers false, so it takes the paths a live server takes
world:SetDataStoreLatency(seconds)before a system starts: every data store call yields that long first, to reach the paths that run while data is still loading
world:ErrorCount()game-code errors so far

A call that yields (InvokeServer, a wait) inside a scenario is resumed by the runner, which steps frames until it can continue.

What is modelled

Instances, attributes and their changed signals, tags, server and client copies of the tree, remotes (delivered on the next frame, arguments copied as Roblox does: metatables dropped, a table with [1] cut at the first nil, other non-string keys turned into strings, each change logged as a note line), Players, RunService, HttpService, DataStoreService in memory, task.*, wait, delay, virtual time (os.clock, time, tick, os.time). Not modelled: physics, rendering, GUIs, character movement, MarketplaceService, TeleportService, TextService, MessagingService, MemoryStore, HTTP.

A note line fails the scenario: shot run says FAILED and shows the note as its first error, because the game probably didn't mean to lose that data. When a scenario knows a note will come, it marks it with world:ExpectNote(text), anywhere before it ends: a note that contains text (plain text, not a pattern) then doesn't fail it, and nothing is logged for the mark. Give enough of the note to match only that one, like world:ExpectNote("mixed table: key Count is lost"); any other note still fails the scenario.

Keep a scenario's log the same on every run: Random.new needs a seed, and log values, counts and reasons, not GUIDs, ids the game makes up, or tables keyed by objects iterated without sorting.