Skip to main content

Super Mario Bros 2 (Europe) Gymnasium Environment

Project description

smb2-gym

Python PyPI License: MIT

A Gymnasium environment for Super Mario Bros 2 (Europe/Doki Doki Panic version) using TetaNES emulator Python bindings. Perfect for reinforcement learning experiments and research.

Features:

  • Curated action sets for faster training (simple, complex)
  • Comprehensive game state via info dict (50+ properties) and a semantic tile map
  • Multiple initialisation modes (character/level, custom ROMs, save states)
  • Human-playable interface with a resizable GUI and keyboard controls
  • Up to 350+ and 750+ FPS rendered and non-rendered respectively

Example gameplay showing Mario in level 2-1 alongside the semantic tile map, colour legend and live game state panel

Installation

pip install smb2-gym

Quick Start

Basic Usage

from smb2_gym import SuperMarioBros2Env
from smb2_gym.app import InitConfig

# Create environment with character/level mode
config = InitConfig(level="1-1", character="luigi")
env = SuperMarioBros2Env(
    init_config=config,
    render_mode="human",  # "human" or None
    action_type="simple",  # "simple" (12), "complex" (16), or "all" (256)
)

# Reset environment
obs, info = env.reset()

# Run game loop
for _ in range(1000):
    action = env.action_space.sample()
    obs, reward, terminated, truncated, info = env.step(action)

    # Access game state from info dict
    print(f"Lives: {info['pc'].lives}, Hearts: {info['pc'].hearts}")
    print(f"Position: ({info['pos'].x_global}, {info['pos'].y_global})")

    if terminated or truncated:
        obs, info = env.reset()

env.close()

Initialisation Modes

from smb2_gym.app import InitConfig

# 1. Character/Level mode (default)
config = InitConfig(level="1-1", character="peach")

# 2. Built-in ROM variant mode
config = InitConfig(rom="prg0", save_state="level_1_1.sav")

# 3. Custom ROM mode
config = InitConfig(
    rom_path="/path/to/your/smb2.nes",
    save_state_path="/path/to/save.sav",  # Optional
)

Info Dict Structure

The info dict uses accessor objects for organized access to game state:

# Player Character state
info['pc'].lives
info['pc'].hearts
info['pc'].cherries
info['pc'].character  # 0=Mario, 1=Peach, 2=Toad, 3=Luigi
info['pc'].speed  # Horizontal velocity (signed: +right, -left)
info['pc'].y_velocity  # Vertical velocity (signed: -up, +falling)

# Position
info['pos'].x_global
info['pos'].y_global
info['pos'].x_local
info['pos'].y_local

# Game state
info['game'].world
info['game'].level
info['game'].is_game_over

# Enemies/Objects/Projectiles (all sprites: enemies, items, projectiles, doors, etc.)
for enemy in info['enemies']:
    if enemy.is_visible:
        enemy.object_type  # EnemyId enum (SHYGUY_RED, BULLET, HEART, MUSHROOM, etc.)
        enemy.global_x
        enemy.global_y
        enemy.health
        enemy.x_velocity
        enemy.y_velocity

# Semantic tile map (15x16 structured array)
info['semantic']

# Episode status — always present, on reset and on every step
info['life_lost']  # bool: a life was lost this step
info['level_completed']  # bool: the level was finished
info['game_over']  # bool: out of lives
info['end_reason']  # None while running, otherwise one of:
#   'level_completed' | 'game_over'
#   'life_lost'       | 'max_steps'

terminated is True for winning and for dying, so use end_reason to tell them apart when shaping rewards.

Semantic Tile Map

The environment provides a semantic tile map representing the game world around the player as a structured 15x16 numpy array:

semantic_map = info['semantic']

# Access tile information
for row in range(15):
    for col in range(16):
        tile = semantic_map[row, col]

        tile_id = tile['tile_id']  # Raw BackgroundTile ID
        fine_type = tile['fine_type']  # Fine-grained FineTileType (SOLID, CLIMBABLE, etc.)
        coarse_type = tile['coarse_type']  # Coarse-grained CoarseTileType (TERRAIN, HAZARD, etc.)

        # RGB colour for visualisation
        r, g, b = tile['color_r'], tile['color_g'], tile['color_b']

The semantic map provides a structured representation of the visible game world, useful for pathfinding, collision avoidance, and spatial reasoning in RL agents.

Terrain and sprite objects are separate layers

A cell can hold both terrain and a dynamic object — a subspace door standing on solid ground — so they occupy different fields. Sprites never overwrite the terrain beneath them, which is what distinguishes standing on a door from being inside it:

