A small Super Mario Bros. style platformer written from scratch in C++ with SDL2. The project currently implements a playable World 1-1 style level, Mario movement, enemies, power-ups, map loading, Box2D-backed collision handling, sound, camera movement, and sprite-based rendering.
Level 1 running in the current build, with the camera scrolled, pipes, question blocks, and Goombas:
Install the SDL2 development packages:
sudo apt install libsdl2-dev libsdl2-mixer-dev libsdl2-ttf-dev libsdl2-image-devCollision detection uses Box2D. CMake first tries to find an installed Box2D package and falls back to downloading Box2D v2.4.1 with FetchContent when it is missing.
mkdir -p build
cd build
cmake ..
make
./Mario./Mario starts on level 1. To play another level directly, pass its number:
./Mario --level 2--level, -l, --level=2, and a bare ./Mario 2 all work, for levels 1 to 3. Finishing a level still advances to the next one from wherever you started.
| Key | Action |
|---|---|
O |
Move right |
P |
Move left |
R or Space |
Jump |
Z |
Shoot fireball when Mario has the fire power-up (two at a time) |
D |
Toggle debug visual overlay |
C |
Save a PNG screenshot to screenshots/ |
Q |
Quit |
The game is intentionally implemented without a game engine. The main systems are organized under source/:
| Path | Purpose |
|---|---|
source/core.* |
Main game loop, input handling, frame timing, rendering orchestration, HUD, reset flow, debug overlay, and high-level player actions. |
source/world.* |
Owns the active map, camera, game state, ghosts/transient objects, object updates, and collision dispatch. |
source/map.* |
Loads the text-based level file and converts map symbols into game objects. |
source/player.* |
Mario state machine, movement, jumping, level/power-up state, death, and sprite selection. |
source/camera.* |
Camera and background parallax offsets. |
source/audio.* |
Sound/music update logic based on player state. |
source/rl_environment.* |
Headless, deterministic fixed-step interface for RL observations, actions, rewards, and episode resets. |
rl/ |
Python Gymnasium-compatible wrapper, PyTorch Rainbow-lite DQN and discrete Soft Actor-Critic agents, trainer, evaluator, and learning-curve logger. |
source/physics.* |
Box2D-backed collision fixtures, contact listener, and object notification dispatch. |
source/objects/ |
Base Object class and concrete gameplay objects such as blocks, bricks, pipes, enemies, mushrooms, flowers, coins, and fireballs. |
assets/ |
Sprites, maps, sounds, fonts, and background/cloud assets. Pixel sprites drawn for this project keep an .svg source beside the .png. |
images/ |
README/debug screenshots. |
Core creates the window, world, timers, and audio system. Each frame follows this flow:
- Poll keyboard/window events.
- Update player input state and camera movement.
- Run
World::loop()to update objects, collect spawned ghost objects, and process collisions. - Update audio.
- Draw the background, objects, player, ghosts, HUD, and optional debug overlay.
- Delay as needed to keep the frame timing stable.
Levels are loaded from text files under assets/maps/. Each character in the map grid represents a tile or object. Map converts those symbols into strongly typed objects such as ground blocks, bricks, question blocks, pipes, enemies, flag pieces, and the player spawn.
This approach is simple and easy to edit, but complex objects like pipes are represented as multiple tiles, so they also become multiple collision objects.
Level geometry is bounded by what the engine actually allows, measured by driving the physics directly rather than by eye:
| Limit | Measured |
|---|---|
| Running jump | rises 99 px (4.1 tiles), travels 195 px (8.1 tiles) |
| Widest pit cleared | 6 tiles |
| Tallest step climbed | 4 tiles |
| Question block punchable | 3 to 6 rows above the floor beneath it |
Levels 2 and 3 were rebuilt against those numbers. They previously contained 8 and 10 tile pits that no jump can cross, reward blocks with solid tiles directly underneath, and a flag that was a single tile instead of a pole. tests/level_playthrough_smoke.cpp now plays every level to its flag with a scripted bot, so a map edit cannot quietly make a level uncompletable again.
All gameplay objects inherit from Object. The base class stores shared data such as position, size, image path, object type, dead/ghost flags, and collision notification hooks.
Important object categories:
| Type | Description |
|---|---|
BLOCK, BRICK, GROUND |
Static level collision tiles. |
COIN_CONTAINER, FIRE_CONTAINER, HEALTH_CONTAINER |
Question blocks that spawn rewards. |
PIPE, FLAG |
Multi-tile world structures. |
GOOMBA, KOOPA |
Enemy objects with their own movement and death states. |
PLAYER |
Mario. |
G_COIN, G_TEXT, G_MUSHROOM, G_FLOWER, G_BULLET |
Ghost/transient objects spawned during gameplay. |
Collision handling is dispatched from World::collision(Object *obj) into Physics::collision(). Physics builds a lightweight Box2D world for the current query, creates sensor fixtures for the moving object, static collision tiles, enemies, and ghost objects, then uses a b2ContactListener to convert Box2D contacts back into the existing gameplay notification hooks:
notifyCollisionLeft()notifyCollisionRight()notifyCollisionTop()notifyCollisionBottom()notifyFreeBottom()notifyDistToPlatform()notifyDistToCeil()
The object itself decides how to react. For example, Mario can land on enemies, enemies can turn around when hitting walls, mushrooms can move along platforms, and fireballs can bounce or disappear when they hit something.
Ghost objects are temporary objects that are not part of the static map. They are collected by World::ghostCollector() and stored in the ghosts vector. Examples include coins spawned from blocks, score text, mushrooms, flowers, and fireballs.
This keeps temporary gameplay effects separate from the main map object list while still allowing them to be updated, drawn, and checked for collisions.
Rendering uses SDL2 through the local rsdl wrapper. The game draws:
- Background/sky and clouds.
- Ghost objects.
- Map objects.
- Player sprite.
- HUD text.
Sprites are stored under assets/sprites/. Cloud sprites are stored in assets/sprites/objects/cloud/.
The sky is a parallax layer of clouds in three sizes. A cloud is assembled from a left cap, one to three center pieces, and a right cap, so the number of center pieces is what makes it small, medium, or large. The clouds sit on two parallax planes and each drifts at its own rate, so the sky does not read as one rigid strip sliding past. source/cloud_layer.* owns the layout and is shared by the game window and the RL playback window.
The fireball has its own sprite, assets/sprites/objects/fireball.png, drawn on the same 16x16 grid and palette as the coin and mushroom; fireball.svg is the editable source and rasterizes to exactly that PNG. Mario can keep two fireballs in flight at once, as in the original game.
Press D while the game is running to toggle the debug visual overlay.
The overlay shows:
- FPS, score, timer, object count, and ghost count.
- Player state, direction, speed, world position, and camera position.
- Player collision box and center/collision guide lines.
- Screen/camera viewport boundaries.
- Visible object bounding boxes, object type labels, and dead-state markers.
- Ghost/transient object bounding boxes and labels.
- Physics helper markers such as object centers, top/bottom/left/right collision points, and the player's nearest-platform guide line.
The overlay as it looks today - grouped static collision bodies, per-object labels, the player box with its guide lines, and the live counters:
Press C to write the current frame to screenshots/; the same capture path produces the images in this README.
The mario_rl shared library runs the same map, player, enemy, collision, power-up, and scoring code as the interactive game, but uses a deterministic simulated clock and skips rendering/audio. The Python wrapper follows the Gymnasium reset/step API; Gymnasium itself is optional.
Build the native game and RL environment, then install the small Python dependency set:
cmake -S . -B build -DBUILD_TESTING=ON
cmake --build build -j
python3 -m pip install -r requirements-rl.txtTrain the PyTorch agent on one level and evaluate its best checkpoint:
python3 -m rl.train --level 1 --episodes 1000 --checkpoint checkpoints/level1.pt
python3 -m rl.evaluate checkpoints/level1_best.pt --level 1 --episodes 10Watch the greedy policy from the best checkpoint play in the native SDL window:
python3 -m rl.play checkpoints/level1_best.pt --level 1The playback HUD shows the selected RL action, score, and environment step. Press Q or Esc, or close the window, to stop. --episodes, --fps, --frame-skip, --max-steps, and --device control playback; keep --frame-skip equal to the value used for training (the default is 4).
The default agent combines dueling Double DQN, prioritized replay, three-step returns, a softly updated target network, Huber loss, and gradient clipping. The tile portion of each observation is processed by a convolutional encoder instead of being treated as an unstructured vector. These changes reduce Q-value overestimation and the late-training instability of the earlier hand-written NumPy optimizer.
Every run creates a directory such as runs/mario_level1_20260723-120000 containing:
metrics.csvwith episode reward, loss, score, progress, win rate, Q/target values, TD error, gradient norm, epsilon, and replay statistics.evaluations.csvwith periodic greedy-policy results, which separate real policy quality from epsilon-greedy training noise.learning_curves.png, refreshed every ten episodes.tensorboard/, viewable withtensorboard --logdir runs.config.json, containing the exact run arguments.
The best checkpoint is selected using periodic greedy evaluation rather than a noisy exploration episode. Training can be resumed, including optimizer and target-network state, with --resume checkpoints/level1.pt. Old .npz checkpoints use a different network and cannot be resumed. Use --device, --max-steps, --frame-skip, --batch-size, --learning-rate, and --log-dir to tune or organize a run; python3 -m rl.train --help lists all algorithm controls.
The discrete action space contains idle, left, right, jump, left+jump, right+jump, shoot, left+shoot, and right+shoot. Each observation combines normalized player state with a local four-channel tile grid for terrain, reward blocks, enemies, and power-ups.
The grid is centred on Mario and reaches six tiles up but only two tiles down, so right after a spawn - while he is still falling towards the floor - the channels are briefly empty. Rewards are based on newly reached horizontal progress and actual game-score increases, with completion bonuses and death/timeout penalties; this teaches the policy to finish while still preferring coins, enemies, blocks, and power-ups that increase score. Picking up a power-up adds an explicit bonus, and losing a power level to a hit costs the same amount, so growing big is worth pursuing rather than avoiding.
Training prints a live tqdm progress bar with rolling reward, score, progress, win rate, epsilon, and loss. Use --progress plain for one log line per episode (better when redirecting to a file) or --progress none to stay quiet. Before the first episode the trainer reports the PyTorch build, the visible CUDA devices, the selected GPU, and where a probe forward pass actually ran, so a silent fallback to CPU is visible immediately; add --require-cuda to abort instead of training slowly on the CPU.
The same environment can hand the agent images instead of engineered features:
python3 -m rl.train --observation pixels --level 1 --episodes 2000 --checkpoint checkpoints/level1_cnn.ptEach observation is then a (frame_stack, 84, 84) uint8 stack of grayscale frames of the visible 640x480 view, rasterized natively in C++ with one gray level per object class (terrain, bricks, question blocks, coins, fireballs, enemies, power-ups, and Mario as the brightest). This needs no display or SDL surface, stays deterministic, and costs a fraction of a real screen grab. --frame-stack sets the stack depth (four by default) so the network can infer velocity.
--observation pixels selects PixelDQNAgent, which keeps the whole Rainbow-lite recipe and swaps the tile-grid encoder for a Nature-DQN convolutional trunk with dueling heads. Frames are kept as bytes in the replay buffer and rescaled on the GPU, so the default replay drops to 20,000 transitions (about 1.1 GiB); pass --replay-capacity to change it. Pixel checkpoints record their frame shape, so rl.evaluate and rl.play detect them and switch the environment to pixel mode automatically. Expect pixel training to need considerably more episodes than the feature-vector agent.
--algorithm sac trains a discrete Soft Actor-Critic agent instead of DQN, on either observation:
python3 -m rl.train --algorithm sac --level 1 --episodes 1000 --checkpoint checkpoints/level1_sac.pt
python3 -m rl.train --algorithm sac --observation pixels --level 1 --checkpoint checkpoints/level1_sac_cnn.ptMario's nine actions are discrete, so this is the categorical formulation of SAC rather than the Gaussian-policy version: an actor emits action probabilities, twin critics score every action at once, and both the soft target and the actor loss take exact expectations over the policy instead of sampling it. Taking the minimum of the two critics counters the value overestimation that a single critic drifts into.
Exploration is not epsilon-greedy. The policy samples its own actions and is paid an entropy bonus for staying undecided, with the temperature alpha tuned automatically to hold the policy near a target entropy of --target-entropy-ratio times log(9) (0.6 by default). Raise the ratio when the agent commits too early and stops exploring; lower it when it never sharpens into a plan. --initial-alpha, --alpha-learning-rate, and --fixed-alpha control the temperature directly. Greedy evaluation and rl.play take the most likely action rather than sampling, so playback is deterministic.
SAC reuses the prioritized replay, three-step returns, soft target updates, and both encoders, so the only real differences are the losses and how exploration happens. Its runs log three extra columns - actor_loss, alpha, and policy_entropy - and the learning-curve image plots temperature and entropy where a DQN run plots epsilon. Checkpoints record the algorithm, so rl.evaluate and rl.play rebuild the right agent with no extra flag.
Run both native and Python checks with:
ctest --test-dir build --output-on-failure
python3 -m unittest discover -s tests -p 'test_*.py'This project was originally built as an Advanced Programming course project at the University of Tehran. The codebase favors simple custom systems over engine abstractions, which makes it useful for learning game loops, collision callbacks, SDL2 rendering, and tile-map driven gameplay.





