Pushdown Stack

Stacked, resumable game-state scopes — push an interrupt on top and whatever was running freezes until it is popped.

Push an interrupt on top of your game; whatever was running freezes mid-stride until it is popped.

InterfaceIPushdownStackService — a factory that creates IPushdownStack instances
Off switchNullPushdownStackService
AssemblyCommonGameSystem.Core
StartupRegistered automatically at boot (one of the 23 services) — no setup needed

What it does

The Pushdown Stack is a headless, last-in-first-out stack of game states, called scopes. It lets you suspend a running gameplay simulation (or AI, or menu) and resume it mid-stride. Push a scope onto the stack — a dialogue interrupt, an AI decision, a logical menu — and the live thing underneath freezes exactly where it was. Pop it, and the previous scope wakes up with nothing to rebuild (within a session).

Only the top scope ticks; suspended scopes hold their state, frozen, until resumed. The stack is pure C# and deterministic, and its structure can be saved and restored through the Save / Load service.

Quick start

Resolve the factory from the service locator, then create a stack:

using CommonGameSystem.Core;
using UnityEngine;

[DefaultExecutionOrder(100)] // Run after the framework has started.
public class MyStackUser : MonoBehaviour
{
    private IPushdownStack _stack;

    private void Awake()
    {
        _stack = ServiceLocator.Resolve<IPushdownStackService>().Create();

        // Or construct one directly — works standalone, with no service registration:
        // _stack = new PushdownStack();
    }
}

API reference

IPushdownStack — the main interface

MemberNotes
void Push(string key, IStackScope scope)Push a scope as the new top. The old top is suspended, then the new scope is entered. key is the label used for saving.
void Pop()Pop the top scope: it exits, and the scope below resumes. A no-op on an empty stack.
void Replace(string key, IStackScope scope)Swap the top scope in place (exit the old, enter the new). The scope below stays suspended and gets no suspend/resume calls.
void Clear()Exit every scope from top to bottom (nothing resumes) and empty the stack.
void Tick(float deltaTime)Tick the top scope only; suspended scopes stay frozen. You supply deltaTime, so you pick the clock.
int Depth { get; }The number of scopes currently on the stack.
IStackScope Top { get; }The top scope, or null if the stack is empty.
string TopKey { get; }The top scope's key, or null if the stack is empty.
string[] GetFrameKeys()The scope keys from bottom to top. This allocates a new array — use it for saving, not every frame.
PushdownStackSnapshot TakeSnapshot()Capture the stack structure for save and restore.
void Restore(PushdownStackSnapshot snapshot, IScopeFactory factory)Rebuild the stack from a snapshot using your factory. No enter/suspend/resume hooks fire during the rebuild — it is a silent hard reset.
event Action<IStackScope, IStackScope> ScopeChangedFires after each completed operation with (pushed, popped). This is a plain per-instance C# event, not a message on the global Event Bus.

IStackScope — what you implement for each scope

MemberNotes
void OnEnter()Called once when the scope is pushed.
void OnSuspend()Called when another scope is pushed on top (this one freezes).
void OnResume()Called when the scope above pops (this one becomes active again).
void OnExit()Called once when the scope is popped, replaced, or cleared.
void Tick(float deltaTime)Called every tick while this scope is the top. Suspended scopes never tick.

IPushdownStackService — the factory registered by Bootstrap

MemberNotes
IPushdownStack Create(PushdownStackOptions options = default)Create a new stack with the given options.
int ActiveStackCount { get; }Count of factory-created stacks that are still alive.

PushdownStackOptions — a readonly configuration struct

MemberNotes
int EffectiveMaxStackDepth { get; }A safety limit on Push. Default 32, range 1–256.
int EffectiveMaxOpsPerDispatch { get; }The limit on queued follow-up operations processed per dispatch (see the re-entrancy pitfall below). Default 8, range 1–64.
int EffectiveInitialCapacity { get; }A pre-allocation hint for the internal scope list. Default 8, range 1–256.
bool LetScopeExceptionsPropagate { get; }When true, an exception thrown by a scope hook propagates to your code (fail-fast development mode). When false (the default), it is caught and logged, and the operation continues.
bool SuppressEmptyPopWarning { get; }When true, silences the warning logged by Pop on an empty stack, for games that pop speculatively. Default false.

