Mr. Rescue

Arcade firefighting in a procedurally generated burning building.

▶ Run in browser

Upstream: https://github.com/SimonLarsen/mrrescue

Licence: this port's own code is offered under MIT, not the SimVX Examples Licence the rest of the gallery carries. See ATTRIBUTION.md for the upstream it re-implements, the terms of anything it bundles, and the credit each one requires.

Ports live in the repository only, not in the simvx-examples distribution, because each is a derivative work licensed individually against the game it re-implements. Read it with git clone https://git.simvx.com/simvx/simvx.

Tags: port tier-2

Mr. Rescue: SimVX port

A SimVX port of Mr. Rescue by Simon Larsen: an arcade firefighter platformer where you spray water at spreading fires and carry civilians out of a procedurally-stitched 3-storey building before your suit overheats.

Run

# from the repo root
uv run python examples/ports/mr_rescue/main.py            # interactive
uv run python examples/ports/mr_rescue/main.py --test     # headless capture (8 stages)
uv run simvx export web examples/ports/mr_rescue/main.py \
    -o /tmp/mr_rescue.html

Controls

Key

Action

Arrow keys / WASD

Move horizontally, climb ladders, aim gun up/down

Space

Jump

Shift

Spray water (drains the tank; it overloads when empty)

E

Grab a civilian / throw the carried one

Enter, Space or click

Confirm in menus

Esc

Quit

What it shows

  • Procedural everything. Tiles, characters, the night skyline and the building layout are generated at runtime as NumPy uint8 RGBA; the SFX and the ambient music bed are synthesized with AudioSynth. No asset files ship with the port.

  • Game feel. Coyote time and jump buffering on the tile-grid platforming, Camera2D follow with clamping and screenshake, and hitstop that freezes the gameplay subtree (via UpdateMode.DISABLED) on rescues and kills.

  • Scene structure. A root phase machine swaps title / game / end scenes and listens to Signals for victory and failure; the gameplay scene listens to the player’s, civilians’ and enemies’ own signals for score, audio and particles.

  • Screen-space UI. The HUD lives on the gameplay scene’s own CanvasLayer and lays out against the live framebuffer size, so it tracks a resized desktop window or a responsive web canvas.

Notes

The port keeps one enemy type (the fire bug) rather than upstream’s six, and leaves out the bosses, the lightmap, the on-screen joystick UI and the licensed music tracks. Fire spread, the heat/water economy, ladders and the carry-and- throw rescue loop all follow upstream’s design.

License: port code under MIT (see LICENSE). It mirrors upstream’s zlib gameplay design, with CC-BY-SA 3.0 attribution to Simon Larsen for the original concept and visual reference; no upstream art is reused, as every texture is procedural. See ATTRIBUTION.md.

Source files

File

Summary

Lines

main.py

Mr. Rescue: Arcade firefighting in a procedurally generated burning building.

213

harness.py

Scripted headless playthrough: the implementation behind main.py --test.

233

nodes/__init__.py

0

nodes/audio.py

Procedural audio for Mr. Rescue: synthesized SFX + a looping ambient bed.

213

nodes/building.py

Procedural multi-floor building generator.

328

nodes/civilian.py

Civilian: IDLE/WALK/PANIC/BURN/CARRIED/FLY state machine.

204

nodes/colours.py

Mr. Rescue palette: warm-on-charcoal arcade pixels.

94

nodes/enemy.py

FireBug: single enemy type (port collapses upstream’s 6 to one).

149

nodes/fire.py

Fire grid: sparse cell map of flames with health and spread timers.

221

nodes/game.py

Active gameplay scene: wires together grid, fires, player, civilians, enemies.

467

nodes/hud.py

HUD: top status strip + bottom water/heat/casualty bar.

198

nodes/menu.py

Title and end screens.

233

nodes/particles.py

CPU particle pool: water mist, smoke, sparkles, ash.

141

nodes/player.py

Firefighter player: state machine + water gun raycast.

