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, 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 groupsAnimatedSprite2Dflipbook animation stitched from individual PNG framesTimernodes plus a coroutine (wait,wait_signal) driving game overAnchored
Control/LabelUI that survives window resizingOne typed
moveaction carried by the player’s ownInputContext, fed by the keys, a controller’s stick and the drawnTouchControlleralikeA 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 |
|---|---|---|
Dodge the Creeps: Godot’s first 2D tutorial, wandering mobs, top-down dodge. |
353 |
|
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. |
89 |
|
Mob: fly/walk/swim creep that drifts across the screen at a fixed velocity. |
73 |
|
Player: moves with keys, a stick or the drawn pad, and emits |
160 |
|
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()