Mr. Rescue

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

📄 Docs only

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

Z / Space

Jump

X

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

C

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, disabled = true, reason = "Blocked on engine API drift: Draw2D.new_layer removed." }
  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: move, aim gun, climb ladder
 26- Z: jump
 27- X: spray water
 28- C: 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 simvx.core import (
 44    CanvasLayer,
 45    Input,
 46    InputMap,
 47    Key,
 48    Node,
 49)
 50from simvx.core.world_environment import WorldEnvironment
 51from simvx.graphics import App
 52
 53from nodes import colours as C
 54from nodes.game import GameScene
 55from nodes.hud import HUD
 56from nodes.menu import EndScreen, TitleScreen
 57
 58
 59WINDOW_W = 1024
 60WINDOW_H = 800
 61
 62
 63# ---------------------------------------------------------------------------- root
 64
 65class MrRescueRoot(Node):
 66    """Phase machine: menu → game → end."""
 67
 68    def __init__(self, *, seed: int | None = None, **kwargs):
 69        super().__init__(name="MrRescueRoot", **kwargs)
 70        self.phase = "menu"
 71        self.section = 1
 72        self._seed = seed
 73        self._sub: Node | None = None
 74        self._hud: HUD | None = None
 75        self._hud_layer: CanvasLayer | None = None
 76
 77    def on_ready(self):
 78        # InputMap MUST live in on_ready (web exporter skips main()).
 79        InputMap.add_action("left", [Key.LEFT, Key.A])
 80        InputMap.add_action("right", [Key.RIGHT, Key.D])
 81        InputMap.add_action("up", [Key.UP, Key.W])
 82        InputMap.add_action("down", [Key.DOWN, Key.S])
 83        InputMap.add_action("jump", [Key.Z, Key.SPACE])
 84        InputMap.add_action("shoot", [Key.X])
 85        InputMap.add_action("grab", [Key.C])
 86        InputMap.add_action("start", [Key.ENTER, Key.SPACE])
 87        InputMap.add_action("quit", [Key.ESCAPE])
 88
 89        env = self.add_child(WorldEnvironment())
 90        env.sky_mode = "disabled"
 91        env.tonemap_mode = "aces"
 92        env.tonemap_exposure = 1.05
 93        env.bloom_enabled = True
 94        env.bloom_threshold = 0.85
 95        env.bloom_intensity = 0.40
 96        env.bloom_soft_knee = 0.5
 97
 98        self._enter_menu()
 99