693

nodes/textures.py

Procedural NumPy textures.

593

nodes/tile_grid.py

Tile grid + collision for the building.

188

Source

  1#!/usr/bin/env python3
  2"""Mr. Rescue: Arcade firefighting in a procedurally generated burning building.
  3
  4A port of Simon Larsen's "Mr. Rescue": spray water at flames that spread cell to
  5cell, carry civilians out through the roof, and get clear before your suit
  6overheats. Everything is generated at runtime: NumPy pixel-art textures,
  7synthesized SFX and music, and a randomised three-storey building. Shows
  8tile-grid platforming with coyote time and jump buffering, Camera2D follow with
  9screenshake and hitstop, a CanvasLayer HUD, signal-driven scene flow
 10(menu, game, end), and bloom via WorldEnvironment.
 11
 12# /// simvx
 13# tags = ["port", "tier-2"]
 14# upstream = "https://github.com/SimonLarsen/mrrescue"
 15# web = { width = 1024, height = 800, responsive = true }
 16# ///
 17
 18Run interactively::
 19
 20    uv run python examples/ports/mr_rescue/main.py
 21
 22Headless smoke test (captures stage screenshots into ``screenshots/``)::
 23
 24    uv run python examples/ports/mr_rescue/main.py --test
 25
 26Web export::
 27
 28    uv run simvx export web examples/ports/mr_rescue/main.py -o /tmp/mr_rescue.html
 29
 30Controls
 31--------
 32- Arrow keys / WASD: move, aim the gun, climb ladders
 33- Space: jump
 34- Shift: spray water
 35- E: grab / throw the carried civilian
 36- Enter, Space or click / tap: confirm in menus
 37- Esc: quit
 38"""
 39
 40from __future__ import annotations
 41
 42import sys
 43from pathlib import Path
 44
 45# Make the port folder importable in --test and direct runs alike.
 46_PORT_DIR = Path(__file__).resolve().parent
 47if str(_PORT_DIR) not in sys.path:
 48    sys.path.insert(0, str(_PORT_DIR))
 49
 50from nodes import colours as C
 51from nodes.audio import get_sfx
 52from nodes.game import GameScene
 53from nodes.menu import EndScreen, TitleScreen
 54
 55from simvx.core import (
 56    AudioPlayer,
 57    Input,
 58    InputMap,
 59    Key,
 60    MouseButton,
 61    Node,
 62)
 63from simvx.core.world_environment import WorldEnvironment
 64from simvx.graphics import App
 65
 66WINDOW_W = 1024
 67WINDOW_H = 800
 68
 69
 70# ---------------------------------------------------------------------------- root
 71
 72
 73class MrRescueRoot(Node):
 74    """Phase machine: menu → game → end."""
 75
 76    def __init__(self, *, seed: int | None = None, **kwargs):
 77        super().__init__(**kwargs)
 78        self.phase = "menu"
 79        self.section = 1
 80        self._seed = seed
 81        self.scene: Node | None = None
 82        self._menu_sfx = None
 83
 84    def on_ready(self):
 85        # InputMap MUST live in on_ready (web exporter skips main()).
 86        InputMap.add_action("left", [Key.LEFT, Key.A])
 87        InputMap.add_action("right", [Key.RIGHT, Key.D])
 88        InputMap.add_action("up", [Key.UP, Key.W])
 89        InputMap.add_action("down", [Key.DOWN, Key.S])
 90        InputMap.add_action("jump", [Key.SPACE])
 91        InputMap.add_action("shoot", [Key.LEFT_SHIFT, Key.RIGHT_SHIFT])
 92        InputMap.add_action("grab", [Key.E])
 93        # Menus confirm on a click/tap too (touch arrives as MouseButton.LEFT on
 94        # web), so the responsive export is reachable without a keyboard.
 95        InputMap.add_action("start", [Key.ENTER, Key.SPACE, MouseButton.LEFT])
 96        InputMap.add_action("quit", [Key.ESCAPE])
 97
 98        env = self.add_child(WorldEnvironment())
 99        env.sky_mode = "disabled"
