Summary
Add a condition-wait primitive: evaluate a user-supplied C# boolean expression repeatedly inside the editor and return as soon as it's true, or exit non-zero on timeout.
unityctl wait --for 'GameObject.Find("Player") != null'
unityctl wait --for '<C# bool expr>' [--timeout <seconds>] [--interval <ms>] [-u <namespaces>]
Today wait (UnityCtl.Cli/WaitCommand.cs) only polls /health until the editor is connected and ready — it has no notion of a runtime/editor condition. There is no built-in way to wait for "the thing I'm about to observe actually exists yet."
Problem it solves
After play enter returns (at EnteredPlayMode), the game's own startup still runs over subsequent frames — scenes finish loading, objects spawn from pools/Addressables, async initializers and network/coroutine callbacks complete. Any command that observes runtime state (snapshot, script eval, ui click, screenshot) can race that startup.
Without a wait primitive, the two available workarounds are both bad:
- Fixed
sleep N — racy and guessy. Too short and the command runs before the state is ready (null refs, empty snapshots, blank screenshots); too long and every iteration wastes wall-clock. The "right" delay isn't knowable in advance and varies per machine/run.
- Shell poll loops — e.g.
until unityctl script eval '<expr>' | grep -q true; do sleep 1; done. Each tick spawns a fresh CLI → bridge → Unity round-trip (process start + WebSocket RPC + C# compile of the snippet), so it's heavy and slow. It also tempts the worse anti-pattern of busy-waiting inside a single script eval (while(!ready){}) — but eval runs on Unity's main thread, so a spin-wait there deadlocks the editor (the condition can never change because the main thread is blocked). A first-class wait removes the temptation entirely.
A bridge-side condition wait collapses all of that into one persistent request, evaluated at frame granularity, with the editor staying responsive between checks.
Example scenarios (all generic Unity patterns)
- Spawned/loaded object exists:
wait --for 'GameObject.Find("Boss") != null' before snapshotting or clicking it.
- Async scene / Addressables load finished:
wait --for -u UnityEngine.SceneManagement 'SceneManager.GetActiveScene().name == "Level1" && SceneManager.GetActiveScene().isLoaded'.
- Loading screen / initializer done:
wait --for 'Object.FindFirstObjectByType<GameBootstrap>().IsReady' before driving the UI.
- A value crosses a threshold:
wait --for 'Object.FindFirstObjectByType<Spawner>().ActiveCount >= 4'.
- State machine reached a state:
wait --for 'Object.FindFirstObjectByType<FlowController>().State == AppState.MainMenu'.
- Editor-side op settled (edit mode): wait for an importer/build/bake flag to flip before continuing a scripted pipeline.
Proposed behavior
- Reuse the eval pipeline so the predicate has the same ergonomics as
script eval: default usings, -u for extra namespaces, the Object → UnityEngine.Object alias (ScriptCommands.BuildEvalCode).
- Evaluate on the main thread once per
--interval (default e.g. 250–500ms) via an EditorApplication.update / player-loop callback, not a spin-wait, so the editor keeps ticking between evaluations.
- Return
0 with a small result (e.g. elapsed time, final value) when the predicate becomes true; return non-zero on --timeout with a clear message (and ideally the predicate's last evaluated value, to aid debugging "why didn't it fire").
- Predicate must return
bool (or be coercible); a compile error in the predicate should fail fast with diagnostics, not silently loop.
- A new Unity-side RPC (register a per-tick evaluator that resolves when true/timeout) is cleaner than the bridge re-sending eval each tick — keeps it main-thread-safe and frame-granular. Bridge holds the request open with a
--timeout-derived budget, mirroring the existing long-running command configs in UnityCtl.Bridge/BridgeEndpoints.cs.
Notes / open questions
- Flag naming: the existing
wait uses --poll-timeout for the readiness wait; the condition wait wants its own --timeout + --interval. Decide whether --for extends wait (readiness is implied as a precondition) or is documented as a distinct mode.
- Optional follow-up (separate, not required here): a
--log-pattern <regex> variant to wait for a console log line, for conditions not expressible as a single predicate.
- Should pair well with SKILL.md guidance explicitly warning against spin-waiting inside
script eval (main-thread deadlock) and pointing to wait --for instead.
Summary
Add a condition-wait primitive: evaluate a user-supplied C# boolean expression repeatedly inside the editor and return as soon as it's
true, or exit non-zero on timeout.Today
wait(UnityCtl.Cli/WaitCommand.cs) only polls/healthuntil the editor is connected and ready — it has no notion of a runtime/editor condition. There is no built-in way to wait for "the thing I'm about to observe actually exists yet."Problem it solves
After
play enterreturns (atEnteredPlayMode), the game's own startup still runs over subsequent frames — scenes finish loading, objects spawn from pools/Addressables, async initializers and network/coroutine callbacks complete. Any command that observes runtime state (snapshot,script eval,ui click,screenshot) can race that startup.Without a wait primitive, the two available workarounds are both bad:
sleep N— racy and guessy. Too short and the command runs before the state is ready (null refs, empty snapshots, blank screenshots); too long and every iteration wastes wall-clock. The "right" delay isn't knowable in advance and varies per machine/run.until unityctl script eval '<expr>' | grep -q true; do sleep 1; done. Each tick spawns a fresh CLI → bridge → Unity round-trip (process start + WebSocket RPC + C# compile of the snippet), so it's heavy and slow. It also tempts the worse anti-pattern of busy-waiting inside a singlescript eval(while(!ready){}) — but eval runs on Unity's main thread, so a spin-wait there deadlocks the editor (the condition can never change because the main thread is blocked). A first-class wait removes the temptation entirely.A bridge-side condition wait collapses all of that into one persistent request, evaluated at frame granularity, with the editor staying responsive between checks.
Example scenarios (all generic Unity patterns)
wait --for 'GameObject.Find("Boss") != null'before snapshotting or clicking it.wait --for -u UnityEngine.SceneManagement 'SceneManager.GetActiveScene().name == "Level1" && SceneManager.GetActiveScene().isLoaded'.wait --for 'Object.FindFirstObjectByType<GameBootstrap>().IsReady'before driving the UI.wait --for 'Object.FindFirstObjectByType<Spawner>().ActiveCount >= 4'.wait --for 'Object.FindFirstObjectByType<FlowController>().State == AppState.MainMenu'.Proposed behavior
script eval: default usings,-ufor extra namespaces, theObject→UnityEngine.Objectalias (ScriptCommands.BuildEvalCode).--interval(default e.g. 250–500ms) via anEditorApplication.update/ player-loop callback, not a spin-wait, so the editor keeps ticking between evaluations.0with a small result (e.g. elapsed time, final value) when the predicate becomestrue; return non-zero on--timeoutwith a clear message (and ideally the predicate's last evaluated value, to aid debugging "why didn't it fire").bool(or be coercible); a compile error in the predicate should fail fast with diagnostics, not silently loop.--timeout-derived budget, mirroring the existing long-running command configs inUnityCtl.Bridge/BridgeEndpoints.cs.Notes / open questions
waituses--poll-timeoutfor the readiness wait; the condition wait wants its own--timeout+--interval. Decide whether--forextendswait(readiness is implied as a precondition) or is documented as a distinct mode.--log-pattern <regex>variant to wait for a console log line, for conditions not expressible as a single predicate.script eval(main-thread deadlock) and pointing towait --forinstead.