Agents
Handles and reports
How agents drive your game one step at a time through C# handles, log what happened and leave you a report with the recorded run.
A compile isn't a check. The agent opens the scene in Play Mode, calls a handle, reads what came back and only then takes the next step. At the end it records the run and leaves a report beside its terminal. You stop being the tester: no pressing Play and pasting errors back.
Handles
A handle is a public static method in your runtime code marked [SaturaHandle("what it does")]. It sets up one moment of the game, does one action or reads state. When the one an agent needs doesn't exist, it writes it next to the feature, and the next feature reuses it.
Keep handles small. One big test only says it failed somewhere. One step per call says which step broke, with its value and the log lines from just before.
[SaturaHandle("Clears the pond and puts the player on the dock with the rod")]
public static void PrepareStageForFishTest() { ... }
[SaturaHandle("Waits until a fish bites or the time runs out. Yields whether it bit.")]
public static IEnumerator WaitForBite(float seconds) { ... yield return bit; }
[SaturaHandle("How deep the fish is")]
public static float GetFishDepth() => fish.position.y;
Handles go in runtime code, not an Editor folder. [SaturaHandle] comes from the SDK.
Calling handles
await handles.list()
await handles.list('Fishing')
const r = await handles.call('Fishing/CastRod', 0.8)
await handles.call('CastRod', 0.8)
handles.call(name, ...args) runs the handle with no C# to compile and returns its status, value and the log lines it wrote. A throw becomes a HandleError with its stack, and a wrong name throws with the closest names.
Handles are grouped by the class that declares them: Fishing.Pond.Drain() is Fishing/Pond/Drain. A bare name works while it's unique. Arguments can be numbers, strings, booleans, enum names, vectors as arrays and GameObject paths.
Coroutine handles
A handle that returns IEnumerator runs as a coroutine, and handles.call waits for it, up to 60 seconds by default, with the last yield as its value. An agent can also start one without waiting, check it later and stop it. Past its timeout a coroutine throws HandleTimeout and keeps running. Runs are lost on recompile and fail when Play ends.
Multiplayer handles
await handles.call('Jump', { player: 'all' })
await handles.call('Jump', { player: 'all', at: 2 })
await handles.call('Jump', { player: 2 })
player: 'all' starts every player at the same wall-clock moment and returns one result per player, so multiplayer checks need no player builds. Player numbers match the panels. See Multiplayer clones.
SaturaLog
SaturaLog.Log(tag, message) writes a stamped log file per tag, and agents read it back with unity.logs({ tag: 'fish' }). unity.logs({ source: 'tags' }) lists the tags. A built player writes these files only when Satura Space launched it, and a log file that reaches 64 MB is cleared and starts over.
SaturaLog.Log("fish", $"bite at {Time.time}");
Recording the run
video.record films the Unity panel as you'd see it while part of the script runs, and unity.screenshot adds before and after stills. The panel streams only in Play Mode, so the agent enters Play before or inside the recording.
const mp4 = await video.record(async () => {
await unity.play('Assets/Scenes/Pond.unity')
await handles.call('CastRod', 0.8)
await handles.call('WaitForBite', 10)
}, { size: '1920x1080', audio: false, path: '.sspace/videos/cast.mp4' })
await unity.stop()
return mp4
Reports
At the end of a feature, and for every failure, the agent writes a report as a React component at .sspace/reports/<name>/report.tsx and opens it beside its terminal. The report shows each check with its result, the recorded run with markers at the moments that matter, the numbers before and after, diffs, and before and after shots on a slider. You review the change by reading the report, not a wall of chat.
import { Report, Summary, Checks, Check, Video, BeforeAfter, Change } from '@satura/report'
export default function FishingReport() {
return (
<Report title="Fishing: bite and hook" status="partial">
<Summary tone="warn">2 of 3 checks pass. A strike 0.4 s into the 0.6 s window doesn't hook.</Summary>
<Checks>
<Check name="WaitForBite(10)" status="pass" detail="Bit after 3.2 s" video="run" t={3.2} />
<Check name="GetFishState" status="fail" detail="hooked=false, see FishBite.cs:91" video="run" t={3.9} />
</Checks>
<Video id="run" src="run.mp4" events={[{ t: 3.2, kind: 'ok', label: 'Bite' }]} />
<BeforeAfter title="Tuning">
<Change name="Bite delay" before={4.1} after={3.2} unit="s" better="lower" />
</BeforeAfter>
</Report>
)
}
- The report kit,
@satura/report, has Report, Summary, Checks, Steps, Video, Videos, Log, Chart, Compare, BeforeAfter, DiffTable, PlayerTable, GitDiff, Files, Image and more. report.opentype-checks the report against the kit and returns errors aspath:line:column. A clean report opens in a panel next to the agent's terminal and reloads when the file is saved.- Reports render in a sandboxed frame with the app's own components and theme, and reach the app only through a checked bridge.
- A file reference like
Bite.cs:48in the text becomes a link. - Every video you open in the app plays in the kit's frame-by-frame player.
- A report can also export as one offline
.html.
A report can carry Action buttons that run a script when you click them. Open only projects you trust: see Security.
Try a report
This is a real report an agent left after a jump pad fix, running in your browser: each check, the numbers before and after, and the recorded run.
Questions
How is this different from Unity Test Framework?
Handles run in the editor you're watching, in Play Mode, and on every multiplayer clone if you want. A failure points at the step that broke, not at a line in a long test.