Playtest Guide¶
Run deterministic gameplay scenarios with assertions and state snapshots.
Overview¶
Playtesting executes a script of gameplay steps: move, wait, assert state, check console. Results are compressed for readability.
Quick Start¶
script = """MOVE /Player TO 5,0,0
WAIT 1.0
ASSERT /Player|Health|hp > 0
ASSERT_CONSOLE_CLEAN"""
await editor(action="play")
try:
result = await run_playtest(script=script, timeout=30.0)
finally:
await editor(action="stop")
# PLAYTEST: 4/4 (1.1s) OK
run_playtest requires Play Mode. Successful step details are removed from the compact result; failures, snapshots, and logs remain.
Run one scenario¶
Pass either inline script text or a saved .playtest path, never both. Use defs for reusable VAL definitions, fresh=True to reload the active scene before the first step, and snapshot_on_failure=True when a failure must include current alias values and recent console errors.
# From file
await run_playtest(path="Assets/Playtests/combat.playtest")
# With defs and fresh start
await run_playtest(
script="ASSERT $player|Health|hp > 0",
defs="VAL $player /Player",
fresh=True,
snapshot_on_failure=True,
)
Run a suite¶
Run multiple .playtest files sequentially with a compact pass/fail matrix. Provide either pattern (a glob or comma/newline-separated paths) or suite_path (a file listing one scenario per line), never both. Use restart_between=True only when each case needs a fresh Play Mode session; a failed stop or start is a suite failure, not a best-effort warning.
await run_playtest_suite(pattern="Playtests/*.playtest", stop_on_fail=True)
# → SUITE: 5/6 passed (42s)
# ✓ movement.playtest (3s)
# ✗ combat.playtest (8s) — ASSERT Health > 0 FAIL
# ...
An empty match is a failure: a passing suite always reports a non-zero exact ratio such as SUITE: 5/5 passed. See the generated schema for every option and default.
lint_playtest¶
Static DSL validation without executing. Checks: unresolved $alias, deprecated ALIAS keyword, unknown CALL targets, mixed AND/OR, missing ASSERT_CONSOLE_CLEAN.
await lint_playtest(script="ASSERT $unknown|Health > 0")
# → WARN:1: unresolved alias $unknown
await lint_playtest(path="Assets/Playtests/combat.playtest")
# → OK
lint_playtest_suite(pattern=..., suite_path=...) lints multiple files at once.
Setup and Teardown¶
Use SETUP and TEARDOWN blocks to organize initialization and cleanup.
SETUP
# Runs before main steps
TELEPORT /Player 0,0,0
ASSERT /Player|Health == 100
SETUP_END
# Main test steps
ASSERT /Player|Score > 0
TEARDOWN
# Runs after normal completion or a non-aborting failure
ASSERT_CONSOLE_CLEAN
LOG Test complete
TEARDOWN_END
Behavior: - SETUP runs before main steps. If any SETUP step fails, the runner skips remaining SETUP steps and jumps directly to TEARDOWN (if present). Main steps do NOT execute. - TEARDOWN runs after normal completion and non-aborting setup or main-step failures. A global ABORT_ON_FAIL, a per-step timeout ABORT, an external Play Mode stop, a global timeout, or an unhandled runner error can end the run before TEARDOWN completes. Do not use it as the only restoration mechanism. - Use SETUP for one-time initialization (spawn objects, set state). - Use TEARDOWN for verification of final state and log collection.
DSL Quick Reference¶
Core Commands¶
| Command | Purpose | Example |
|---|---|---|
MOVE | Walk to position | MOVE /Player TO 5,0,0 |
MOVE_PATH | Walk through waypoints | MOVE_PATH 0,0,0 > 5,0,0 > 10,0,0 TIMEOUT 15 |
TELEPORT | Instant move | TELEPORT /Player 0,0,0 |
SET_ACTIVE | Toggle active | SET_ACTIVE /Boss false |
WAIT | Sleep | WAIT 2.0 |
WAIT_UNTIL | Poll condition | WAIT_UNTIL /Player\|Health == 100 TIMEOUT 10 |
WAIT_CAPTURED | Poll captured value | WAIT_CAPTURED hp_before INCREASED TIMEOUT 10 |
SET | Set runtime field | SET /Player Health value 50 |
INVOKE | Call method | INVOKE /Enemy EnemyAI AttackPlayer |
CLICK / TAP | Click UI object | CLICK /Canvas/StartButton WAIT 0.5 |
Assertions¶
| Command | Purpose | Example |
|---|---|---|
ASSERT | Test condition | ASSERT /Player\|Health == 100 |
ASSERT_BATCH | Multiple asserts | ASSERT_BATCH ... END |
ASSERT_NEAR | Distance check | ASSERT_NEAR /Player /Enemy 5.0 |
ASSERT_CTA | CTA visibility or interactivity | ASSERT_CTA CLICKABLE |
ASSERT_CONSOLE_CLEAN | No errors | ASSERT_CONSOLE_CLEAN IGNORE "warning" |
ASSERT_CONSERVED | Sum constant check | ASSERT_CONSERVED SUM /A\|val + /B\|val == CONSTANT OVER 3 |
ASSERT_CAPTURED | Compare snapshot | ASSERT_CAPTURED health_before INCREASED |
ASSERT_CHANGED | Value changed since capture | ASSERT_CHANGED $label |
ASSERT_ONE_ACTIVE | Exactly one active | ASSERT_ONE_ACTIVE /Cam_Intro /Cam_Menu /Cam_Game |
ASSERT_FRAMES_DIFFER | Captured frames differ | ASSERT_FRAMES_DIFFER my_label |
ASSERT_FRAMES_STATIC | Captured frames identical | ASSERT_FRAMES_STATIC my_label |
Capture & Monitor¶
| Command | Purpose | Example |
|---|---|---|
CAPTURE | Save value | CAPTURE health_before /Player\|Health |
CAPTURE_FRAMES | Capture N screenshots | CAPTURE_FRAMES 5 INTERVAL 0.2 CAMERA game LABEL anim |
SNAPSHOT | Capture state | SNAPSHOT /Player\|Health |
MONITOR | Watch value over time | MONITOR /Player\|Health |
INVARIANT | Always true check | INVARIANT /Player\|Health > 0 |
Flow Control¶
| Command | Purpose | Example |
|---|---|---|
SECTION | Label a group of steps | SECTION "Combat Phase" |
DESC | Label the next step | DESC "Check initial health" |
LOG | Print to results | LOG Starting combat test |
TIMESCALE | Time speed | TIMESCALE 0.5 |
SIMULATE | Run simulation | SIMULATE physics DURATION 2.0 TIMESCALE 1.0 |
TRACE_FLOW | Trace value flow | TRACE_FLOW FROM /A TO /B FIELD Health |
Compound Commands (parse-time expansion)¶
| Command | Purpose | Example |
|---|---|---|
SWEEP_PATH | Move along path with dwell | See below |
COMPLETE_PURCHASE | Invoke purchase + wait for expected state | COMPLETE_PURCHASE $buy_gate EXPECT followed by expected aliases and TIMEOUT |
INVOKE_REPEAT | Invoke N times | INVOKE_REPEAT 3 /Enemy Health TakeDamage 10 |
Directives (not emitted as steps)¶
| Directive | Purpose | Example |
|---|---|---|
VAL | Path or const alias | VAL $player /Player |
VAR | Runtime alias (resolves live) | VAR $hp @/Player\|Health\|value |
MACRO / END_MACRO | Define reusable block | MACRO check_health ... END_MACRO |
CALL | Invoke a macro | CALL check_health |
INCLUDE | Import definitions file | INCLUDE path/to/file.defs |
FOR / END_FOR | Loop with range | FOR $i IN 0..5 ... END_FOR |
ABORT_ON_FAIL | Stop after the first failed step or automatic console failure; remaining steps, including TEARDOWN, are skipped | ABORT_ON_FAIL |
SET_DEFAULT_TIMEOUT | Default timeout for steps | SET_DEFAULT_TIMEOUT 10 |
PATH_PREFIX | Prefix for all paths | PATH_PREFIX /Level1 |
COMMENT / END_COMMENT | Block comment | COMMENT ... END_COMMENT |
SWEEP_PATH Example¶
SWEEP_PATH /Player DWELL 0.5
0,0,0 > 5,0,0 > 10,0,0
UNTIL /Player|Trigger|activated == true TIMEOUT 10
Expands at parse time to Move+Wait per waypoint, then a WaitUntil.
Aliases & Substitution¶
Define once, use everywhere. Sigil syntax is $name (no curly braces).
Three alias types:
| Type | Syntax | Behavior |
|---|---|---|
| Path alias | VAL $name /path\|Comp\|field | Expands at parse time to the path string |
| Const alias | VAL $name some_literal | Expands at parse time to the literal value |
| Runtime alias | VAR $name @/path\|Comp\|field | Resolves live value each step |
Aliases work in batch and all direct MCP tools. Suffix preserved: $alias|Comp|field expands to expanded-path|Comp|field.
Use INCLUDE path/to/file.defs to import alias definitions from external files.
Path Special Characters¶
Handle literal slashes and backslashes in GameObject names using backslash escaping or bracket protection.
Escaping Rules: - \/ — literal forward slash in the GameObject name - \\ — literal backslash in the GameObject name - [Name/With/Slashes] — bracket protection (entire segment as one path component, no escaping needed)
Examples:
# GameObject named "Day/Night"
ASSERT /Day\/Night|Health|hp == 100
# GameObject named "Folder\Path" (Windows-style)
ASSERT /Folder\\Path|Component|field == value
# Using brackets for "Zone A/Zone B"
ASSERT /[Zone A/Zone B]/Child|Comp|field == value
GetPath(go) round-trips through FindObject(path) only when every hierarchy segment name identifies a unique child. Duplicate root or sibling names make a text path ambiguous; use the transient $HEX entity reference reported by the ambiguity response for the current Editor process instead.
GameObject Property Shorthands¶
Assert on GameObject properties without specifying a component:
ASSERT /Player|activeSelf
ASSERT /Player|activeInHierarchy
ASSERT /Player|tag == Player
ASSERT /Player|layer == 0
ASSERT /Player|name == Player
Bool fields like activeSelf don't need == true — bare ASSERT is sufficient.
Bool Value Aliases¶
When setting or comparing bool values, use common bool spellings — all normalize to Unity's serialized format automatically:
| Input | Normalizes to |
|---|---|
true, yes, on | True |
false, no, off | False |
Works in SET_ACTIVE, SET, and all assertions:
SET_ACTIVE /Player true # or: yes, on
SET /NPC Enabled enabled no # sets to False
ASSERT /Door|Locked == yes # equivalent to == True
All inputs are case-insensitive (TRUE, Yes, OFF all work).
Virtual Fields¶
Synthetic fields on well-known components:
| Component | Field | Returns |
|---|---|---|
Animator | currentState or stateName | Active clip name |
Rigidbody | speed | Velocity magnitude |
Rigidbody2D | speed | Velocity magnitude |
FOR Loops¶
Range is exclusive (0..5 = 0,1,2,3,4). Max 10000 iterations.
Macros¶
Macros expand at parse time. Nested macros are not supported.
Full Example: Combat Test¶
script = """
LOG Starting combat scenario
SECTION "Setup"
# Snapshot initial state
CAPTURE initial_health /Player|Health|value
# Trigger combat
INVOKE /Enemy EnemyAI AttackPlayer
WAIT_CAPTURED initial_health DECREASED TIMEOUT 5
# Verify damage
SECTION "Verification"
ASSERT_CAPTURED initial_health DECREASED
ASSERT_CONSOLE_CLEAN IGNORE "test_warning"
ASSERT /Player|Health|value > 0
LOG Combat completed
SNAPSHOT /Player|Health
"""
await run_playtest(script=script, timeout=60.0)
Comparison Operators¶
| Op | Meaning | Example |
|---|---|---|
== | Equals | Health == 50 |
!= | Not equals | Status != dead |
> | Greater | Score > 100 |
< | Less | Health < 50 |
>= | Greater-equal | Distance >= 2.0 |
<= | Less-equal | Time <= 10.0 |
contains | Substring match | Name contains Player |
Common Patterns¶
Before/after snapshots:
Frame comparison (animation playing):
Exclusive camera check:
Physics conservation check:
Timeout & Performance¶
| Config | Default | Notes |
|---|---|---|
| Script timeout | 120s | Total execution time |
| MOVE timeout | 15s | Per movement command |
| WAIT_UNTIL timeout | 5s | Per poll condition |
| SET_DEFAULT_TIMEOUT | custom | Overrides per-step default |
| SIMULATE duration | per DURATION | TIMESCALE defaults to 1.0 |
Error Handling¶
| Result | Meaning | Example |
|---|---|---|
| PASS | Assertion true | ASSERT Health > 0 — PASS |
| FAIL | Assertion false | ASSERT Health > 100 — FAIL |
| ERR | Exception | ASSERT NonExistent/Field — ERR |
| TIMEOUT | Deadline exceeded | WAIT_UNTIL X timeout=5 — TIMEOUT |
Use abort_on_fail=True or the global ABORT_ON_FAIL directive to stop after the first FAIL, ERR, TIMEOUT, or automatic CONSOLE_ERR. This skips every remaining step, including TEARDOWN. A per-step ABORT applies only to that WAIT_UNTIL timeout and stops Play Mode.
Use snapshot_on_failure=True to capture alias values and console errors on failure.
Console Filtering¶
Ignore known warnings:
Report Compression¶
When sampling is enabled, long reports may be summarized to keep the result compact:
[Compressed] 24/25 passed
[3] ASSERT Player/Health > 0 — FAIL
[15] WAIT_UNTIL enemy_dead timeout=5 — TIMEOUT
See also: Runtime Tools for multi-step verification.