100        env.tonemap_mode = "aces"
101        env.tonemap_exposure = 1.05
102        env.bloom_enabled = True
103        env.bloom_threshold = 0.78
104        env.bloom_intensity = 0.52
105        env.bloom_soft_knee = 0.5
106
107        # One-shot menu confirm cue (gameplay SFX live on the GameScene's bank).
108        self._menu_sfx = self.add_child(AudioPlayer(stream=get_sfx("menu_confirm"), bus="SFX"))
109
110        # The HUD is owned by the GameScene (in its own CanvasLayer) so it is
111        # created and destroyed with the game phase: clean lifecycle, with no HUD
112        # state to reset between phases.
113        self._enter_menu()
114
115    # ----------------------------------------------------------- phase swaps
116
117    def _drop_sub(self):
118        # Destroying the sub-scene takes its Camera2D with it, and Camera2D's
119        # teardown releases the tree's active-camera slot, so the menu phases
120        # come back up in screen space with no extra bookkeeping here.
121        if self.scene is not None:
122            self.scene.destroy()
123            self.scene = None
124
125    def _enter_menu(self):
126        self.phase = "menu"
127        self._drop_sub()
128        self.section = 1
129        ts = TitleScreen()
130        ts.start.connect(self._enter_game)
131        self.add_child(ts)
132        self.scene = ts
133
134    def _play_menu_sfx(self):
135        if self._menu_sfx is not None:
136            self._menu_sfx.play()
137
138    def _restart(self):
139        self._play_menu_sfx()
140        self._enter_menu()
141
142    def _enter_game(self):
143        self.phase = "game"
144        self._play_menu_sfx()
145        self._drop_sub()
146        game = GameScene(
147            viewport_w=WINDOW_W,
148            viewport_h=WINDOW_H,
149            section=self.section,
150            level=1,
151            seed=self._seed,
152        )
153        game.victory.connect(self._on_victory)
154        game.failure.connect(self._on_failure)
155        self.add_child(game)
156        self.scene = game
157
158    def _on_victory(self):
159        self._enter_end(victory=True, reason="ALL CIVILIANS RESCUED")
160
161    def _on_failure(self, reason: str):
162        msg = {
163            "casualty": "TOO MANY CIVILIANS LOST",
164            "overheat": "YOUR SUIT OVERHEATED",
165        }.get(reason, "")
166        self._enter_end(victory=False, reason=msg)
167
168    def _enter_end(self, *, victory: bool, reason: str):
169        self.phase = "end"
170        # Snapshot stats before we drop the game scene (only it ends the run).
171        game = self.scene
172        rescued = max(0, game.civilians_total - game.casualties - len(game.civilians))
173        es = EndScreen(
174            victory=victory,
175            score=game.score,
176            rescued=rescued,
177            civilians_total=game.civilians_total,
178            casualties=game.casualties,
179            reason=reason,
180        )
181        self._drop_sub()
182        es.restart.connect(self._restart)
183        self.add_child(es)
184        self.scene = es
185
186    # ----------------------------------------------------------- update
187
188    def on_update(self, dt: float):
189        if Input.is_action_just_pressed("quit"):
190            if self.app is not None:
191                self.app.quit()
192
193
194# --------------------------------------------------------------------- entry
195
196
197def main():
198    if "--test" in sys.argv:
199        from harness import capture_playthrough
200
201        capture_playthrough(
202            MrRescueRoot,
203            window_size=(WINDOW_W, WINDOW_H),
204            bg_colour=C.BG,
205            out_dir=_PORT_DIR / "screenshots",
206        )
207        return
208    app = App(title="Mr. Rescue", width=WINDOW_W, height=WINDOW_H, bg_colour=C.BG)
209    app.run(MrRescueRoot())
210
211
212if __name__ == "__main__":
213    main()