the symptom, in your words

"Static registry persists across editor Play restarts: gate on live objects"

✓ verified on 26.07.22
lane Tooling & environmentposted updated

▸ SYMPTOM

After stopping and restarting Play in the editor (or after a code hotload), a static list or registry populated in the previous session is still full, but its entries point at GameObjects and Components that were destroyed with the old scene. Code that iterates the registry and acts on its contents (spawning relative to registered positions, querying registered state) operates on dead references, causing silent wrong behavior: objects teleport to destroyed positions, routines append to stale data, or fall-through failures cascade.

The registry reports a plausible count (e.g. 30 entries), masking the fact that every entry is stale.

▸ CAUSE

An editor Play stop → start is a scene reload, not an assembly reload. C# static fields survive scene reloads. s&box's code hotload also preserves statics by design. So a static List<T> or static Dictionary<K, V> populated by Components during Play session N is still populated at session N+1's boot, but every GameObject / Component reference in it was destroyed when the old scene tore down.

Clearing the static on the teardown path (e.g. in OnDestroy or a reset method) helps but is not sufficient alone: the static also survives a fresh session that never called the clear path (first Play after a hotload, or a session that didn't trigger the specific teardown flow).

▸ FIX

Gate on live-object validity, not static contents. When iterating a static registry, skip any entry whose Component.IsValid() or GameObject.IsValid() returns false. If no valid entries remain, treat the registry as empty:

snippet
static readonly List<MyRegisteredThing> Registry = new();

// When consuming the registry:
var live = Registry.Where( r => r.Component.IsValid() ).ToList();
if ( live.Count == 0 )
{
    // Registry is effectively empty - handle the absent case
    return;
}
// Operate only on live entries

Optionally, prune dead entries lazily during iteration to keep the list from growing unboundedly across sessions. And still clear on teardown as good hygiene, but never rely on it as the sole guard.

▸ SECOND FAILURE MODE: MASKING (PLAY-ORDER-DEPENDENT)

The same persisting-registry mechanism can produce a masking failure instead of a dangling-reference crash. A scene registered several high-priority entries into a library's static catalog and never cleared them on teardown. Switching to an unrelated scene left those entries in place, and because they outranked every entry the new scene registered, they pinned the catalog's UI state to targets that could never complete: their trigger objects lived only in the now-unloaded first scene.

Each scene was individually correct, so the bug depended entirely on play order: it appeared only after the first scene had been played earlier in the same editor process, and a fresh editor restart made it vanish, reading as flaky rather than a real defect. The tell that cracked it: a diagnostic listing command printed an entry whose source scene didn't match the loaded one.

Rules that follow:

  1. Any scene component that writes into a static registry must hand the registration back in OnDestroy: either clear it outright or restore whatever it overwrote.
  2. Library authors should give the registry a scoped-registration or snapshot/restore seam so a consumer can't leak into it by default.
  3. A catalog's diagnostic listing should label each entry with its source scene: an entry from a scene that isn't loaded is the immediate giveaway.
  4. Treat play-order-dependent flakiness (works standalone, breaks after playing scene A then scene B, vanishes on editor restart) as a signal to check for statics that survived a previous session, not as ordinary flake.

▸ THIRD FAILURE MODE: A SELF-TEST BATTERY CONVAR (CROSS-BATTERY HIJACK)

The same persistence law reconfirmed on a single arming [ConVar] bool, not a registry or list, and it produced a third failure shape: cross-battery hijack. A convar that arms a self-test battery on a 0-to-1 edge stays 1 across play_stop and play_start. So you run battery A to completion, stop, and start battery B without disarming A. The instant battery B's session boots, A re-arms itself, and both batteries drive the same player.

Seen live: a phase-0 battery re-armed mid-run and stole the door interaction a HUD run needed. That broke the HUD battery's own prompt-visible check, with no error anywhere pointing at the real cause.

Protocol for any battery convar:

  1. Disarm every known battery convar (set each to 0) before each run.
  2. Arm exactly the one battery under test.
  3. Confirm the run started by watching its result count rise, not by watching the convar value change or a log line appear. A hijacked run can still print a plausible single log line while a different, already-armed suite drives the outcome underneath it.

This is the same persistence law crossing a full Play-session boundary, with no hotload involved.

▸ WHY IT WORKS

IsValid() checks both nullity and the alive/destroyed flag on the underlying engine object. A destroyed GameObject's managed wrapper is non-null (it survives in the static list) but IsValid() returns false, correctly identifying it as unusable. This makes the guard robust against every scenario that leaves stale entries: scene reloads, hotloads, partial teardowns, and sessions that skip the explicit clear path.

Verified on engine 26.07.22: seen in a real project.
s&box moves fast; an undated fix is a liability. Spot a stale detail?
changelog
  • Synced the 26.08.05 self-test battery update: added the third failure shape, a self-test battery [ConVar] bool that survives Play stop/start and hijacks a later battery. Added the disarm-all-then-arm-one protocol and the confirm-by-result-count check.
  • Synced 26.07.22 source update: added the masking-failure variant (stale high-priority entries outrank a new scene) and the OnDestroy-handback rules; re-stamped verifiedOn 26.07.22.

Want to know when new guides or fixes drop? Join the community to help build this out. Report gotchas, flag outdated fixes, or just lurk.

Join the Discord