Custom Obstacles¶
Obstacles are a plug-in mechanism. A custom obstacle is just a function that reacts to the
car, decorated with @pgtg_obstacle. You can give it its own trigger probability,
observation plane, render color, and random-map generation weight.
The guiding principle is observation follows generation: an obstacle that can actually appear in an environment is, by default, also visible to the agent. Using this decorator, you do not have to manage the obstacle's feature bit by hand.
A minimal obstacle¶
from pgtg import PGTGEnv, StepContext, pgtg_obstacle
@pgtg_obstacle("MUD", act_probability=0.25, obstacle_weight=1.0) # (1)!
def mud_behavior(env: PGTGEnv, local_ctx: StepContext) -> bool:
"""Mud drags the car: halve the current substep's velocity."""
if local_ctx.velocity_substep is not None:
local_ctx.velocity_substep = local_ctx.velocity_substep // 2
return False # (2)!
env = PGTGEnv(
obstacles=[mud_behavior()], # (3)!
random_map_context={"obstacle_probability": 0.3},
)
- Extra keyword arguments to
@pgtg_obstacle(likeact_probability) become defaults for every instance. A positiveobstacle_weight(the default is1.0) is all it takes to let the random-map generator place this obstacle. Setobstacle_weight=0to define an obstacle that is only ever placed by hand-authored maps, never randomly. - Return
Falseto let the step continue,Trueto halt the remaining substeps this step. See the contract below. - The decorated function is an obstacle factory. Call it to get an
Obstacleinstance for the environment. There is norngargument: every obstacle's generator is re-seeded from the environment seed on each reset to obtain reproducible episodes.
Passing obstacles= augments the generatable pool
An obstacles list does not replace the built-in ice/sand/broken-road set. The
environment keeps your instances and auto-fills a default handler for every other
generatable obstacle you did not cover, so a placed obstacle always has something that
acts on it. To stop an obstacle from being placed, give it obstacle_weight=0 (or use
the obs_feature_weights whitelist) rather than omitting it from the list. The traffic
light is always added by the environment and never needs to be in the list.
You usually don't even need to pass obstacles= at all: a registered obstacle with a
positive weight is auto-instantiated into every environment, so it is active, generated,
and observed just by being defined and imported.
The obstacle contract¶
Your behavior function has the signature (env: PGTGEnv, local_ctx: StepContext) -> bool
and is only called when (a) the obstacle's feature is present at the car's current
cell and (b) a random draw comes up below act_probability. Inside it you can:
- Modify the current substep via
local_ctx(e.g. changevelocity_substep). - Change persistent car state via
env(e.g.env.velocity,env.flat_tire). - Add a penalty via
local_ctx.sum_penalty.
StepContext fields you will typically touch:
| Field | Meaning |
|---|---|
current_position |
The car's position for this substep. |
velocity_substep |
The unit velocity vector for this substep (may be reassigned). |
acceleration |
The acceleration applied this step. |
sum_reward, sum_penalty |
Reward/penalty accumulators for the step (add to them). |
remaining_substeps |
The substeps still to process. Set to [None] to cancel the rest of the velocity after validating the move. This is how sand stops the car safely. |
Don't move the car and return True
Returning True halts the substep loop immediately. If you also moved the car yourself,
the destination cell is never validated and the car can end up inside a wall or off the map.
To stop the car, set local_ctx.remaining_substeps = [None] (as sand does), then return False.
Randomness inside the behavior should use the obstacle's own generator, exposed as an
attribute on the decorated function (e.g. mud_behavior.rng). Each generator is seeded
from the environment seed and re-seeded on every reset, so your obstacle is
reproducible under a fixed seed, and independent from stepping on other obstacles.
See reproducibility.
Observation follows generation¶
observe is a tri-state (bool | None) and defaults to None = auto:
observe |
The obstacle's feature plane is in the observation… |
|---|---|
None (default) |
…iff the obstacle can actually appear in this env. It is generated (positive effective weight with obstacle_probability > 0) or present on a loaded static map. |
True |
…always, even when the obstacle is never generated here. Use this to keep a channel for a policy trained with an obstacle that you now run on maps without it. |
False |
…never. The obstacle still acts when stepped on; it is just invisible to the agent. The agent will not be able to learn to avoid this obstacle! |
This means the common cases just work with no obs_features bookkeeping:
# ice/sand/broken_road are generated here -> automatically observed (observe defaults to auto)
env = PGTGEnv(random_map_context={"obstacle_probability": 0.5})
# A static map with no ice -> ice is NOT in the observation, even though it is a
# registered obstacle. Declaring an obstacle does not force it into every observation.
env = PGTGEnv(map_path="benchmark_4x4")
A hidden obstacle that can still end the episode
If an obstacle can appear here but you set observe=False (or drop its structural
feature from obs_features), the environment logs a warning: the agent cannot learn to
avoid what it cannot see.
For the human/rgb_array renderers, @pgtg_obstacle(color=(r, g, b)) sets the render color.
Random-map generation¶
An obstacle is placed by the procedural generator
when it is in the RANDOM_OBSTACLE category (the decorator's category default) and
has a positive weight. Its frequency relative to other obstacles is obstacle_weight,
which you can override per-environment through RandomMapContext.obs_feature_weights:
env = PGTGEnv(
random_map_context={
"obstacle_probability": 0.3,
"obs_feature_weights": {
"ice": 1.0,
mud_behavior.observation_feature: 2.0, # mud twice as common as ice
},
},
)
Retuning weights globally: set_obstacle_weight¶
obs_feature_weights is per-environment.
To change an obstacle's default weight process-wide use pgtg.set_obstacle_weight:
import pgtg
# Accepts a {name: weight} mapping or a single name, weight pair.
pgtg.set_obstacle_weight({"ice": 0.2, "broken road": 0.0})
pgtg.set_obstacle_weight("sand", 3.0)
pgtg.get_obstacle_weight("ice") # -> 0.2
# Built afterwards: ice (0.2) and sand (3.0) are generated -> and, via observe=auto,
# observed; broken_road (0.0) is neither generated nor observed.
env = PGTGEnv(random_map_context={"obstacle_probability": 0.5})
The change persists on the process-global obstacle registry until set again. Because
observation follows generation, setting a weight to 0 removes the obstacle from both
generation and the observation; a positive weight puts it back in both.
# An ice-only curriculum stage, no per-env config needed
pgtg.set_obstacle_weight({"ice": 1.0, "sand": 0.0, "broken_road": 0.0})
Reproducibility & tests
set_obstacle_weight mutates process-global state.
Prefer per-environment obs_feature_weights inside a single training run
if you need several environments with different obstacle mixtures.
Tuning the built-in obstacles¶
Both the trigger probability and the generation weight are just parameters of the obstacle
instance, so you can re-tune a built-in by re-instantiating it with a different
act_probability or obstacle_weight.
The factory call overrides the registered default:
from pgtg.obstacles import broken_road_behavior, ice_behavior, sand_behavior
env = PGTGEnv(
obstacles=[
ice_behavior(act_probability=0.3, obstacle_weight=2.0),
sand_behavior(act_probability=0.1),
broken_road_behavior(obstacle_weight=0.0),
],
)
A per-instance obstacle_weight beats the obs_feature_weights default and the registry
weight, so it's the most local way to retune placement. In short, there are three levels,
narrowest-scope first:
| Mechanism | Scope | Wins over |
|---|---|---|
factory(..., obstacle_weight=w) in obstacles= |
that one instance | everything below |
obs_feature_weights={...} in RandomMapContext |
that one environment | the registry default |
pgtg.set_obstacle_weight(...) |
every env built afterwards | the decorator's obstacle_weight |
act_probability is per-instance only
Unlike obstacle_weight, there is no global setter for act_probability.
It lives on the obstacle object, so re-instantiating (as above) is the way to change it.
@pgtg_obstacle parameters¶
| Parameter | Default | Meaning |
|---|---|---|
name |
— | Human-readable name; also how the obstacle is referenced in saved maps and in set_obstacle_weight. |
observation_feature |
auto | The feature bit. (Usually allocated automatically.) |
obstacle_weight |
1.0 |
Random-map frequency weight. > 0 makes the obstacle generatable; 0 excludes it from random generation. Seeds the registry default that set_obstacle_weight later overrides. |
observe |
None (auto) |
Tri-state: None = observed iff generatable here; True = always; False = never. |
color |
auto | Render color (r, g, b). |
act_probability, … |
— | Extra keyword arguments become Obstacle defaults (e.g. the trigger probability). |