Squash the Creeps¶
a 3D arcade port of Godot’s first 3D game tutorial.
▶ Run in browserUpstream: https://github.com/godotengine/godot-demo-projects/tree/master/3d/squash_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 “Squash the Creeps”, the game built in Godot’s “Your First 3D Game” tutorial. Creeps march in from the arena edge; jump on one from above to squash it and score. Let one reach you on the ground, or wander off the arena yourself, and the run ends.
What it demonstrates:
A jump / gravity / bounce controller on
CharacterBody3D.move_and_sliderunning against a static ground bodyCollision layers plus a masked
node.physics.overlapquery for stomp and contact detectionPlayer, creeps and arena built entirely from procedural
MeshprimitivesTimer-driven spawning and signal wiring (hit,squashed_mob)A
WorldEnvironmentgradient sky with fog and ACES tonemapping, lit by a single directional sunA resize-aware retained-2D HUD (
on_drawplusqueue_redraw) with text fitted to the windowInput latched on the frame clock so a tap survives the fixed physics step
Run: uv run python examples/ports/squash_the_creeps/main.py uv run python examples/ports/squash_the_creeps/main.py –test # headless smoke run
Web export: uv run simvx export web examples/ports/squash_the_creeps/main.py -o /tmp/squash_the_creeps.html
Controls: WASD / arrows Move Drag Steer in the drag direction (mouse or touch) Space / tap Jump, and bounce when you land on a creep R / Enter / Space / tap Start, and restart after game over Esc Quit
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 |
|---|---|---|
Squash the Creeps: a 3D arcade port of Godot’s first 3D game tutorial. |
343 |
|
Squash the Creeps: node modules. |
1 |
|
Arena: static ground body, decorative pillars, lighting and sky. |
182 |
|
Mob: chases the player; dies when squashed or off-screen. |
138 |
|
Player: a CharacterBody3D steered by keyboard or pointer, with jump and bounce. |
260 |
Source¶
1"""Squash the Creeps: a 3D arcade port of Godot's first 3D game tutorial.
2
3# /// simvx
4# tags = ["port", "tier-0"]
5# upstream = "https://github.com/godotengine/godot-demo-projects/tree/master/3d/squash_the_creeps"
6# web = { width = 800, height = 600, responsive = true }
7# ///
8
9A from-scratch re-implementation of "Squash the Creeps", the game built in
10Godot's "Your First 3D Game" tutorial. Creeps march in from the arena edge;
11jump on one from above to squash it and score. Let one reach you on the ground,
12or wander off the arena yourself, and the run ends.
13
14What it demonstrates:
15
16- A jump / gravity / bounce controller on ``CharacterBody3D.move_and_slide``
17 running against a static ground body
18- Collision layers plus a masked ``node.physics.overlap`` query for stomp and
19 contact detection
20- Player, creeps and arena built entirely from procedural ``Mesh`` primitives
21- ``Timer``-driven spawning and signal wiring (``hit``, ``squashed_mob``)
22- A ``WorldEnvironment`` gradient sky with fog and ACES tonemapping, lit by a
23 single directional sun
24- A resize-aware retained-2D HUD (``on_draw`` plus ``queue_redraw``) with text
25 fitted to the window
26- Input latched on the frame clock so a tap survives the fixed physics step
27
28Run:
29 uv run python examples/ports/squash_the_creeps/main.py
30 uv run python examples/ports/squash_the_creeps/main.py --test # headless smoke run
31
32Web export:
33 uv run simvx export web examples/ports/squash_the_creeps/main.py -o /tmp/squash_the_creeps.html
34
35Controls:
36 WASD / arrows Move
37 Drag Steer in the drag direction (mouse or touch)
38 Space / tap Jump, and bounce when you land on a creep
39 R / Enter / Space / tap Start, and restart after game over
40 Esc Quit
41
42Music "House In a Forest Loop" by HorrorPen (CC-BY 3.0). See ATTRIBUTION.md for
43the full upstream credits and licences.
44"""
45
46from __future__ import annotations
47
48import os
49import sys
50from pathlib import Path
51
52# Allow `python main.py` from anywhere: make the port directory importable
53# so `nodes.player` etc. resolve regardless of cwd.
54_PORT_DIR = Path(__file__).resolve().parent
55if str(_PORT_DIR) not in sys.path:
56 sys.path.insert(0, str(_PORT_DIR))
57
58from nodes.arena import ( # noqa: E402
59 CAMERA_FOV,
60 PLAYER_MARGIN,
61 Arena,
62 camera_offset,
63 is_off_arena,
64 random_spawn_position,
65)
66from nodes.mob import Mob # noqa: E402
67from nodes.player import Player # noqa: E402
68
69from simvx.core import ( # noqa: E402
70 AudioClip,
71 AudioPlayer,
72 Camera3D,
73 Input,
74 InputMap,
75 Key,
76 MouseButton,
77 Node,
78 Property,
79 Text2D,
80 Timer,
81)
82from simvx.graphics import App # noqa: E402
83
84ASSETS = _PORT_DIR / "assets"
85
86VIEWPORT_W = 1024
87VIEWPORT_H = 768
88
89STATE_MENU = "menu"
90STATE_PLAY = "play"
91STATE_OVER = "over"
92
93HINT_COLOUR = (0.70, 0.70, 0.70, 1.0)
94# The controls panel sits over grass, sky and the bright arena edge strip, so
95# it gets a dimming plate behind it rather than relying on the backdrop.
96CONTROLS_COLOUR = (0.97, 0.97, 0.95, 1.0)
97CONTROLS_PLATE = (0.0, 0.0, 0.0, 0.62)
98
99
100class SquashTheCreeps(Node):
101 """Top-level game scene."""
102
103 spawn_interval = Property(
104 0.5,
105 range=(0.1, 5.0),
106 hint="Seconds between mob spawns",
107 on_change="_on_spawn_interval_changed",
108 )
109
110 def __init__(self, **kwargs):
111 super().__init__(**kwargs)
112 self._score = 0
113 self._state = STATE_MENU
114 self._screen_w = VIEWPORT_W
115 self._screen_h = VIEWPORT_H
116
117 self._arena = self.add_child(Arena(name="Arena"))
118
119 cam_pos, cam_target = camera_offset()
120 self._camera = self.add_child(Camera3D(name="Camera", position=cam_pos, fov=CAMERA_FOV, far=120.0))
121 self._camera.look_at(cam_target)
122
123 self._player = self.add_child(Player(name="Player"))
124 self._player.hit.connect(self._on_player_hit)
125 self._player.squashed_mob.connect(self._on_mob_squashed)
126
127 # Spawn timer: paused until the player leaves the menu.
128 self._mob_timer = self.add_child(
129 Timer(duration=self.spawn_interval, one_shot=False, autostart=False, name="MobTimer")
130 )
131 self._mob_timer.timeout.connect(self._on_mob_timer)
132
133 # HUD: the score label is pinned to the top-left corner and only ever
134 # has its text rewritten; the splash and controls overlay in on_draw is
135 # the resize-aware half of the HUD.
136 self._score_text = self.add_child(
137 Text2D(
138 name="Score",
139 text="Score: 0",
140 position=(16, 12),
141 font_scale=2.4,
142 colour=(1.0, 1.0, 1.0, 1.0),
143 )
144 )
145
146 # Music: the loop bundled with the upstream tutorial, started with the run.
147 self._music = self._make_music()
148
149 def _make_music(self) -> AudioPlayer | None:
150 """Add the bundled music loop, or None when it was not shipped."""
151 path = ASSETS / "music_loop.ogg"
152 if not path.exists():
153 return None
154 return self.add_child(
155 AudioPlayer(
156 name="Music",
157 stream=AudioClip(str(path)),
158 bus="Music",
159 loop=True,
160 autoplay=False,
161 volume_db=-9.0,
162 )
163 )
164
165 def _on_spawn_interval_changed(self) -> None:
166 """Push a live edit of the tunable through to the running spawn timer."""
167 self._mob_timer.duration = self.spawn_interval
168
169 # -- lifecycle ----------------------------------------------------------
170
171 def on_ready(self):
172 # InputMap calls MUST live in the root's on_ready: module-scope
173 # registration is silently dropped by the web exporter.
174 InputMap.add_action("move_left", [Key.A, Key.LEFT])
175 InputMap.add_action("move_right", [Key.D, Key.RIGHT])
176 InputMap.add_action("move_forward", [Key.W, Key.UP])
177 InputMap.add_action("move_back", [Key.S, Key.DOWN])
178 # Jump is keyboard-only as an action: during a run the pointer is a
179 # steering stick, so the Player itself decides whether a press was a
180 # steering drag or a tap-to-jump (mouse press surfaces as touch on web).
181 InputMap.add_action("jump", [Key.SPACE])
182 InputMap.add_action("retry", [Key.R, Key.ENTER, Key.SPACE, MouseButton.LEFT])
183 InputMap.add_action("quit", [Key.ESCAPE])
184
185 def on_update(self, dt: float):
186 if Input.is_action_just_pressed("quit"):
187 self.app.quit()
188 return
189
190 # Track current window size for HUD positioning. A resize changes the
191 # centred splash + controls-panel layout, so dirty the retained 2D
192 # draw when (and only when) the screen size actually changes.
193 if self.tree:
194 sw, sh = float(self.tree.screen_size[0]), float(self.tree.screen_size[1])
195 if (sw, sh) != (self._screen_w, self._screen_h):
196 self._screen_w, self._screen_h = sw, sh
197 self.queue_redraw()
198
199 if self._state == STATE_MENU:
200 if Input.is_action_just_pressed("retry"):
201 self._begin_run()
202 return
203
204 if self._state == STATE_OVER:
205 if Input.is_action_just_pressed("retry"):
206 self._restart()
207 return
208
209 # Lose condition: the player strays past the marked boundary.
210 if is_off_arena(self._player.position, margin=PLAYER_MARGIN):
211 self._player.die()
212
213 # Despawn mobs that wander too far.
214 for mob in list(self.tree.group("mob")):
215 if is_off_arena(mob.position):
216 mob.destroy()
217
218 def _begin_run(self) -> None:
219 self._state = STATE_PLAY
220 self._player.start()
221 self._mob_timer.start()
222 if self._music is not None:
223 self._music.play()
224 # State drives the on_draw splash text; dirty the retained 2D layer so
225 # the title overlay is cleared once play begins.
226 self.queue_redraw()
227
228 # -- handlers -----------------------------------------------------------
229
230 def _on_mob_timer(self):
231 if self._state != STATE_PLAY:
232 return
233 spawn = random_spawn_position()
234 mob = self.add_child(Mob(name="Mob"))
235 mob.initialize(spawn, self._player.position)
236
237 def _on_mob_squashed(self):
238 self._score += 1
239 self._score_text.text = f"Score: {self._score}"
240
241 def _on_player_hit(self):
242 self._state = STATE_OVER
243 self._mob_timer.stop()
244 if self._music is not None:
245 self._music.stop()
246 # Reveal the GAME OVER splash: state change must re-run on_draw.
247 self.queue_redraw()
248
249 # -- restart ------------------------------------------------------------
250
251 def _restart(self):
252 self.tree.change_scene(SquashTheCreeps())
253
254 # -- per-frame HUD draw -------------------------------------------------
255
256 def on_draw(self, renderer):
257 sw, sh = self._screen_w, self._screen_h
258
259 def fit(text: str, target_w: float, max_scale: int) -> int:
260 for s in range(max_scale, 0, -1):
261 if renderer.text_width(text, s) <= target_w:
262 return s
263 return 1
264
265 def line_h(s):
266 return s * 16
267
268 def draw_centered(text: str, scale: int, y: float, colour=(1.0, 1.0, 1.0, 1.0)):
269 w = renderer.text_width(text, scale)
270 renderer.draw_text(text, (sw / 2 - w / 2, y), scale=scale, colour=colour)
271
272 # Splash text: title on menu, "GAME OVER + score" on lose.
273 if self._state == STATE_MENU:
274 title_scale = fit("SQUASH THE CREEPS", target_w=sw * 0.85, max_scale=6)
275 prompt_scale = fit("PRESS [SPACE] OR TAP TO PLAY", target_w=sw * 0.85, max_scale=2)
276 block_h = line_h(title_scale) + 24 + line_h(prompt_scale)
277 y = sh / 2 - block_h / 2
278 draw_centered("SQUASH THE CREEPS", title_scale, y, (1.0, 0.95, 0.4, 1.0))
279 y += line_h(title_scale) + 24
280 draw_centered("PRESS [SPACE] OR TAP TO PLAY", prompt_scale, y, HINT_COLOUR)
281 elif self._state == STATE_OVER:
282 game_over_scale = fit("GAME OVER", target_w=sw * 0.7, max_scale=6)
283 score_scale = max(2, game_over_scale - 2)
284 prompt_scale = fit("PRESS [SPACE] OR TAP TO RETRY", target_w=sw * 0.85, max_scale=2)
285 block_h = line_h(game_over_scale) + 14 + line_h(score_scale) + 14 + line_h(prompt_scale)
286 y = sh / 2 - block_h / 2
287 draw_centered("GAME OVER", game_over_scale, y, (0.95, 0.4, 0.4, 1.0))
288 y += line_h(game_over_scale) + 14
289 draw_centered(f"SCORE {self._score}", score_scale, y, (1.0, 1.0, 1.0, 1.0))
290 y += line_h(score_scale) + 14
291 draw_centered("PRESS [SPACE] OR TAP TO RETRY", prompt_scale, y, HINT_COLOUR)
292
293 # Bottom-right vertical, left-justified controls panel.
294 lines = [
295 "MOVE: WASD / DRAG",
296 "JUMP: SPACE / TAP",
297 "QUIT: ESC",
298 ]
299 widest = max(lines, key=len)
300 scale = fit(widest, target_w=sw * 0.30, max_scale=2)
301 widest_w = renderer.text_width(widest, scale)
302 pad = 6.0
303 panel_w = widest_w + pad * 2
304 panel_h = line_h(scale) * len(lines) + pad * 2
305 panel_x = sw - panel_w - 8
306 panel_y = sh - panel_h - 8
307 renderer.draw_rect((panel_x, panel_y), (panel_w, panel_h), colour=CONTROLS_PLATE, filled=True)
308 y = panel_y + pad
309 for line in lines:
310 renderer.draw_text(line, (panel_x + pad, y), scale=scale, colour=CONTROLS_COLOUR)
311 y += line_h(scale)
312
313
314def main() -> None:
315 headless = "--test" in sys.argv or os.environ.get("SIMVX_HEADLESS") == "1"
316 app = App(
317 title="Squash the Creeps (SimVX)",
318 width=VIEWPORT_W,
319 height=VIEWPORT_H,
320 physics_fps=60,
321 visible=not headless,
322 )
323 if headless:
324 from simvx.graphics import save_png
325
326 out_dir = _PORT_DIR / "screenshots"
327 out_dir.mkdir(parents=True, exist_ok=True)
328 frames = app.run_headless(
329 SquashTheCreeps(),
330 frames=120,
331 capture_frames=[60, 119],
332 )
333 for idx, frame_no in enumerate([60, 119]):
334 save_png(frames[idx], str(out_dir / f"frame_{frame_no:03d}.png"))
335 print(f"Headless screenshots written to {out_dir}")
336 app.quit()
337 return
338
339 app.run(SquashTheCreeps())
340
341
342if __name__ == "__main__":
343 main()