Dodge the Creeps

Godot’s first 2D tutorial, wandering mobs, top-down dodge.

▶ Run in browser

Upstream: https://github.com/godotengine/godot-demo-projects/tree/master/2d/dodge_the_creeps

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-0

A from-scratch re-implementation of the Godot “Your first 2D game” demo on SimVX. Survive as long as you can: creeps spawn at the screen edges twice a second and drift across the play field, and your score ticks up once per second until one of them touches you.

Controls: WASD or the arrow keys to move; Space, Enter, or a click to start and restart; Escape to quit. On touch screens, press and hold to steer towards your finger.

What it demonstrates:

  • Node hierarchy with signals (Player.hit, HUD.start_game) and groups

  • AnimatedSprite2D flipbook animation stitched from individual PNG frames

  • Timer nodes plus a coroutine (wait, wait_signal) driving game over

  • Anchored Control / Label UI that survives window resizing

  • Action-based input (InputMap, Input.get_vector) shared by desktop, web and touch

  • A game-over cue synthesised at load time with AudioSynth, so no third-party sound file is bundled

Run: uv run python examples/ports/dodge_the_creeps/main.py uv run python examples/ports/dodge_the_creeps/main.py –test # headless smoke run

Art by Kenney (CC0); music “House In a Forest Loop” by HorrorPen (CC-BY 3.0). See ATTRIBUTION.md for the full upstream credits and licences.

Source files

File

Summary

Lines

main.py

Dodge the Creeps: Godot’s first 2D tutorial, wandering mobs, top-down dodge.

367

capture.py

Headless screenshot capture for Dodge the Creeps.

31

nodes/__init__.py

Node modules for the Dodge the Creeps SimVX port.

1

nodes/audio.py

Procedural audio: built on the engine’s AudioSynth API.

67

nodes/hud.py

HUD: anchored Label widgets for score / message / start prompt.

88

nodes/mob.py

Mob: fly/walk/swim creep that drifts across the screen at a fixed velocity.

74

nodes/player.py

Player: moves with WASD/arrows, plays walk/up animations, emits hit.

142

nodes/sprite_sheets.py

Build horizontal sprite-sheet ndarrays from individual PNG files.

64

Source

  1#!/usr/bin/env python3
  2"""Dodge the Creeps: Godot's first 2D tutorial, wandering mobs, top-down dodge.
  3
  4# /// simvx
  5# tags = ["port", "tier-0"]
  6# upstream = "https://github.com/godotengine/godot-demo-projects/tree/master/2d/dodge_the_creeps"
  7# web = { width = 480, height = 720, responsive = true }
  8# ///
  9
 10A from-scratch re-implementation of the Godot "Your first 2D game" demo on SimVX.
 11Survive as long as you can: creeps spawn at the screen edges twice a second and
 12drift across the play field, and your score ticks up once per second until one of
 13them touches you.
 14
 15Controls: WASD or the arrow keys to move; Space, Enter, or a click to start and
 16restart; Escape to quit. On touch screens, press and hold to steer towards your
 17finger.
 18
 19What it demonstrates:
 20
 21- Node hierarchy with signals (``Player.hit``, ``HUD.start_game``) and groups
 22- ``AnimatedSprite2D`` flipbook animation stitched from individual PNG frames
 23- ``Timer`` nodes plus a coroutine (``wait``, ``wait_signal``) driving game over
 24- Anchored ``Control`` / ``Label`` UI that survives window resizing
 25- Action-based input (``InputMap``, ``Input.get_vector``) shared by desktop, web
 26  and touch
 27- A game-over cue synthesised at load time with ``AudioSynth``, so no
 28  third-party sound file is bundled
 29
 30Run:
 31    uv run python examples/ports/dodge_the_creeps/main.py
 32    uv run python examples/ports/dodge_the_creeps/main.py --test   # headless smoke run
 33
 34Art by Kenney (CC0); music "House In a Forest Loop" by HorrorPen (CC-BY 3.0).
 35See ATTRIBUTION.md for the full upstream credits and licences.
 36"""
 37
 38# /// script
 39# requires-python = ">=3.14"
 40# dependencies = [
 41#     "simvx-core",
 42#     "simvx-graphics",
 43#     "numpy",
 44#     "pillow",
 45# ]
 46# ///
 47
 48from __future__ import annotations
 49
 50import math
 51import random
 52import sys
 53from pathlib import Path
 54
 55# Allow running this file directly (without an installed package) by exposing
 56# the `nodes` subpackage as a top-level module group.
 57sys.path.insert(0, str(Path(__file__).resolve().parent))
 58
 59from nodes.audio import make_gameover  # noqa: E402
 60from nodes.hud import HUD  # noqa: E402
 61from nodes.mob import Mob  # noqa: E402
 62from nodes.player import Player  # noqa: E402
 63
 64from simvx.core import (  # noqa: E402
 65    AudioClip,
 66    AudioPlayer,
 67    Camera2D,
 68    Input,
 69    InputMap,
 70    Key,
 71    MouseButton,
 72    Node,
 73    Timer,
 74    Vec2,
 75)
 76from simvx.core.ui import AnchorPreset, Panel  # noqa: E402
 77from simvx.graphics import App  # noqa: E402
 78
 79WIDTH, HEIGHT = 480, 720
 80ASSETS = Path(__file__).resolve().parent / "assets"
 81
 82
 83# ---------------------------------------------------------------------------
 84# Background: a solid colour rectangle behind everything else, anchored to
 85# the full viewport so it scales with the window. Mirrors the Godot demo's
 86# ColorRect at (0.219608, 0.372549, 0.380392).
 87# ---------------------------------------------------------------------------
 88
 89
 90class Background(Panel):
 91    BG = (0.219608, 0.372549, 0.380392, 1.0)
 92
 93    def __init__(self, **kwargs):
 94        super().__init__(name="Background", **kwargs)
 95        self.set_anchor_preset(AnchorPreset.FULL_RECT)
 96        self.bg_colour = self.BG
 97
 98
 99# ---------------------------------------------------------------------------