from smb2_gym.constants import NO_OBJECT, CoarseTileType, FineTileType

cell = semantic_map[row, col]

cell['fine_type']  # the TERRAIN here (SOLID, PLATFORM, ...)
cell['object_id']  # EnemyId of the sprite here, or NO_OBJECT (0xFF)
cell['object_fine_type']  # that sprite's type (DOOR, ENEMY, COIN, ...)

# Solid ground with something standing on it
standing_on = (semantic_map['coarse_type'] == CoarseTileType.TERRAIN) & (
    semantic_map['object_id'] != NO_OBJECT
)

Objects are classified by type rather than lumped together: a subspace door reads as DOOR/INTERACTIVE, a coin as COIN/COLLECTIBLE, and only genuinely hostile objects as ENEMY. The same object classifies identically whether it came from the background tile map or a sprite slot — a POW block is POW_BLOCK either way. Unrecognised object ids fall back to ENEMY, since treating an unknown hazard as harmless is the more dangerous mistake.

Object footprints are measured from the sprites actually being drawn, so oversized objects (a 1×3 Hawkmouth, a 1×2 Birdo) fill all the cells they occupy.

Tensor view (for ML)

info['semantic_tensor'] is the same data as a binary (15, 16, 16) uint8 array, suitable for feeding a conv net directly:

tensor = info['semantic_tensor']  # (H, W, C) uint8, values in {0, 1}
velocity = info['semantic_velocity']  # (H, W, 2) float32, normalised ~[-1, 1]

from smb2_gym.constants import COARSE_TENSOR_CHANNEL_NAMES

COARSE_TENSOR_CHANNEL_NAMES  # ('terrain:EMPTY', ..., 'object:DAMAGES', 'object:LIFTABLE')

Channels are binary masks rather than category ids — ids are nominal labels, and feeding them as numbers would imply ENEMY (15) is "more" than SOLID (1). Each coarse category appears twice, once per layer (14 channels). Within a layer the encoding is one-hot (exactly one terrain channel is always set); across layers it is multi-hot, so a door on solid ground sets both terrain:TERRAIN and object:INTERACTIVE.

Two property channels follow: object:DAMAGES (touching this hurts) and object:LIFTABLE (can be picked up and thrown). These describe what happens on contact, which is the decision an agent actually makes about a sprite.

Velocity is returned separately as float32, so the tensor stays a pure binary mask. Without it the map is a still frame — an agent cannot tell an enemy closing on it from one moving away. Positive X is rightward, positive Y is downward (matching row order). Concatenate if you want a single input array:

combined = np.concatenate([tensor.astype(np.float32), velocity], axis=-1)  # (15, 16, 18)

PyTorch users want tensor.transpose(2, 0, 1) for (C, H, W).

Other per-object state — direction, health, object_timer, raw sprite_flags — stays in info['enemies'] rather than becoming channels: it is useful for reward shaping and debugging, but mostly-constant channels cost input dimensionality without teaching a policy anything. Per-object collision is a runtime result (it reports what just happened, and is almost always zero), so it suits reward shaping rather than observation.

Note: There is a plan to extend the semantic map or create a separate collision_map which has the collision properties for more detailed physical interaction information.

Example Custom Reward Function

from smb2_gym import SuperMarioBros2Env
from smb2_gym.app import InitConfig


class CustomSMB2Env(SuperMarioBros2Env):
    def step(self, action):
        obs, reward, terminated, truncated, info = super().step(action)

        # Custom reward based on x-position progress
        reward = info['pos'].x_global / 100.0

        # Bonus for collecting cherries
        reward += info['pc'].cherries * 10

        # Bonus for hearts
        reward += info['pc'].hearts * 5

        # Penalty for losing a life
        if info.get('life_lost'):
            reward -= 100

        return obs, reward, terminated, truncated, info


config = InitConfig(level="1-1", character="luigi")
env = CustomSMB2Env(init_config=config, action_type="simple")

Play as Human

The package includes a human-playable interface with multiple initialisation modes:

Character/Level Mode (Default)

smb2-play --level 1-1 --char luigi --scale 3

smb2-play --level 2-3 --char peach

Built-in ROM Variant Mode

# Use specific ROM variant with save state
smb2-play --rom prg0_edited --save-state /path/to/save.sav

Custom ROM Mode

# Use your own ROM file
smb2-play --custom-rom /path/to/smb2.nes

