Custom environment¶
Add an env-client-side environment so plugrl-run-env-client <env_id> can discover and run it.
Quickstart¶
Create an env class and a config dataclass, then register both. This one is complete and runs as written: a point on a plane moves toward a goal, with a 2-dimensional continuous action.
import dataclasses
import gymnasium as gym
import numpy as np
from plugrl_env_client.envs.base_env import (
Action,
BaseEnv,
BaseEnvConfig,
BoolArray,
Observation,
RewardArray,
)
from plugrl_env_client.utils.registration import register_env, register_env_config
UID = "Point-v1" # the CLI subcommand is the lowercase form, point-v1
@register_env_config(UID)
@dataclasses.dataclass
class PointConfig(BaseEnvConfig):
"""Every field needs a default: register_env_config calls PointConfig()
at import time. Each field becomes a flag, e.g. --env.step-size."""
step_size: float = 0.1 # distance moved per step at full action
goal_radius: float = 0.1 # how close counts as reaching the goal
@register_env(UID, max_episode_steps=100, best_reward_threshold_for_success=1.0)
class PointEnv(BaseEnv):
"""A point on a plane moves toward a goal. Reward 1.0 on reaching it."""
def __init__(
self,
config: PointConfig,
num_envs: int = 1,
process_id: int | None = None,
total_processes: int | None = None,
):
super().__init__(
config=config,
num_envs=num_envs,
process_id=process_id,
total_processes=total_processes,
)
self.step_size = config.step_size
self.goal_radius = config.goal_radius
# The env client sizes its action buffer from single_action_space
# (the action of ONE env) before the first step.
self.single_action_space = gym.spaces.Box(-1.0, 1.0, (2,), np.float32)
self.action_space = gym.spaces.Box(-1.0, 1.0, (num_envs, 2), np.float32)
self.pos = np.zeros((num_envs, 2), dtype=np.float32)
self.goal = np.zeros((num_envs, 2), dtype=np.float32)
def _obs(self) -> Observation:
# Every array has the env batch as its leading axis.
return Observation(
images={},
states={"pos": self.pos.copy(), "goal": self.goal.copy()},
text="move to the goal",
)
def reset(
self, *, seed: int | None = None, options: dict | None = None
) -> tuple[Observation, dict]:
self.seed_rngs(seed) # first, so that --runner.seed has an effect
idx = np.arange(self.num_envs)
if options is not None and options.get("reset_indices") is not None:
# After an episode ends the client resets only the finished envs.
idx = np.asarray(options["reset_indices"], dtype=np.int64)
# Draw from self.np_random, not np.random, or the seed does nothing.
self.pos[idx] = self.np_random.uniform(-1.0, 1.0, (len(idx), 2))
self.goal[idx] = self.np_random.uniform(-1.0, 1.0, (len(idx), 2))
return self._obs(), {} # the whole batch, not only the reset envs
def step(
self, actions: Action
) -> tuple[Observation, RewardArray, BoolArray, BoolArray, dict]:
a = np.clip(np.asarray(actions, dtype=np.float32), -1.0, 1.0) # (num_envs, 2)
self.pos = np.clip(self.pos + self.step_size * a, -1.0, 1.0)
dist = np.linalg.norm(self.pos - self.goal, axis=1)
reached = dist < self.goal_radius
reward = np.where(reached, 1.0, -dist).astype(np.float32) # (num_envs,)
terminated = reached # bool, (num_envs,)
truncated = np.zeros(self.num_envs, dtype=np.bool_) # the time limit is added for you
# Do not reset here. The client sends this terminal observation and
# then calls reset(options={"reset_indices": ...}) itself.
return self._obs(), reward, terminated, truncated, {}
if __name__ == "__main__":
from plugrl_env_client.cli import main
main()
Where the file goes¶
The CLI can only offer an env whose module was imported before the CLI was built. There are two ways to get there.
Inside the env client. Save the file as
src/plugrl_env_client/envs/point/point_env.py in your plugrl-env-client
checkout, with an empty __init__.py beside it like the shipped families.
At startup the CLI imports every file named *_env.py under
plugrl_env_client/envs/, so uv run plugrl-run-env-client point-v1 finds
it. If that import fails, the CLI only prints a Skip loading env module ...
warning and the ID is missing.
Anywhere else. Keep the if __name__ == "__main__": block and run the
file itself with the env client's Python, for example from inside
plugrl-env-client:
Importing the file registers the env, and plugrl_env_client.cli.main() then
builds the CLI with it included. plugrl-run-env-client on its own never
imports your file, so it will not list the env. The CLI module builds its
list of envs when it is first imported, so if you write your own launcher,
import your env module before plugrl_env_client.cli.
examples/pusht/pusht_env.py in plugrl-env-client is built this way.
Verify¶
The env should appear as a CLI subcommand (in-tree placement shown; for the
other, replace plugrl-run-env-client with python /path/to/point_env.py):
Run it against a dummy server. The dummy policy's action is 7-dimensional by
default, for dummy-v1, so match this env's 2 dimensions:
# Terminal A, in plugrl-server
uv run plugrl-run-server dummy-policy default dummy default --policy.action-dim 2
# Terminal B, in plugrl-env-client
uv run plugrl-run-env-client point-v1 --server-host 127.0.0.1 --server-port 8000 --num-episodes 3
The client exits 0. runs/<exp-name>/rollout/proc_000/summary.json has the
episode count and mean return.
Contract¶
- Env inherits
BaseEnv. Config inheritsBaseEnvConfigand is a dataclass whose every field has a default. __init__takesconfig, num_envs, process_id, total_processes, the same four asBaseEnv.__init__and as the shippedMuJoCoEnv.EnvSpec.makealways passesnum_envs, andgym.make_vecforwardsprocess_idandtotal_processes. Both areNoneunless the client runs with--runner.pass-proc-id. An earlier version of this page usedworker_idandtotal_workers; those names appear nowhere inplugrl-env-client, and a class with that signature raisesTypeErroron the unexpectednum_envs.__init__setssingle_action_space, the space of one env's action. The client reads its shape and dtype before the first step;action_spaceis only a fallback.- Everything is batched over
num_envs.stepreceives actions of shape(num_envs, *action_shape)and returns(Observation, reward, terminated, truncated, info): reward a float32 array of shape(num_envs,), terminated and truncated bool arrays of shape(num_envs,), info a dict. Every image and state array in theObservationhasnum_envsas its leading axis;textmay be a single string. reset(*, seed=None, options=None)returns(Observation, info)for the whole batch. When episodes end, the client callsreset(options={"reset_indices": ...}): reset only those envs, and still return the observation of all of them. The option arrives withnum_envs=1too, so do not pass it on to a wrapped Gymnasium env.resetcallsself.seed_rngs(seed)first and draws all randomness fromself.np_random. Otherwise--runner.seeddoes nothing. The client passes the seed on the first reset only.stepmust not reset the env itself. The step that reports terminated or truncated returns that episode's last observation; the client sends it as the terminal observation and resets the env afterwards (SPEC.md §5.4). An env that resets insidestepsends the next episode's first observation instead, and nothing raises.- info can be
{}. The client adds theepisodestatistics the server reads; the server reads nothing else from info.
Registration¶
register_env_config(uid)registers the config dataclass. It calls the class with no arguments at import time, which is why every field needs a default.register_env(uid, ...)registers the env class. The CLI subcommand isuidin lowercase.max_episode_stepsadds a time limit that setstruncated.--runner.max-episode-stepsoverrides it at run time.best_reward_threshold_for_success: an episode counts as a success if any of its step rewards reaches this value. That decides thesthe server averages intorollout/success. Without it, success is always false androllout/successstays 0.- Any other keyword argument is passed to the env's
__init__, and must be JSON-serializable, orregister_envraisesRuntimeError.
Troubleshooting¶
| Symptom | Cause |
|---|---|
| Env ID not listed | Its module was not imported. In-tree: the file name must end in _env.py; look for a Skip loading env module warning. Elsewhere: run the file itself, not plugrl-run-env-client |
Env must expose action space via single_action_space/action_space |
__init__ did not set single_action_space |
Expected action shape tail (2,), got (7,) |
The server policy's action dimension differs from the env's; set --policy.action-dim on the server |
Expected reward shape (1,), got () |
step returned a plain float; return an array of shape (num_envs,) |
| Multi process init conflicts | Try --runner.use-env-lock |