Skip to content

Customize termination

cfg.termination_fn is called every step with the pre-step state, the new state, the cfg, and the new time, and returns a scalar bool array. Both states are passed so a termination can resolve events that peak between two decision samples. Once it fires, the episode ends and per-side done fields broadcast that scalar (see In depth → Symmetric core).

Two paths to swap the termination:

  1. Pass it as a constructor kwarg — ScenarioConfig(..., termination_fn=MyTermination()). __post_init__ only consults cfg.game.default_termination_fn() when termination_fn is None.
  2. dataclasses.replace — cfg = replace(cfg, termination_fn=MyTermination()).

Where defaults come from

Every Game subclass declares its default termination via default_termination_fn. ScenarioConfig.__post_init__ calls it when cfg.termination_fn is None. The mapping is:

Game Default termination
NoGame MaxStepsOnly (step-cap only, no spatial event)
LadyBanditGuard LbgEventTermination (max-steps OR bandit breach OR guard catch, both resolved within the step and optionally over a dwell)
PursuitEvasion PursuitEvasionTermination (max-steps OR capture)
SunBlocking MaxStepsOnly
ObservationBlocking MaxStepsOnly

The protocol

orbitalgym.termination.base.TerminationFn

Bases: Protocol

Episode-level termination — returns scalar bool.

Per-side done in SideOutput is this scalar broadcast (kept for shape uniformity). Per-side termination (one side gives up, the other does not) is a Phase-N concern; not needed by the four bootstrap games.

Bundled implementations

MaxStepsOnly is the universal step-cap termination — it ends the episode when state.step >= params.max_steps. It reads max_steps from the cfg at call time, so it can be constructed without a cfg in scope. NoGame, SunBlocking, and ObservationBlocking use it as their default:

orbitalgym.termination.reference.MaxStepsOnly dataclass

MaxStepsOnly()

Episode ends when state.step >= params.max_steps. No spatial event.

The default termination for NoGame. Reads max_steps from the cfg at call time, so it can be constructed without a cfg in scope — which is what lets each Game subclass build its default termination cleanly inside default_termination_fn.

LbgEventTermination adds the bandit-breach and guard-catch events on top of the step cap; it's LadyBanditGuard's default and reads breach_radius_m / catch_radius_m plus the optional relative-speed gates breach_speed_mps / catch_speed_mps from its own fields (which LadyBanditGuard.default_termination_fn threads from the game knobs). Each event is evaluated over the whole step by treating the relative motion as a straight line between samples, so a slow encounter that never lands inside the radius at a sample still fires. catch_dwell_steps and breach_dwell_steps turn either event into a hold: the event fires once a bandit has been inside that radius for that many consecutive steps, counted on the environment state, and zero (the default) fires on the first step inside it:

orbitalgym.termination.lbg_events.LbgEventTermination dataclass

LbgEventTermination(
    breach_radius_m: float,
    catch_radius_m: float = 0.0,
    breach_speed_mps: float = float("inf"),
    catch_speed_mps: float = float("inf"),
    catch_dwell_steps: int = 0,
    breach_dwell_steps: int = 0,
    escape_radius_m: float = 0.0,
    repel_on_empty_tank: bool = False,
)

Terminate on max_steps OR breach OR catch OR every bandit repelled.

The catch event is optional: set catch_radius_m=0 to disable, leaving only the max-steps and breach gates. catch_dwell_steps and breach_dwell_steps require the corresponding radius to be held for that many consecutive steps; zero, the default, fires on the first step inside it. Both repel gates are off by default: escape_radius_m=0 disables the distance gate and repel_on_empty_tank=False the propellant gate. max_steps, the step duration and the reference orbit are read from params at call time (single source of truth on the cfg).

PursuitEvasion ships PursuitEvasionTermination that fires on max_steps OR capture (relative distance below cfg.game.capture_distance_m).

Worked example A: max-steps only (no breach)

    import dataclasses
    from dataclasses import dataclass

    import jax.numpy as jnp

    from orbitalgym import OrbitalGymEnv, SingleAgentView, make_lady_bandit_guard
    from orbitalgym.termination.reference import MaxStepsOnly
    # The framework already ships `MaxStepsOnly` (reads `params.max_steps`
    # at call time, no stored field). Wire it onto the cfg to drop LBG's
    # event-driven termination and keep only the step-cap.
    cfg = make_lady_bandit_guard(seed=0, max_horizon_s=200.0)
    cfg = dataclasses.replace(cfg, termination_fn=MaxStepsOnly())

Worked example B: AND-composite of two existing conditions

    @dataclass(frozen=True)
    class AndComposite:
        """Episode ends only when BOTH conditions are met (rare; usually OR)."""

        first: object
        second: object

        def __call__(self, prev_state, state, params, t):
            return jnp.logical_and(
                self.first(prev_state, state, params, t),
                self.second(prev_state, state, params, t),
            )

AndComposite accepts any two callables of the right shape and ANDs their results. Use it sparingly — most game-design reasons want OR composition, which you can write the same way with jnp.logical_or.

Wiring is the usual dataclasses.replace:

    cfg = dataclasses.replace(
        cfg,
        termination_fn=AndComposite(MaxStepsOnly(), MaxStepsOnly()),
    )
    env = OrbitalGymEnv(cfg)

Per-game applicability

Game Default termination What's reasonable to swap Worth varying?
Lady-Bandit-Guard LbgEventTermination (max-steps + breach + catch) MaxStepsOnly for unbiased eval; AND-composites for stricter end conditions Yes — eliminates terminal-event survivor bias
Pursuit-Evasion PursuitEvasionTermination (max-steps + capture) Add fuel-out condition Sometimes — depends on study design
Sun-Blocking MaxStepsOnly Time-window plus geometry-gated termination Yes — for fixed-window benchmarks
Observation-Blocking MaxStepsOnly Visibility-window-only Yes — matches the game's gating logic

See also