100    # ----------------------------------------------------------- phase swaps
101
102    def _drop_sub(self):
103        if self._sub is not None:
104            self._sub.destroy()
105            self._sub = None
106        # P6 fix landed but be defensive: null out the camera ref so menu
107        # phases render in screen-space without inheriting a dead camera.
108        if self.tree is not None:
109            self.tree._current_camera_2d = None
110        # Drop existing HUD too; we'll re-add LAST so it draws on top.
111        if self._hud_layer is not None:
112            self._hud_layer.destroy()
113            self._hud_layer = None
114            self._hud = None
115
116    def _ensure_hud_on_top(self):
117        """Re-create the HUDLayer as the last child so it draws on top.
118
119        Node._draw_recursive iterates children in declaration order; the last
120        child draws last (on top of everything else). Node2D auto-reorders
121        CanvasLayers but our root is a plain Node, so we manage it manually.
122        """
123        if self._hud_layer is not None:
124            self._hud_layer.destroy()
125        self._hud_layer = CanvasLayer(name="HUDLayer", layer=10)
126        self.add_child(self._hud_layer)
127        self._hud = HUD(viewport_w=WINDOW_W, viewport_h=WINDOW_H)
128        self._hud_layer.add_child(self._hud)
129
130    def _enter_menu(self):
131        self.phase = "menu"
132        self._drop_sub()
133        self.section = 1
134        ts = TitleScreen(viewport_w=WINDOW_W, viewport_h=WINDOW_H)
135        ts.start.connect(self._enter_game)
136        self.add_child(ts)
137        self._sub = ts
138        self._ensure_hud_on_top()
139
140    def _enter_game(self):
141        self.phase = "game"
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        self._ensure_hud_on_top()
155
156    def _on_victory(self):
157        self._enter_end(victory=True, reason="ALL CIVILIANS RESCUED")
158
159    def _on_failure(self, reason: str):
160        msg = {
161            "casualty": "TOO MANY CIVILIANS LOST",
162            "overheat": "YOUR SUIT OVERHEATED",
163        }.get(reason, "")
164        self._enter_end(victory=False, reason=msg)
165
166    def _enter_end(self, *, victory: bool, reason: str):
167        self.phase = "end"
168        # Snapshot stats before we drop the scene.
169        scene = self._sub
170        score = getattr(scene, "score", 0)
171        total = getattr(scene, "civilians_total", 0)
172        rescued = total - getattr(scene, "casualties", 0) - len(getattr(scene, "civilians", []))
173        rescued = max(0, rescued)
174        self._drop_sub()
175        es = EndScreen(
176            viewport_w=WINDOW_W,
177            viewport_h=WINDOW_H,
178            victory=victory,
179            score=score,
180            rescued=rescued,
181            civilians_total=total,
182            reason=reason,
183        )
184        es.restart.connect(self._enter_menu)
185        self.add_child(es)
186        self._sub = es
187        self._ensure_hud_on_top()
188
189    # ----------------------------------------------------------- update
190
191    def on_process(self, dt: float):
192        if Input.is_action_just_pressed("quit"):
193            if self.app is not None:
194                self.app.quit()
195            return
196
197        # Drive HUD with current scene state.
198        if self._hud is None:
199            return
200        self._hud.visible = (self.phase == "game")
201        if self.phase == "game" and isinstance(self._sub, GameScene):
202            scene: GameScene = self._sub
203            p = scene.player
204            self._hud.set_state(
205                water=p.water,
206                water_max=p.water_capacity,
207                overloaded=p.overloaded,
208                has_reserve=p.has_reserve,
209                temperature=p.temperature,
210                max_temperature=p.max_temperature,
211                casualties=scene.casualties,
212                max_casualties=scene.max_casualties,
213                civilians_remaining=len(scene.civilians),
214                civilians_total=scene.civilians_total,
215                fires_remaining=scene.fires.fire_count(),
216                section=scene.section,
217                score=scene.score,
218                is_dying=p.is_dying,
219            )
220
221
222# --------------------------------------------------------------------- entry
223
224def _run_test():
225    """Headless smoke test: captures 8 stage screenshots."""
226    from simvx.core import InputSimulator
227    from simvx.graphics import save_png
228
229    out_dir = _PORT_DIR / "screenshots"
230    out_dir.mkdir(exist_ok=True)
231
232    app = App(title="Mr. Rescue (test)", width=WINDOW_W, height=WINDOW_H,
233              visible=False, bg_colour=C.BG)
234    sim = InputSimulator()
235    root = MrRescueRoot(seed=42)
236
237    # Frame plan:
238    #  0..40   menu (capture frame 20 = title screen)
239    #  40..80  press ENTER → game loaded (capture 60 = building overview)
240    #  80..150 player runs right + sprays (capture 120 = water spray)
241    # 150..230 player attempts to grab / mid-game (capture 200 = mid-game)
242    # 230..330 chaos: fire spread + civilian states (capture 280 = peak fire)
243    # 330..360 end (capture 350 = end screen via dev-trigger)
244    captures = {
245        20: "01_title.png",
246        60: "02_world_overview.png",
247        120: "03_water_spray.png",
248        180: "04_climb_grab.png",
249        260: "05_combat.png",
250        320: "06_late_game.png",
251        450: "07_end_screen.png",
252        500: "08_post_restart.png",
253    }
254    capture_frames = sorted(captures.keys())
255
256    def _drive(idx, _t):
257        # Frame 30: press ENTER to start the game.
258        if idx == 30:
259            sim.press_key(Key.ENTER)
260        if idx == 32:
261            sim.release_key(Key.ENTER)
262        # Walk RIGHT toward fires + civilians (player starts at x=72, civilians
263        # at x=488 on the same floor, ~3.5s of running at max 160 px/s).
264        if idx == 50:
265            sim.press_key(Key.RIGHT)
266        if idx == 100:
267            sim.press_key(Key.X)  # spray water as we run
268        if idx == 200:
269            sim.release_key(Key.X)
270        # Stop and try grabbing civilian.
271        if idx == 280:
272            sim.release_key(Key.RIGHT)
273            sim.press_key(Key.C)
274        if idx == 285:
275            sim.release_key(Key.C)
276        # Spray more fires + walk further (different floor demonstration).
277        if idx == 300:
278            sim.press_key(Key.RIGHT)
279            sim.press_key(Key.X)
280        if idx == 380:
281            sim.release_key(Key.X)
282            sim.release_key(Key.RIGHT)
283        # Try climbing a ladder (the auto-detect uses UP).
284        if idx == 400:
285            sim.press_key(Key.UP)
286        if idx == 410:
287            sim.release_key(Key.UP)
288        # Heat may eventually kill the player. Press ENTER on the end screen.
289        if idx == 470:
290            sim.press_key(Key.ENTER)
291        if idx == 472:
292            sim.release_key(Key.ENTER)
293        return None
294
295    frames_captured = app.run_headless(
296        root,
297        frames=520,
298        on_frame=_drive,
299        capture_frames=capture_frames,
300    )
301    for idx, frame in zip(capture_frames, frames_captured, strict=False):
302        if frame is None:
303            continue
304        # Workaround for known headless alpha-bleed bug.
305        frame[..., 3] = 255
306        path = out_dir / captures[idx]
307        save_png(path, frame)
308        print(f"saved {path}")
309
310
311def main():
312    if "--test" in sys.argv:
313        _run_test()
314        return
315    app = App(title="Mr. Rescue", width=WINDOW_W, height=WINDOW_H,
316              bg_colour=C.BG)
317    app.run(MrRescueRoot())
318
319
320if __name__ == "__main__":
321    main()