Skip to content

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},
)
  1. Extra keyword arguments to @pgtg_obstacle (like act_probability) become defaults for every instance. A positive obstacle_weight (the default is 1.0) is all it takes to let the random-map generator place this obstacle. Set obstacle_weight=0 to define an obstacle that is only ever placed by hand-authored maps, never randomly.
  2. Return False to let the step continue, True to halt the remaining substeps this step. See the contract below.
  3. The decorated function is an obstacle factory. Call it to get an Obstacle instance for the environment. There is no rng argument: 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.

env = PGTGEnv(random_map_context={"obstacle_probability": 0.3})  # mud is already in play

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. change velocity_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).