Mr. Rescue

Arcade firefighter, fire spread, rescue civilians, procedural building.

▶ Run in browser

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

Tags: port tier-2

Mr. Rescue: SimVX port

Tier 2 port (#21) of Mr. Rescue by Simon Larsen, an arcade firefighter platformer where you spray water at spreading fires and rescue civilians from a procedurally-stitched 3-storey building.

Run

# Interactive
uv run python ported_games/mr_rescue/simvx_port/main.py

# Headless (captures 8 screenshots into screenshots/)
uv run python ported_games/mr_rescue/simvx_port/main.py --test

# Web export
uv run simvx export web ported_games/mr_rescue/simvx_port/main.py \
    -o ported_games/mr_rescue/simvx_port/web/index.html

Controls

Key

Action

Arrow keys / WASD

Move horizontally, climb ladders, aim gun up/down

Space

Jump

Shift

Spray water (drains tank, must cool down if overloaded)

E

Grab civilian / Throw carried civilian

Enter

Confirm in menus

Esc

Quit

Notes

Procedural NumPy textures throughout; no upstream PNG assets are bundled. Audio is procedural beeps (no upstream WAV/OGG reuse). See ../NOTES.md for deliberate cuts (bosses, lightmap, joystick UI, music tracks, multiple enemy types).

License: Port code under the engine’s MIT. Mirrors upstream’s zlib gameplay design; CC-BY-SA 3.0 attribution to Simon Larsen for the original concept and visual reference (no upstream art reused; all textures are procedural).

Source

  1#!/usr/bin/env python3
  2"""Mr. Rescue: Arcade firefighter, fire spread, rescue civilians, procedural building.
  3
  4# /// simvx
  5# tags = ["port", "tier-2"]
  6# upstream = "https://github.com/SimonLarsen/mrrescue"
  7# web = { width = 1024, height = 800, responsive = true }
  8# ///
  9
 10Run interactively::
 11
 12    uv run python ported_games/mr_rescue/simvx_port/main.py
 13
 14Headless smoke test (captures 8 stage screenshots)::
 15
 16    uv run python ported_games/mr_rescue/simvx_port/main.py --test
 17
 18Web export::
 19
 20    uv run simvx export web ported_games/mr_rescue/simvx_port/main.py \
 21        -o ported_games/mr_rescue/simvx_port/web/index.html
 22
 23Controls
 24--------
 25- Arrow keys / WASD: move, aim gun, climb ladder
 26- Space: jump
 27- Shift: spray water
 28- E: grab / throw civilian
 29- Enter / Space: confirm in menus
 30- Esc: quit
 31"""
 32
 33from __future__ import annotations
 34
 35import sys
 36from pathlib import Path
 37
 38# Make the port folder importable in --test and direct runs alike.
 39_PORT_DIR = Path(__file__).resolve().parent
 40if str(_PORT_DIR) not in sys.path:
 41    sys.path.insert(0, str(_PORT_DIR))
 42
 43from nodes import colours as C
 44from nodes.game import GameScene
 45from nodes.menu import EndScreen, TitleScreen
 46
 47from simvx.core import (
 48    Input,
 49    InputMap,
 50    Key,
 51    Node,
 52)
 53from simvx.core.world_environment import WorldEnvironment
 54from simvx.graphics import App
 55
 56WINDOW_W = 1024
 57WINDOW_H = 800
 58
 59
 60# ---------------------------------------------------------------------------- root
 61
 62class MrRescueRoot(Node):
 63    """Phase machine: menu → game → end."""
 64
 65    def __init__(self, *, seed: int | None = None, **kwargs):
 66        super().__init__(name="MrRescueRoot", **kwargs)
 67        self.phase = "menu"
 68        self.section = 1
 69        self._seed = seed
 70        self._sub: Node | None = None
 71        self._menu_sfx = None
 72
 73    def on_ready(self):
 74        # InputMap MUST live in on_ready (web exporter skips main()).
 75        InputMap.add_action("left", [Key.LEFT, Key.A])
 76        InputMap.add_action("right", [Key.RIGHT, Key.D])
 77        InputMap.add_action("up", [Key.UP, Key.W])
 78        InputMap.add_action("down", [Key.DOWN, Key.S])
 79        InputMap.add_action("jump", [Key.SPACE])
 80        InputMap.add_action("shoot", [Key.LEFT_SHIFT, Key.RIGHT_SHIFT])
 81        InputMap.add_action("grab", [Key.E])
 82        InputMap.add_action("start", [Key.ENTER, Key.SPACE])
 83        InputMap.add_action("quit", [Key.ESCAPE])
 84
 85        env = self.add_child(WorldEnvironment())
 86        env.sky_mode = "disabled"
 87        env.tonemap_mode = "aces"
 88        env.tonemap_exposure = 1.05
 89        env.bloom_enabled = True
 90        env.bloom_threshold = 0.78
 91        env.bloom_intensity = 0.52
 92        env.bloom_soft_knee = 0.5
 93
 94        # One-shot menu confirm cue (gameplay SFX live on the GameScene's bank).
 95        try:
 96            from nodes.audio import get_sfx
 97
 98            from simvx.core import AudioPlayer
 99            self._menu_sfx = self.add_child(AudioPlayer(stream=get_sfx("menu_confirm"), bus="SFX"))
100        except Exception:  # noqa: BLE001 -- audio is non-essential
101            self._menu_sfx = None
102
103        # The HUD is owned by the GameScene (in its own CanvasLayer) so it is
104        # created and destroyed with the game phase -- clean lifecycle with no HUD
105        # state to reset between phases.
106        self._enter_menu()
107
108    # ----------------------------------------------------------- phase swaps
109
110    def _drop_sub(self):
111        if self._sub is not None:
112            self._sub.destroy()
113            self._sub = None
114        # P6 fix landed but be defensive: null out the camera ref so menu
115        # phases render in screen-space without inheriting a dead camera.
116        if self.tree is not None:
117            self.tree._current_camera_2d = None
118
119    def _enter_menu(self):
120        self.phase = "menu"
121        self._drop_sub()
122        self.section = 1
123        ts = TitleScreen()
124        ts.start.connect(self._enter_game)
125        self.add_child(ts)
126        self._sub = ts
127
128    def _play_menu_sfx(self):
129        if self._menu_sfx is not None:
130            try:
131                self._menu_sfx.play()
132            except Exception:  # noqa: BLE001
133                pass
134
135    def _restart(self):
136        self._play_menu_sfx()
137        self._enter_menu()
138
139    def _enter_game(self):
140        self.phase = "game"
141        self._play_menu_sfx()
142        self._drop_sub()
143        scene = GameScene(
144            viewport_w=WINDOW_W,
145            viewport_h=WINDOW_H,
146            section=self.section,
147            level=1,
148            seed=self._seed,
149        )
150        scene.victory.connect(self._on_victory)
151        scene.failure.connect(self._on_failure)
152        self.add_child(scene)
153        self._sub = scene
154
155    def _on_victory(self):
156        self._enter_end(victory=True, reason="ALL CIVILIANS RESCUED")
157
158    def _on_failure(self, reason: str):
159        msg = {
160            "casualty": "TOO MANY CIVILIANS LOST",
161            "overheat": "YOUR SUIT OVERHEATED",
162        }.get(reason, "")
163        self._enter_end(victory=False, reason=msg)
164
165    def _enter_end(self, *, victory: bool, reason: str):
166        self.phase = "end"
167        # Snapshot stats before we drop the scene.
168        scene = self._sub
169        score = getattr(scene, "score", 0)
170        total = getattr(scene, "civilians_total", 0)
171        casualties = getattr(scene, "casualties", 0)
172        rescued = max(0, total - casualties - len(getattr(scene, "civilians", [])))
173        self._drop_sub()
174        es = EndScreen(
175            victory=victory,
176            score=score,
177            rescued=rescued,
178            civilians_total=total,
179            casualties=casualties,
180            reason=reason,
181        )
182        es.restart.connect(self._restart)
183        self.add_child(es)
184        self._sub = 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            return
193        # The GameScene now drives its own HUD (it owns it), so the root only
194        # handles the global quit + phase machine.
195
196
197# --------------------------------------------------------------------- entry
198
199def _run_test():
200    """Headless smoke test: captures 8 stage screenshots."""
201    from simvx.core import InputSimulator
202    from simvx.graphics import save_png
203
204    out_dir = _PORT_DIR / "screenshots"
205    out_dir.mkdir(exist_ok=True)
206
207    app = App(title="Mr. Rescue (test)", width=WINDOW_W, height=WINDOW_H,
208              visible=False, bg_colour=C.BG)
209    sim = InputSimulator()
210    root = MrRescueRoot(seed=42)
211
212    # Frame plan:
213    #  0..40   menu (capture frame 20 = title screen)
214    #  40..80  press ENTER → game loaded (capture 60 = building overview)
215    #  80..150 player runs right + sprays (capture 120 = water spray)
216    # 150..230 player attempts to grab / mid-game (capture 200 = mid-game)
217    # 230..330 chaos: fire spread + civilian states (capture 280 = peak fire)
218    # 330..360 end (capture 350 = end screen via dev-trigger)
219    captures = {
220        20: "01_title.png",
221        60: "02_world_overview.png",
222        120: "03_water_spray.png",
223        180: "04_climb_grab.png",
224        260: "05_combat.png",
225        320: "06_late_game.png",
226        450: "07_end_screen.png",
227        500: "08_post_restart.png",
228    }
229    capture_frames = sorted(captures.keys())
230
231    def _drive(idx, _t):
232        # Frame 30: press ENTER to start the game.
233        if idx == 30:
234            sim.press_key(Key.ENTER)
235        if idx == 32:
236            sim.release_key(Key.ENTER)
237        # Walk RIGHT toward fires + civilians (player starts at x=72, civilians
238        # at x=488 on the same floor, ~3.5s of running at max 160 px/s).
239        if idx == 50:
240            sim.press_key(Key.RIGHT)
241        if idx == 100:
242            sim.press_key(Key.LEFT_SHIFT)  # spray water as we run
243        if idx == 200:
244            sim.release_key(Key.LEFT_SHIFT)
245        # Stop and try grabbing civilian.
246        if idx == 280:
247            sim.release_key(Key.RIGHT)
248            sim.press_key(Key.E)
249        if idx == 285:
250            sim.release_key(Key.E)
251        # Spray more fires + walk further (different floor demonstration).
252        if idx == 300:
253            sim.press_key(Key.RIGHT)
254            sim.press_key(Key.LEFT_SHIFT)
255        if idx == 380:
256            sim.release_key(Key.LEFT_SHIFT)
257            sim.release_key(Key.RIGHT)
258        # Try climbing a ladder (the auto-detect uses UP).
259        if idx == 400:
260            sim.press_key(Key.UP)
261        if idx == 410:
262            sim.release_key(Key.UP)
263        # Force the overheat end-state so the end screen is captured (test-only
264        # state poke; real play reaches this through fire heat).
265        if idx == 430 and hasattr(root._sub, "player"):
266            p = root._sub.player
267            p.temperature = p.max_temperature + 1.0
268        # Press ENTER on the end screen to return to the title.
269        if idx == 470:
270            sim.press_key(Key.ENTER)
271        if idx == 472:
272            sim.release_key(Key.ENTER)
273        return None
274
275    frames_captured = app.run_headless(
276        root,
277        frames=520,
278        on_frame=_drive,
279        capture_frames=capture_frames,
280    )
281    for idx, frame in zip(capture_frames, frames_captured, strict=False):
282        if frame is None:
283            continue
284        # Workaround for known headless alpha-bleed bug.
285        frame[..., 3] = 255
286        path = out_dir / captures[idx]
287        save_png(path, frame)
288        print(f"saved {path}")
289
290
291def main():
292    if "--test" in sys.argv:
293        _run_test()
294        return
295    app = App(title="Mr. Rescue", width=WINDOW_W, height=WINDOW_H,
296              bg_colour=C.BG)
297    app.run(MrRescueRoot())
298
299
300if __name__ == "__main__":
301    main()