# Use custom ROM with save state
smb2-play --custom-rom /path/to/smb2.nes --custom-state /path/to/save.sav

# Start from beginning without save state
smb2-play --custom-rom /path/to/smb2.nes --no-save-state

Controls

Primary Controls:

  • Arrow Keys: Move
  • Z: A button (Jump)
  • X: B button (Pick up/Throw)
  • Enter: Start
  • Right Shift: Select
  • P: Pause
  • R: Reset
  • ESC: Quit

Save States:

  • F5: Save state
  • F9: Load state

Interface:

  • F1 or H: Toggle the on-screen controls overlay
  • F2: Open the options menu
  • M: Toggle the semantic map panel
  • L: Toggle the legend panel
  • I: Toggle the stats panel
  • Tab / Shift+Tab: Cycle stats tabs (Overview, Player, Character, Enemies)
  • F11: Toggle fullscreen

The window is resizable — the game view, semantic map and stats panels all reflow to fit, and the sidebar is dropped automatically on narrow windows so the game keeps usable space.

Options Menu

Press F2 for settings that can be changed mid-session. Use the arrow keys to select and change a row, click a row directly, or press its letter key.

Option Key Effect
Frame rate - / + 15, 30, 60, 90, 120, 240 FPS or uncapped. Slow it down to study a jump, raise it to cross a level quickly
Integer scaling S Snap the game view to a whole-number scale so every NES pixel is the same size, at the cost of a slightly smaller picture
Collision overlay C Show the player's collision tiles on the semantic map
Map grid G Grid lines between semantic map cells
Log rewards O Print each step's reward to the terminal, useful when shaping a reward function

CLI Options

Character/Level Mode:

  • --level: Level to play (1-1 through 7-2, default: 1-1)
  • --char: Character (mario, luigi, peach, toad, default: luigi)

Built-in ROM Mode:

  • --rom: ROM variant (prg0, prg0_edited)
  • --save-state: Save state filename

Custom ROM Mode:

  • --custom-rom: Path to custom ROM file
  • --custom-state: Path to custom save state (optional)
  • --no-save-state: Start from beginning without loading save state

Display:

  • --scale: Display scale factor (1-4, default: 3)

Development

This project uses uv for dependency management and ruff for linting and formatting.

# Set up the environment (creates .venv and installs everything)
uv sync --extra dev

# Install the git hooks, so lint and formatting run on each commit
uv run pre-commit install

# Run the test suite ('-m "not slow"' skips the long emulator runs)
uv run pytest -m "not slow"

# Lint and format by hand
uv run ruff check --fix .
uv run ruff format .

# Run every hook over the whole repo
uv run pre-commit run --all-files

CI runs the linters, the test suite on Python 3.10-3.13, and a packaging check on every push and pull request.

Disclaimer

This project is for educational and research purposes only. Users must provide their own legally obtained ROM files.

Acknowledgements

This project builds upon invaluable reverse-engineering work from the SMB2 community:

These resources were essential for understanding the game's internals and implementing the state tracking features in this library.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

smb2_gym-0.3.0.tar.gz (973.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

smb2_gym-0.3.0-py3-none-any.whl (953.0 kB view details)

Uploaded Python 3

File details

Details for the file smb2_gym-0.3.0.tar.gz.

File metadata

  • Download URL: smb2_gym-0.3.0.tar.gz
  • Upload date:
  • Size: 973.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.14.6

File hashes

Hashes for smb2_gym-0.3.0.tar.gz
Algorithm Hash digest
SHA256 99a81dc9e7a34b3a887f936be045607ac13f902a7dc6a0de83aa516106cb579b
MD5 f5ce70403b262beaee00929cddf18f76
BLAKE2b-256 2260dc534c96b1bdefd69caed4ae4814a1b46b5cd99550d697c681f2cafcaa83

See more details on using hashes here.

File details

Details for the file smb2_gym-0.3.0-py3-none-any.whl.

File metadata

  • Download URL: smb2_gym-0.3.0-py3-none-any.whl
  • Upload date:
  • Size: 953.0 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? No
  • Uploaded via: twine/6.2.0 CPython/3.14.6

File hashes

Hashes for smb2_gym-0.3.0-py3-none-any.whl
Algorithm Hash digest
SHA256 0bbd5aafec12a8657fe93c5aa5daec04b6ada642764fba0e93bd7fe4ff1a19b1
MD5 1f81336c12c7c685465ee8fb3989e128
BLAKE2b-256 fd274f00b6561a706276ff780487b5aa5315c310988f568c9506d13f5fd1ab2d

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page