PushdownStackSnapshot — a [Serializable] struct for save and restore

MemberNotes
int schemaVersionThe snapshot format version (currently 1).
string[] frameKeysThe scope keys from bottom to top. Never null; an empty stack saves as an empty array.

IScopeFactory — implemented by your game, passed only to Restore

MemberNotes
IStackScope Create(string key)Rebuild the scope for a saved key, already in its entered state. Return null to skip that scope; the skip is logged as a warning.

Examples

A dialogue interrupt that suspends gameplay:

using CommonGameSystem.Core;
using UnityEngine;

[DefaultExecutionOrder(100)] // Run after the framework has started.
public class GameplayController : MonoBehaviour
{
    private IPushdownStack _stack;

    void Awake()
    {
        _stack = ServiceLocator.Resolve<IPushdownStackService>().Create();
    }

    void Update()
    {
        RunGameplay();
        _stack.Tick(Time.deltaTime);
    }

    public void StartDialogue(DialogueScope dialogue)
    {
        _stack.Push("dialogue", dialogue);
        // Gameplay is now frozen; only the dialogue ticks.
    }

    // Called by the dialogue when it closes:
    public void EndDialogue()
    {
        _stack.Pop();
        // Gameplay resumes mid-stride.
    }

    void OnDestroy() => _stack?.Clear();  // Exits every scope top-to-bottom (nothing resumes).

    private void RunGameplay()
    {
        // Your per-frame gameplay update.
    }
}

// A simple dialogue scope:
public class DialogueScope : IStackScope
{
    public void OnEnter() => Debug.Log("Dialogue opened.");
    public void OnSuspend() { }  // Freezes naturally: suspended scopes never tick.
    public void OnResume() { }   // Nothing to do for a self-contained scope.
    public void OnExit() => Debug.Log("Dialogue closed.");
    public void Tick(float deltaTime)
    {
        // Advance the dialogue text here.
    }
}

Turning it off

ServiceLocator.Replace<IPushdownStackService>(new NullPushdownStackService());

The NullPushdownStack mutes every operation with no changes to calling code: Depth is always 0, Top is always null, all mutating calls do nothing, and ScopeChanged never fires. Use it in headless or server builds, or whenever the stack is unused. It logs one warning when constructed so you know it is active.

Common pitfalls

  • Main thread only. All operations — Push, Pop, Tick, and the rest — must run on the main thread. Worker-thread calls throw InvalidOperationException.
  • Scope internals are your responsibility. The snapshot captures only the stack structure: which scope keys, in what order. Each scope's internal state — a dialogue's current line, a state machine's current state — must be saved separately (see Save / Load) and rebuilt by your IScopeFactory.Create(key).
  • Operations issued from inside a hook are deferred. If a hook (OnEnter, OnSuspend, OnResume, OnExit, or Tick) calls Push, Pop, Replace, or Clear, that operation is queued and runs after the current hook finishes, in the order requested. At most EffectiveMaxOpsPerDispatch queued operations (default 8) run per dispatch; beyond that, a warning is logged and the rest are dropped.
  • ScopeChanged subscribers must not throw. The event fires in the middle of an operation without a try/catch. On Pop and Clear, it fires before the new top's OnResume — a throwing subscriber leaves the operation half applied.
  • IL2CPP: preserve your own scope classes. The framework preserves its own types only. A game class implementing IStackScope or IScopeFactory that is used only through its interface can be removed by IL2CPP code stripping. Protect your classes with [Preserve] or an entry in your project's link.xml.
  • Revalidate AI scopes after a resume. A dialogue or menu resumes exactly as it was, and that is correct. But an AI scope's cached view of the world (last-seen target position, real-time timers) is stale by however long it was suspended — resuming it blindly can walk a character to where an enemy used to be. Use OnResume to recheck world assumptions. Timers driven by Tick pause naturally and need no reset; only real-time (wall-clock) timers go stale.