Squash the Creeps

a 3D arcade port of Godot’s first 3D game tutorial.

▶ Run in browser

Upstream: 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_slide running against a static ground body

  • Collision layers plus a masked node.physics.overlap query for stomp and contact detection

  • Player, creeps and arena built entirely from procedural Mesh primitives

  • Timer-driven spawning and signal wiring (hit, squashed_mob)

  • A WorldEnvironment gradient sky with fog and ACES tonemapping, lit by a single directional sun

  • A resize-aware retained-2D HUD (on_draw plus queue_redraw) with text fitted to the window

  • Input 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

main.py

Squash the Creeps: a 3D arcade port of Godot’s first 3D game tutorial.

343

nodes/__init__.py

Squash the Creeps: node modules.

1

nodes/arena.py

Arena: static ground body, decorative pillars, lighting and sky.

182

nodes/mob.py

Mob: chases the player; dies when squashed or off-screen.

138

nodes/player.py

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()