Skip to content

Customize rewards

cfg.reward_fn is the function the env calls every step to score the new state for both sides. Two paths to swap it:

  1. Pass it as a constructor kwarg — ScenarioConfig(..., reward_fn=MyReward()). This is the canonical override path; __post_init__ only consults cfg.game.default_reward_fn() when reward_fn is None.
  2. dataclasses.replace — cfg = replace(cfg, reward_fn=MyReward()). Useful when you already have a cfg in hand and want to swap one field without re-listing the rest.

Where defaults come from

Every Game subclass declares its default reward (and termination) via default_reward_fn / default_termination_fn. ScenarioConfig.__post_init__ calls those when cfg.reward_fn is None. The mapping is:

Game Default reward
NoGame ZeroReward (no-op; you'll want to override)
LadyBanditGuard LbgZeroSumReward (parameterized by breach_radius_m, catch_radius_m)
PursuitEvasion PursuitEvasionReward (reads cfg.game.capture_distance_m)
SunBlocking SunBlockingReward
ObservationBlocking ObservationBlockingReward

The protocol

orbitalgym.rewards.base.RewardFn

Bases: Protocol

Structural protocol for a per-step reward function.

Called as reward_fn(prev_state, action, next_state, side, params, t). action is a full Actions container (both sides) so multi-side rewards (e.g. zero-sum penalties using both sides' actions) can be expressed naturally. Returns shape per scope: PER_VEHICLE: (N_side,) PER_SIDE: () (scalar)

orbitalgym.rewards.base.RewardScope

Bases: StrEnum

A RewardFn returns a jax.Array whose shape depends on scope:

  • PER_SIDE — scalar (()).
  • PER_VEHICLE — (N_side,).

It receives both sides' actions in one call (so multi-side rewards like zero-sum penalties are natural), the previous and next env states, the Side it's scoring, the cfg via params, and the step counter t.

Bundled implementations

ZeroReward is the no-op reward (NoGame's default). It always returns 0; useful as an explicit "I will supply my own reward" placeholder:

orbitalgym.rewards.reference.ZeroReward dataclass

ZeroReward(scope: RewardScope = RewardScope.PER_SIDE)

No-op reward — returns 0.0 for any side.

The default reward for NoGame. Use as a transparent placeholder when the user is expected to supply a custom reward via the reward_fn= constructor kwarg on ScenarioConfig.

DistanceToReferenceOrbit is a reference single-agent reward — negative sum of guard distances to the reference origin, zero for the bandit. Useful when you want a guard-only signal without LBG event semantics:

orbitalgym.rewards.reference.DistanceToReferenceOrbit dataclass

DistanceToReferenceOrbit(
    scope: RewardScope = RewardScope.PER_SIDE,
)

Negative sum of guard-to-reference-orbit-origin distances.

Returns 0.0 for the bandit side (preserving the bootstrap's single-reward-on-guard-only behavior). Future zero-sum games negate explicitly inside their own reward classes.

LbgZeroSumReward is the LBG default — each side gets potential-based shaping over the distance it is trying to close, plus mirrored terminal events on catch / breach, and the guard side pays a separation charge. Reads catch_radius_m and breach_radius_m, and the dwell each event requires, from its own fields (the LadyBanditGuard.default_reward_fn builder threads those from the game knobs):

orbitalgym.rewards.lbg_zero_sum.LbgZeroSumReward dataclass

LbgZeroSumReward(
    shaping_scale_m: float = 300.0,
    shaping_gain: float = 50.0,
    shaping_discount: float = 1.0,
    home_weight: float = 0.0,
    guard_separation_m: float = 20.0,
    lady_keepout_m: float = 20.0,
    separation_cost: float = 10.0,
    r_catch: float = 1000.0,
    r_breach: float = 1000.0,
    catch_radius_m: float = 50.0,
    breach_radius_m: float = 5.0,
    catch_speed_mps: float = float("inf"),
    breach_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,
    dv_cost: float = 0.0,
    scope: RewardScope = RewardScope.PER_SIDE,
)

Potential-shaped reward with zero-sum terminal events for Lady-Bandit-Guard.

The terminal events are mirrored; the shaping is not, because each side gets a potential over the distance it is trying to close. The shaping gives each side a dense gradient toward the right behavior without moving the game's equilibria, while the terminal events dominate the cumulative return when triggered. The potential is zero at an absorbing state, so the step that ends the episode pays no shaping beyond returning the potential the side had.

shaping_scale_m is the distance that makes a potential unity, shaping_gain scales the shaping term (zero for a terminal-only game), shaping_discount must match the planner's per-step discount for a side's shaped value to be exactly its unshaped value less its own potential, and home_weight prices a guard's distance from the lady inside the guard's potential.

catch_dwell_steps and breach_dwell_steps hold back the matching terminal bonus until a bandit has spent that many consecutive steps inside the radius, matching the termination condition so the bonus is paid on the step the episode ends. Zero pays it on the first step inside the radius.

guard_separation_m, lady_keepout_m and separation_cost set the guard-side crowding charge; dv_cost is the price of fuel in reward units per m/s of delta-v. Both are real per-side costs rather than shaping: each side pays only its own, so raising either makes that side more careful without handing the other an advantage.

potential

potential(state, side: Side) -> jax.Array

:func:lbg_potential for side at this reward's scale and home weight.

The potentials and the separation charge are module-level functions, so a custom reward or a value estimate can reuse the same geometry:

orbitalgym.rewards.lbg_zero_sum.lbg_distances

lbg_distances(
    state,
) -> tuple[jax.Array, jax.Array, jax.Array]

(d_gb, d_bl, d_gl) minima from the vehicle positions in state.

d_gb is the nearest guard-bandit distance over all pairs, d_bl the nearest bandit-lady distance, and d_gl the nearest guard-lady distance. Leading axes of the side states pass through. Both potentials and any value estimate built on them read these three numbers, so computing them once and passing them to :func:lbg_potential_from_distances avoids repeating the pairwise reduction.

orbitalgym.rewards.lbg_zero_sum.lbg_potential_from_distances

lbg_potential_from_distances(
    side: Side,
    d_gb: Array,
    d_bl: Array,
    d_gl: Array,
    shaping_scale_m: float,
    home_weight: float,
) -> jax.Array

The potential side sees, from distances already reduced.

The guard's is (-d_gb - home_weight * d_gl) / shaping_scale_m and the bandit's is -d_bl / shaping_scale_m, so each side's potential rises as that side closes what it is chasing. home_weight reaches the guard's potential only; the bandit's does not read where the guard is.

orbitalgym.rewards.lbg_zero_sum.lbg_potential

lbg_potential(
    state,
    side: Side,
    shaping_scale_m: float,
    home_weight: float,
) -> jax.Array

The shaping potential side sees at state.

:func:lbg_potential_from_distances applied to :func:lbg_distances. The potential is defined as zero at an absorbing state; the reward applies that on the terminating transition rather than here, because whether a step terminates is a property of the transition, not of the arriving state alone.

orbitalgym.rewards.lbg_zero_sum.guard_separation_cost

guard_separation_cost(
    state,
    guard_separation_m: float,
    lady_keepout_m: float,
    separation_cost: float,
) -> jax.Array

Crowding charge on the guard side at state.

One squared hinge per guard pair inside guard_separation_m plus one per guard inside lady_keepout_m of the lady, each scaled by separation_cost. Returned positive; the caller subtracts it.

Pursuit-Evasion ships its own zero-sum reward (PursuitEvasionReward) that reads cfg.game.capture_distance_m. Sun-Blocking ships SunBlockingReward, and Observation-Blocking ships ObservationBlockingReward.

Worked example: a sparse "breach happened" reward

Replace the shaped default with a sparse penalty that fires only on the breach step:

    import dataclasses
    from dataclasses import dataclass

    import jax.numpy as jnp

    from orbitalgym import OrbitalGymEnv, Side, SingleAgentView, make_lady_bandit_guard
    from orbitalgym.policies import ZeroControl
    from orbitalgym.rewards.base import RewardScope
    @dataclass(frozen=True)
    class SparseBreachReward:
        """+0 every step; -1 to the guard on the step where the bandit
        breaches the lady (and +1 to the bandit, mirrored)."""

        breach_radius_m: float
        scope: RewardScope = RewardScope.PER_SIDE

        def __call__(self, prev_state, action, next_state, side, params, t):
            del prev_state, action, params, t
            bandits = next_state.bandits
            positions = bandits.rtn[:, :3] if hasattr(bandits, "rtn") else bandits.rt[:, :2]
            min_dist = jnp.min(jnp.linalg.norm(positions, axis=-1))
            breached = min_dist < self.breach_radius_m
            # Guard loses 1 on breach; bandit mirrors.
            penalty = jnp.where(breached, -1.0, 0.0)
            return jnp.where(side == Side.GUARD, penalty, -penalty)

Wire it onto the cfg:

    cfg = make_lady_bandit_guard(seed=0, max_horizon_s=200.0)
    cfg = dataclasses.replace(cfg, reward_fn=SparseBreachReward(breach_radius_m=5.0))
    env = OrbitalGymEnv(cfg)

Per-game applicability

Game Default reward What's reasonable to swap Worth varying?
Lady-Bandit-Guard LbgZeroSumReward (per-side potential shaping + zero-sum terminal events) Sparse breach-only reward, distance + control-effort, single-agent DistanceToReferenceOrbit Yes — sparse vs dense changes learning dynamics fundamentally
Pursuit-Evasion Zero-sum on relative distance Time-discounted distance, capture-bonus Yes — capture-bonus speeds up learning
Sun-Blocking Sun-line geometry score Add control effort penalty Less so — geometry term is the load-bearing signal
Observation-Blocking Visibility-gated geometry Multi-target reward composition Yes — multi-target is research-relevant

See also