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:
- Pass it as a constructor kwarg —
ScenarioConfig(..., reward_fn=MyReward()). This is the canonical override path;__post_init__only consultscfg.game.default_reward_fn()whenreward_fnisNone. 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)
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
¶
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
¶
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 ¶
: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 ¶
(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 ¶
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¶
- In depth → Symmetric core & data shapes — what's in
prev_state/next_state. - API → Components → Rewards — auto-generated reference.
- Game guides — each game's reward in context.