Dodge the Creeps¶
Godot’s first 2D tutorial, wandering mobs, top-down dodge.
▶ Run in browserUpstream: 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 groupsAnimatedSprite2Dflipbook animation stitched from individual PNG framesTimernodes plus a coroutine (wait,wait_signal) driving game overAnchored
Control/LabelUI that survives window resizingAction-based input (
InputMap,Input.get_vector) shared by desktop, web and touchA 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 |
|---|---|---|
Dodge the Creeps: Godot’s first 2D tutorial, wandering mobs, top-down dodge. |
367 |
|
Headless screenshot capture for Dodge the Creeps. |
31 |
|
Node modules for the Dodge the Creeps SimVX port. |
1 |
|
Procedural audio: built on the engine’s AudioSynth API. |
67 |
|
HUD: anchored Label widgets for score / message / start prompt. |
88 |
|
Mob: fly/walk/swim creep that drifts across the screen at a fixed velocity. |
74 |
|
Player: moves with WASD/arrows, plays walk/up animations, emits |
142 |
|
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()