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, the arrow keys, a controller’s left stick or the on-screen stick to move; Space, Enter, a tap or a controller’s A button to start and restart; Escape to quit.

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

  • One typed move action carried by the player’s own InputContext, fed by the keys, a controller’s stick and the drawn TouchController alike

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

Run: simvx examples run ports_dodge_the_creeps simvx examples run ports_dodge_the_creeps –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.

353

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.

89

nodes/mob.py

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

73

nodes/player.py

Player: moves with keys, a stick or the drawn pad, and emits hit.

160

nodes/sprite_sheets.py

Build horizontal sprite-sheet ndarrays from individual PNG files.

64

Source

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