Skip to content

Add wait --for '<C# predicate>': block until an in-Unity condition is true #52

Description

@Niaobu

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:

  1. 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.
  2. 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 ObjectUnityEngine.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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions