Skip to content
Back to skills

Add Backend

ASecurity

Add a new game engine (backend) to fmri-gym, or wrap a game that has no Gymnasium env: the adapter, the gym/ package, the config, the pyproject extra, the tests row, and the traps that make a backend look fine and replay wrong. Use when asked to add, port or wire up a game.

  • 3 stars
  • 0 votes
  • 0 copies
  • 0 views
  • Added November 7, 2026
developmentpythongobashawstestinggitbackend

Security analysis

A92/100
  • mediumInstalls packages at runtime which could introduce malicious dependencies

Pro shows the line behind each finding and how to fix it

Scanned November 7, 2026

npx -y skills add tomov/fmri-gym --skill add-backend --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Add Backend?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Add Backend
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/tomov-add-backend/badge)](https://www.skillsdirectory.com/skills/tomov-add-backend)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: add-backend
description: Add a new game engine (backend) to fmri-gym, or wrap a game that has no Gymnasium env: the adapter, the gym/ package, the config, the pyproject extra, the tests row, and the traps that make a backend look fine and replay wrong. Use when asked to add, port or wire up a game.
---

# Adding a backend

Read AGENTS.md first: rule 1 (the game logic lives in the gym env, not the adapter) decides where every line below goes.

## The checklist

0. Check that no existing backend already plays the game, possibly out of the box: `ale` takes any Atari ROM, `retro` any libretro core, `gym` any registered Gymnasium env, `vizdoom` any Doom scenario. If one does, all you need is a config.
1. `fmri_gym/adapters/<BACKEND>.py`: module docstring (what the engine is, why it was chosen, what its observation/action spaces look like, what is not supported, and which keys belong in the phase's `env_kwargs` for `gym.make`), then a subclass of `EnvAdapter` overriding only the hooks you actually need. Everything else is inherited; do not re-state the defaults. In `_make`, pass constructor kwargs with `**spec.get("env_kwargs", {})` into `gym.make` (or into your thin env wrapper), like `default.py` and `vizdoom.py`; fixed adapter choices (`render_mode`, turning off an env's own trial pacing) stay in the adapter, not in the curriculum. `adapters/vizdoom.py`, `baba.py` and `minihack.py` are the models; stay under about 150 lines, one file.
2. `fmri_gym/adapters/__init__.py`: one `if backend == ...:` branch with a lazy import.
3. `configs/curricula/<BACKEND>/levels/<GAME>.json`: a runnable curriculum of one level (a curriculum that plays several goes in `configs/curricula/<BACKEND>/` itself), and `configs/curricula/<BACKEND>/common/README.md` with any install step that is not a plain `pip install` and what the action indices in its `controls` mean. Anything that is an argument to `gym.make` or your env's constructor goes in `"env_kwargs": { ... }` on the game phase, not as sibling keys of `game` / `controls` / `fps` (see `vizdoom/common/freedoom1.json`, `crafter/common/crafter.json`, `rushhour/common/rushhour.json`). Shared phases hold the common `env_kwargs`; a level file or an `__include` overlay merges object keys, so a line like `"env_kwargs": {"puzzle": "p07"}` keeps the shared `movable_only`. Comments in the JSON (`"## ..."` keys) are for what is particular to one file, and few. Lay it out like the others: one `##` comment per line, the message's `text` one screen line per line, fixations as one-liners, and the game block on a few lines with `controls` (and `menu`) inline, not one control per line. A game with several levels gets one curriculum per level in `levels/`, whose game phases `__include` (`"../common/<GAME>.json"`) a shared `configs/curricula/<BACKEND>/common/<GAME>.json` (the controls, the instructions, the settings) and state only the level and its title (README, "Shared phases"). A controller curriculum's shared phase shows a controls image above its Controls lines (the `controls-images` skill).
4. `pyproject.toml`: one extra under `[project.optional-dependencies]` (and a mention in `dbp` / `all` if it belongs there), then `uv lock`. A game without a Gymnasium env gets one first, as its own small package under `gym/<GAME>/` (`pyproject.toml`, `README.md`, `<game>_gym/{__init__,env}.py`, a `gym.register` id), listed in `[tool.uv.sources]`; the extra installs that package. It wraps the game, it does not contain it.
5. `tests/backends.py`: a `Backend` row (the extra, the module that proves it is installed, one or two short smoke configs, the `gym/` dirs it runs on, its `external/` checkout, a `ci` reason if a hosted Linux runner cannot play it), and a line in `.github/workflows/tests.yml`'s matrix unless it has one. `test_configs` fails until the row exists.
6. `README.md`: only if the backend needs setup beyond `pip install` (a repo checkout, a binary, an env var).

Then play it, through the suite and by hand:

```bash
uv run pytest --backend <BACKEND>
uv run fmri-play --subject sub-test --dummy-trigger --ses 1 --run 1 --rig configs/examples/rigs/laptop.json \
    --curriculum configs/curricula/<BACKEND>/levels/<GAME>.json
uv run ruff check fmri_gym fmri_play.py agents tests
```

The suite's four checks (playable, recorded, replays, runs) are the acceptance test; the `testing` skill says how to read a failure. A backend that passes everything but **replays** is not done: reproducibility is the product.

## Things that are easy to get wrong

- **A control is a keyboard key's pygame name, upper-cased, without `K_`** (`"LEFT"`, `"SPACE"`, `"Z"`), or a controller's control name, its pygame constant (`"CONTROLLER_BUTTON_A"`, `"CONTROLLER_AXIS_LEFTY_UP"`); all are listed in `ControlNames` (`fmri_gym/inputs/control_names.py`). Add an unlisted key to `ControlNames` and to `NAMES` in `fmri_gym/inputs/keyboard.py` rather than mapping keycodes yourself; a config that names a control no device has is refused.
- **Every game phase writes its whole `controls` map; there is no default.** A `MultiBinary` env takes button indices (held controls combine), anything else takes the action itself and a real-time phase also needs the `""` entry, the action for nothing held. One class per action space in `adapters/keymap.py`. Don't add a per-backend default or a merge step: the map depends on the site's input device, not on the engine.
- **`env_kwargs` is where constructor options live.** Do not teach a new backend to read puzzle ids, scenario flags, or other `gym.make` kwargs off the top level of `spec`; put them in `env_kwargs` and document them in the adapter docstring. Older backends (`vgdl`, `arc3`) still expose a few top-level fields; new work should not copy that pattern.
- **Copy observations you keep.** Several envs reuse their observation buffers, so `capture` must `.copy()` anything it stores (see `minihack.py`). The replay check is what found nethack logging each frame's variables one frame late: a view of a reused buffer, pickled after the loop had moved on.
- **`render()` is not always free of side effects.** An engine that draws from the same RNG its dynamics use spends a draw on every render, so one extra call shifts everything after it and the run silently stops matching its own log. Return the frame `step` already produced rather than rendering again; crafter's night noise is the live example, and it is why `gym/crafter` caches the observation and hands that back.
- **Reproducibility is the product.** A block must be replayable from `episode_seeds` + `actions`. If the env has no savestate of its own, wrap it in `replay_state_gym.ReplayState` (`gym/replay_state/`: the seed and the actions since, restored by replay) and make sure `reset(seed=...)` really determines the episode. **Test it rather than assume it**: `uv run python -m tests.verify_replay --curriculum <config>` records a block with the random policy, replays it in a *second process* under another `PYTHONHASHSEED` and compares every frame, every sound chunk and every savestate anchor bit for bit. A second process because the usual culprit is hash order, which is fixed for the life of one interpreter and would let a replay inside the recording process pass. Crafter passes every surface check (a seed argument, a seeded `RandomState`, deterministic worldgen) and still diverges, because one creature list is built from a Python `set` and the despawn pick therefore follows object `id()`, which moves with `PYTHONHASHSEED`. Sorting that list is the whole fix, and it lives in a fork the env package pins (`gym/crafter/pyproject.toml`: `crafter @ git+https://github.com/chengfanbrain/crafter.git@deterministic`), not in the adapter: when the bug is in the engine, fix the engine and pin the build, or the rig starts owning game behaviour.
- **Sound is part of the record.** An engine that makes sound returns this step's PCM from `sound()` (the contract is in `EnvAdapter.sound`; `retro.py` is a two-line example); the loop plays it and logs it, and the replay check compares it chunk for chunk. ViZDoom's first episode after `init()` sounded different from one after `new_episode()`, which is why `vizdoom_gym` begins every episode the same way.
- **Turn-based games** (grid worlds, puzzles) need `"is_turn_based": true` in the config.
- **Slow real-time games** (a grid world that must keep ticking, so `is_turn_based` is out) need nothing more: a frame's action is resolved from the keys held at the tick *and* the keys pressed during the frame, so a tap that begins and ends between two frames still counts for one.

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.