100# Main scene
101# ---------------------------------------------------------------------------
102
103
104class Main(Node):
105    """Root scene. Owns the player, mob spawner, score timer, and HUD."""
106
107    SPAWN_INTERVAL = 0.5  # seconds; Godot MobTimer.wait_time
108    SCORE_INTERVAL = 1.0  # seconds; Godot ScoreTimer default
109    START_DELAY = 2.0  # seconds; Godot StartTimer.wait_time
110    MOB_SPEED_RANGE = (150.0, 250.0)
111    START_POSITION = Vec2(WIDTH / 2, HEIGHT - 270)  # ~(240, 450)
112
113    def __init__(self, **kwargs):
114        super().__init__(name="Main", **kwargs)
115        self.score = 0
116        self._game_active = False
117
118    def on_ready(self):
119        # Input map: must live in on_ready so the web exporter picks it up.
120        InputMap.add_action("move_left", [Key.A, Key.LEFT])
121        InputMap.add_action("move_right", [Key.D, Key.RIGHT])
122        InputMap.add_action("move_up", [Key.W, Key.UP])
123        InputMap.add_action("move_down", [Key.S, Key.DOWN])
124        InputMap.add_action("start_game", [Key.SPACE, Key.ENTER])
125        InputMap.add_action("restart_click", [MouseButton.LEFT])
126        # Mobile / touch: left-click-and-hold steers the player toward the
127        # cursor. Touches surface as MouseButton.LEFT in the web runtime.
128        InputMap.add_action("touch_move", [MouseButton.LEFT])
129        InputMap.add_action("quit", [Key.ESCAPE])
130
131        # Background (anchored, scales with window).
132        self.add_child(Background())
133
134        # Camera: keeps the world in screen pixels.  Position is updated each
135        # frame in on_update so the world centres on the live window.
136        self.camera = self.add_child(Camera2D(name="Camera", position=Vec2(WIDTH / 2, HEIGHT / 2)))
137
138        # Player: registered first so HUD draws on top of it.
139        self.player = self.add_child(Player(screen_size=Vec2(WIDTH, HEIGHT), name="Player"))
140        self.player.hit.connect(self._on_player_hit)
141
142        # HUD: anchored Control widgets (Label).
143        self.hud = self.add_child(HUD())
144        self.hud.start_game.connect(self._new_game)
145
146        # Audio: bundled music streams from disk; the death cue is synthesised
147        # at load time (no third-party sound file is bundled).
148        self.music = self._make_audio("House In a Forest Loop.ogg", loop=True, volume_db=-8.0)
149        self.death_sound = self.add_child(
150            AudioPlayer(
151                stream=make_gameover(),
152                loop=False,
153                autoplay=False,
154                volume_db=-2.0,
155                name="death_sound",
156            )
157        )
158
159        # Mob spawn / score timers.
160        self.mob_timer = self.add_child(Timer(self.SPAWN_INTERVAL, one_shot=False, name="MobTimer"))
161        self.mob_timer.timeout.connect(self._on_mob_timer)
162
163        self.score_timer = self.add_child(Timer(self.SCORE_INTERVAL, one_shot=False, name="ScoreTimer"))
164        self.score_timer.timeout.connect(self._on_score_timer)
165
166        self.start_timer = self.add_child(Timer(self.START_DELAY, one_shot=True, name="StartTimer"))
167        self.start_timer.timeout.connect(self._on_start_timer)
168
169        # The root's on_draw renders the splash from the HUD's plain-attribute
170        # state (message_text / message_visible / show_prompt), which the HUD
171        # mutates on its OWN schedule -- a message-fade Timer and the game-over
172        # coroutine -- with no Property write to auto-dirty this node. Those
173        # cross-node pokes can't cleanly reach the root, so mark the root
174        # `dynamic`: its on_draw re-captures every frame (a cheap per-node patch
175        # of a few text ops; the rest of the scene still frame-skips) and the
176        # splash can never freeze mid-transition.
177        self.dynamic = True
178
179        # Splash screen, identical to Godot's: title visible, prompt visible,
180        # waiting for input.
181        self.player.kill()
182        self.hud.message_text = "Dodge the Creeps"
183        self.hud.message_visible = True
184        self.hud.show_prompt = True
185
186    # ------------------------------------------------------------------
187    # Audio helpers
188    # ------------------------------------------------------------------
189
190    def _make_audio(self, name: str, *, loop: bool, volume_db: float) -> AudioPlayer | None:
191        path = ASSETS / name
192        if not path.exists():
193            return None
194        try:
195            stream = AudioClip(str(path))
196        except Exception:
197            return None
198        return self.add_child(
199            AudioPlayer(
200                stream=stream,
201                loop=loop,
202                autoplay=False,
203                volume_db=volume_db,
204                name=path.stem.replace(" ", "_"),
205            )
206        )
207
208    # ------------------------------------------------------------------
209    # Game flow
210    # ------------------------------------------------------------------
211
212    def _live_size(self):
213        """Current window dimensions in pixels."""
214        if self.tree:
215            return float(self.tree.screen_size[0]), float(self.tree.screen_size[1])
216        return float(WIDTH), float(HEIGHT)
217
218    def _new_game(self):
219        if self._game_active:
220            return
221        # Clear any leftover mobs from a previous run.
222        for mob in list(self.tree.group("mobs")):
223            mob.destroy()
224        self.score = 0
225        self.hud.update_score(self.score)
226        self.hud.hide_start_prompt()
227        self.hud.show_message("Get Ready")
228        # Start position derived from current window size, not the baked WIDTH/HEIGHT.
229        sw, sh = self._live_size()
230        self.player.start(Vec2(sw / 2, sh - 270))
231        self._game_active = True
232        self.start_timer.start()
233        if self.music is not None:
234            self.music.play()
235
236    def _on_start_timer(self):
237        self.mob_timer.start()
238        self.score_timer.start()
239
240    def _on_score_timer(self):
241        self.score += 1
242        self.hud.update_score(self.score)
243
244    def _on_mob_timer(self):
245        # Pick a random edge: 0=top, 1=right, 2=bottom, 3=left, then a random
246        # offset along that edge. Direction is the inward normal plus a small
247        # random spread. Bounds come from the live window size.
248        sw, sh = self._live_size()
249        edge = random.randrange(4)
250        if edge == 0:
251            pos = Vec2(random.uniform(0, sw), -40)
252            direction = math.pi / 2  # downward
253        elif edge == 1:
254            pos = Vec2(sw + 40, random.uniform(0, sh))
255            direction = math.pi  # leftward
256        elif edge == 2:
257            pos = Vec2(random.uniform(0, sw), sh + 40)
258            direction = -math.pi / 2  # upward
259        else:
260            pos = Vec2(-40, random.uniform(0, sh))
261            direction = 0.0  # rightward
262        direction += random.uniform(-math.pi / 4, math.pi / 4)
263        speed = random.uniform(*self.MOB_SPEED_RANGE)
264        mob = Mob(screen_size=Vec2(sw, sh), name="Mob")
265        self.add_child(mob)
266        mob.configure(pos, direction, speed)
267
268    def _on_player_hit(self):
269        # Triggered by Main when overlap is detected; the player has already
270        # been hidden via kill().
271        if not self._game_active:
272            return
273        self._game_active = False
274        self.mob_timer.stop()
275        self.score_timer.stop()
276        self.hud.show_game_over()
277        if self.music is not None:
278            self.music.stop()
279        if self.death_sound is not None:
280            self.death_sound.play()
281        # `show_game_over` re-raises the restart prompt once its coroutine has
282        # run its course, and the HUD only emits `start_game` while the prompt
283        # is up, so restart input re-enables itself with no extra timer here.
284
285    # ------------------------------------------------------------------
286    # Per-frame logic
287    # ------------------------------------------------------------------
288
289    def on_fixed_update(self, dt: float):
290        # Player-mob collision via a circle-overlap test over the "mobs" group.
291        if self._game_active and self.player.overlapping_mobs():
292            self.player.kill()
293            self.player.hit()
294
295    def on_update(self, dt: float):
296        # Keep the camera centred on the live window so resize works.
297        if self.tree:
298            sw, sh = self._live_size()
299            self.camera.position = Vec2(sw / 2, sh / 2)
300
301        if Input.is_action_just_pressed("quit"):
302            self.app.quit()
303
304    HINT_COLOUR = (0.70, 0.70, 0.70, 1.0)
305    WHITE = (1.0, 1.0, 1.0, 1.0)
306
307    def on_draw(self, renderer):
308        if self.tree is None:
309            return
310        sw, sh = float(self.tree.screen_size[0]), float(self.tree.screen_size[1])
311
312        def line_h(s):
313            return s * 16
314
315        def fit(text: str, target_w: float, max_scale: int) -> int:
316            for s in range(max_scale, 0, -1):
317                if renderer.text_width(text, s) <= target_w:
318                    return s
319            return 1
320
321        def draw_centered(text: str, scale: int, y: float, colour=self.WHITE):
322            w = renderer.text_width(text, scale)
323            renderer.draw_text(text, (sw / 2 - w / 2, y), scale=scale, colour=colour)
324
325        # Splash text: title + (optional) prompt, vertically stacked, centred.
326        if self.hud.message_visible and self.hud.message_text:
327            title_scale = fit(self.hud.message_text, target_w=sw * 0.85, max_scale=6)
328            prompt_scale = fit("Press [Space] / Click", target_w=sw * 0.9, max_scale=2)
329            block_h = line_h(title_scale)
330            if self.hud.show_prompt:
331                block_h += 24 + line_h(prompt_scale)
332            y = sh / 2 - block_h / 2
333            draw_centered(self.hud.message_text, title_scale, y, colour=self.WHITE)
334            y += line_h(title_scale) + 24
335            if self.hud.show_prompt:
336                draw_centered("Press [Space] / Click", prompt_scale, y, colour=self.HINT_COLOUR)
337
338        # Controls panel: bottom-right, vertical, left-justified.
339        lines = ["WASD/ARROWS: MOVE", "SPACE: START", "ESC: QUIT"]
340        widest = max(lines, key=len)
341        scale = fit(widest, target_w=sw * 0.30, max_scale=2)
342        widest_w = renderer.text_width(widest, scale)
343        panel_x = sw - widest_w - 8
344        y = sh - line_h(scale) * len(lines) - 8
345        for line in lines:
346            renderer.draw_text(line, (panel_x, y), scale=scale, colour=self.HINT_COLOUR)
347            y += line_h(scale)
348
349
350# ---------------------------------------------------------------------------
351# Entry point
352# ---------------------------------------------------------------------------
353
354
355def main():
356    test_mode = "--test" in sys.argv
357    app = App(width=WIDTH, height=HEIGHT, title="Dodge the Creeps", visible=not test_mode)
358    if test_mode:
359        # Render N frames headlessly, then exit cleanly.
360        app.run_headless(Main(), frames=120)
361        # No app.quit() needed; run_headless tears down the app on return.
362    else:
363        app.run(Main())
364
365
366if __name__ == "__main__":
367    main()