Skip to content

Writing your own environment

StableTested and in active use. Breaking changes will be called out.

An environment is a struct implementing a trait combination. Which combination you pick determines which algorithms will accept it, and the compiler enforces that — a tabular agent will not instantiate against an environment that cannot produce a state index.

Your statesYour actionsImplementWorks with
discrete, enumerablediscreteDiscreteEnvQ-Learning, SARSA, Monte Carlo
continuous vectordiscreteBoxDiscreteActionEnvDQN, Rainbow, PPO-discrete, tile coding
continuous vectorcontinuousBoxContinuousActionEnvDDPG, TD3, SAC, PPO
continuous vectorcontinuous, with termination/truncation splitTerminationAwareEnvthe above, correctly bootstrapped
two-player, turn-baseddiscreteTwoPlayerDiscreteEnvAlphaZero, MuZero

See Traits for what each requires.

from noeira.core.state import State
from noeira.core.action import Action
struct MyState(State):
var x: Float64
var v: Float64
struct MyAction(Action):
var thrust: Float64

State requires Copyable, ImplicitlyCopyable, Movable and Deinitable, plus __eq__; Action is a lighter marker trait. For standard shapes the framework provides generics — ObsState[N] for an N-dimensional observation and ContAction[N] for an N-dimensional continuous action — so you often do not need your own.

A discrete example:

from noeira.core.env_traits import DiscreteEnv
struct MyEnv(DiscreteEnv):
comptime dtype = DType.float64
comptime StateType = MyState
comptime ActionType = MyAction
var state: MyState
var steps: Int
def step(mut self, action: MyAction, verbose: Bool = False)
-> Tuple[MyState, Float64, Bool]:
# advance dynamics, compute reward, decide termination
...
def reset(mut self) -> MyState: ...
def get_state(mut self) -> MyState: ...
def close(mut self): ...
def was_terminated(self) -> Bool:
# True only for genuine termination, NOT the time limit
return self.state.x < -1.0
# DiscreteStateEnv
def state_to_index(self, state: MyState) -> Int: ...
def num_states(self) -> Int: ...
# DiscreteActionEnv
def action_from_index(self, action_idx: Int) -> MyAction: ...
def num_actions(self) -> Int: ...

Nothing else needs to know your type:

var env = MyEnv()
var agent = QLearningAgent(
num_states=env.num_states(),
num_actions=env.num_actions(),
)
_ = agent.train(env, num_episodes=500)

Optional, and a bigger commitment. Implement GPUDiscreteEnv or GPUContinuousEnv — separate traits, not extensions of the CPU ones, because a batched device environment has a genuinely different interface: it steps N environments per call, writes observations into device tensors, and handles per-environment resets internally.

The design rule is that state is stored strided as [BATCH, STATE_SIZE] so one kernel launch advances every environment. Look at physics2d for a worked example — its PhysicsLayout computes compile-time offsets for bodies, contacts and joints across the batch dimension.

Once implemented, wrap it for the batched drivers:

from noeira.deep_agents.training.batched_env import BatchedGpuDiscreteEnv
comptime EnvT = BatchedGpuDiscreteEnv[MyEnv[DT], N_ENVS, OBS_DIM, 1]

If your environment is a rigid-body system, you probably want physics3d rather than hand-written dynamics. Define the model in MJCF XML — the same format MuJoCo uses — and wrap it as a compile-time model:

from noeira.physics3d.parser import ModelDefFromXML

Then implement a config struct (Phyics3dEnvConfig) supplying observation extraction, reward, termination and any pre-step hooks, and use Phyics3dEnv[MyModel, MyConfig] on the CPU or Phyics3dBatchedEnv[MyModel, MyConfig, N_ENVS] on the GPU. The 3D locomotion environments are all built this way and are the best templates — hopper/ is the smallest complete one.

For a manipulation task over a scene of props, write a family and a task file instead of a config: see Writing a family.

  • Trait combination matches the algorithms you intend to use
  • was_terminated() distinguishes termination from truncation
  • reset() randomises initial state — a fixed start teaches memorisation
  • Reward scale is sane; distributional agents care about the range
  • A tabular or random-policy smoke run terminates and produces finite returns
  • Dimensions exposed as compile-time constants, so networks can read them