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

  • One typed move action fed by the keys, a controller stick and the drawn TouchController alike, declared by the nodes that read it

Run: simvx examples run ports_squash_the_creeps simvx examples run ports_squash_the_creeps –test # headless smoke run

Web export: simvx export web main.py -o squash_the_creeps.html

Controls: WASD / arrows / stick Move Space / A Jump, and bounce when you land on a creep R / Enter / Space / tap / A Start, and restart after game over Esc Quit

On a phone the drawn controller is the whole control scheme: its stick steers and its A button jumps. It leaves the screen as soon as a real controller is used.

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.

334

nodes/__init__.py

Squash the Creeps: node modules.

1

nodes/arena.py

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

180

nodes/controls.py

What the player can do, declared once and carried by the node that reads it.

32

nodes/mob.py

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

138

nodes/player.py

Player: a CharacterBody3D steered by keys, stick or the drawn pad, with jump and bounce.

214

Source

  1# /// simvx
  2# tags = ["port", "tier-0"]
  3# upstream = "https://github.com/godotengine/godot-demo-projects/tree/master/3d/squash_the_creeps"
  4# web = { width = 800, height = 600, responsive = true }
  5# ///
  6"""Squash the Creeps: a 3D arcade port of Godot's first 3D game tutorial.
  7
  8A from-scratch re-implementation of "Squash the Creeps", the game built in
  9Godot's "Your First 3D Game" tutorial. Creeps march in from the arena edge;
 10jump on one from above to squash it and score. Let one reach you on the ground,
 11or wander off the arena yourself, and the run ends.
 12
 13What it demonstrates:
 14
 15- A jump / gravity / bounce controller on ``CharacterBody3D.move_and_slide``
 16  running against a static ground body
 17- Collision layers plus a masked ``node.physics.overlap`` query for stomp and
 18  contact detection
 19- Player, creeps and arena built entirely from procedural ``Mesh`` primitives
 20- ``Timer``-driven spawning and signal wiring (``hit``, ``squashed_mob``)
 21- A ``WorldEnvironment`` gradient sky with fog and ACES tonemapping, lit by a
 22  single directional sun
 23- A resize-aware retained-2D HUD (``on_draw`` plus ``queue_redraw``) with text
 24  fitted to the window
 25- One typed ``move`` action fed by the keys, a controller stick and the drawn
 26  ``TouchController`` alike, declared by the nodes that read it
 27
 28Run:
 29    simvx examples run ports_squash_the_creeps
 30    simvx examples run ports_squash_the_creeps --test   # headless smoke run
 31
 32Web export:
 33    simvx export web main.py -o squash_the_creeps.html
 34
 35Controls:
 36    WASD / arrows / stick         Move
 37    Space / A                     Jump, and bounce when you land on a creep
 38    R / Enter / Space / tap / A   Start, and restart after game over
 39    Esc                           Quit
 40
 41On a phone the drawn controller is the whole control scheme: its stick steers and
 42its A button jumps. It leaves the screen as soon as a real controller is used.
 43
 44Music "House In a Forest Loop" by HorrorPen (CC-BY 3.0). See ATTRIBUTION.md for
 45the full upstream credits and licences.
 46"""
 47
 48from __future__ import annotations
 49
 50import os
 51import sys
 52from pathlib import Path
 53
 54_PORT_DIR = Path(__file__).resolve().parent
 55
 56from nodes.arena import (  # noqa: E402
 57    CAMERA_FOV,
 58    PLAYER_MARGIN,
 59    Arena,
 60    camera_offset,
 61    is_off_arena,
 62    random_spawn_position,
 63)
 64from nodes.controls import SHELL  # noqa: E402
 65from nodes.mob import Mob  # noqa: E402
 66from nodes.player import Player  # noqa: E402
 67
 68from simvx.core import (  # noqa: E402
 69    AudioClip,
 70    AudioPlayer,
 71    Camera3D,
 72    CanvasLayer,
 73    Controller,
 74    Input,
 75    Node,
 76    Property,
 77    Text2D,
 78    Timer,
 79)
 80from simvx.core.ui import TouchController  # noqa: E402
 81from simvx.graphics import App  # noqa: E402
 82
 83ASSETS = _PORT_DIR / "assets"
 84
 85# The window the game opens at. Layout reads the live size, never this.
 86WINDOW_W = 1024
 87WINDOW_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    # What the scene itself answers for. The tree registers these when it
104    # takes the root, so a web export and a test that loads this scene both
105    # get them without anything being declared from `main()`. Moving and
106    # jumping are the player's own business and live on the player.
107    input_context = SHELL
108
109    spawn_interval = Property(
110        0.5,
111        range=(0.1, 5.0),
112        hint="Seconds between mob spawns",
113        on_change="_on_spawn_interval_changed",
114    )
115
116    def __init__(self, **kwargs):
117        super().__init__(**kwargs)
118        self._score = 0
119        self._state = STATE_MENU
120
121        self._arena = self.add_child(Arena(name="Arena"))
122
123        cam_pos, cam_target = camera_offset()
124        self._camera = self.add_child(Camera3D(name="Camera", position=cam_pos, fov=CAMERA_FOV, far=120.0))
125        self._camera.look_at(cam_target)
126
127        self._player = self.add_child(Player(name="Player"))
128        self._player.hit.connect(self._on_player_hit)
129        self._player.squashed_mob.connect(self._on_mob_squashed)
130
131        # Spawn timer: paused until the player leaves the menu.
132        self._mob_timer = self.add_child(
133            Timer(duration=self.spawn_interval, one_shot=False, autostart=False, name="MobTimer")
134        )
135        self._mob_timer.timeout.connect(self._on_mob_timer)
136
137        # HUD: the score label is pinned to the top-left corner and only ever
138        # has its text rewritten; the splash and controls overlay in on_draw is
139        # the resize-aware half of the HUD.
140        # HUD text rides a CanvasLayer, pinned to the screen and out of the tonemap.
141        self._hud_layer = self.add_child(CanvasLayer(name="HUD"))
142        self._score_text = self._hud_layer.add_child(
143            Text2D(
144                name="Score",
145                text="Score: 0",
146                position=(16, 12),
147                font_scale=2.4,
148                colour=(1.0, 1.0, 1.0, 1.0),
149            )
150        )
151
152        # The pad on the glass. It publishes a real controller, so the
153        # player's stick and jump bindings and this scene's Controller.A
154        # already read it and nothing further is wired. It leaves the screen
155        # the moment a real controller is used.
156        self.add_child(TouchController(buttons=(Controller.A,), name="TouchPad"))
157
158        # Music: the loop bundled with the upstream tutorial, started with the run.
159        self._music = self._make_music()
160
161    def _make_music(self) -> AudioPlayer | None:
162        """Add the bundled music loop, or None when it was not shipped."""
163        path = ASSETS / "music_loop.ogg"
164        if not path.exists():
165            return None
166        return self.add_child(
167            AudioPlayer(
168                name="Music",
169                stream=AudioClip(str(path)),
170                bus="Music",
171                loop=True,
172                autoplay=False,
173                volume_db=-9.0,
174            )
175        )
176
177    def _on_spawn_interval_changed(self) -> None:
178        """Push a live edit of the tunable through to the running spawn timer."""
179        self._mob_timer.duration = self.spawn_interval
180
181    # -- lifecycle ----------------------------------------------------------
182
183    def on_update(self, dt: float):
184        if Input.just_pressed("quit"):
185            self.app.quit()
186            return
187
188        if self._state == STATE_MENU:
189            if Input.just_pressed("start"):
190                self._begin_run()
191            return
192
193        if self._state == STATE_OVER:
194            if Input.just_pressed("start"):
195                self._restart()
196            return
197
198        # Lose condition: the player strays past the marked boundary.
199        if is_off_arena(self._player.position, margin=PLAYER_MARGIN):
200            self._player.die()
201
202        # Despawn mobs that wander too far.
203        for mob in self.tree.group("mob"):
204            if is_off_arena(mob.position):
205                mob.destroy()
206
207    def _begin_run(self) -> None:
208        self._state = STATE_PLAY
209        self._player.start()
210        self._mob_timer.start()
211        if self._music is not None:
212            self._music.play()
213        # State drives the on_draw splash text; dirty the retained 2D layer so
214        # the title overlay is cleared once play begins.
215        self.queue_redraw()
216
217    # -- handlers -----------------------------------------------------------
218
219    def _on_mob_timer(self):
220        if self._state != STATE_PLAY:
221            return
222        spawn = random_spawn_position()
223        mob = self.add_child(Mob(name="Mob"))
224        mob.initialize(spawn, self._player.position)
225
226    def _on_mob_squashed(self):
227        self._score += 1
228        self._score_text.text = f"Score: {self._score}"
229
230    def _on_player_hit(self):
231        self._state = STATE_OVER
232        self._mob_timer.stop()
233        if self._music is not None:
234            self._music.stop()
235        # Reveal the GAME OVER splash: state change must re-run on_draw.
236        self.queue_redraw()
237
238    # -- restart ------------------------------------------------------------
239
240    def _restart(self):
241        # Deferred: a scene swapped from inside the frame walk detaches these
242        # children while the walk is still visiting them.
243        self.call_deferred(self.tree.change_scene, SquashTheCreeps())
244
245    # -- per-frame HUD draw -------------------------------------------------
246
247    def on_draw(self, renderer):
248        sw, sh = self.viewport_size
249
250        def fit(text: str, target_w: float, max_scale: int) -> int:
251            for s in range(max_scale, 0, -1):
252                if renderer.text_width(text, s) <= target_w:
253                    return s
254            return 1
255
256        def line_h(s):
257            return s * 16
258
259        def draw_centered(text: str, scale: int, y: float, colour=(1.0, 1.0, 1.0, 1.0)):
260            w = renderer.text_width(text, scale)
261            renderer.text(text, (sw / 2 - w / 2, y), fill=colour, scale=scale)
262
263        # Splash text: title on menu, "GAME OVER + score" on lose.
264        if self._state == STATE_MENU:
265            title_scale = fit("SQUASH THE CREEPS", target_w=sw * 0.85, max_scale=6)
266            prompt_scale = fit("PRESS SPACE, TAP OR (A) TO PLAY", target_w=sw * 0.85, max_scale=2)
267            block_h = line_h(title_scale) + 24 + line_h(prompt_scale)
268            y = sh / 2 - block_h / 2
269            draw_centered("SQUASH THE CREEPS", title_scale, y, (1.0, 0.95, 0.4, 1.0))
270            y += line_h(title_scale) + 24
271            draw_centered("PRESS SPACE, TAP OR (A) TO PLAY", prompt_scale, y, HINT_COLOUR)
272        elif self._state == STATE_OVER:
273            game_over_scale = fit("GAME OVER", target_w=sw * 0.7, max_scale=6)
274            score_scale = max(2, game_over_scale - 2)
275            prompt_scale = fit("PRESS SPACE, TAP OR (A) TO RETRY", target_w=sw * 0.85, max_scale=2)
276            block_h = line_h(game_over_scale) + 14 + line_h(score_scale) + 14 + line_h(prompt_scale)
277            y = sh / 2 - block_h / 2
278            draw_centered("GAME OVER", game_over_scale, y, (0.95, 0.4, 0.4, 1.0))
279            y += line_h(game_over_scale) + 14
280            draw_centered(f"SCORE  {self._score}", score_scale, y, (1.0, 1.0, 1.0, 1.0))
281            y += line_h(score_scale) + 14
282            draw_centered("PRESS SPACE, TAP OR (A) TO RETRY", prompt_scale, y, HINT_COLOUR)
283
284        # Bottom-right vertical, left-justified controls panel.
285        lines = [
286            "MOVE: WASD / STICK",
287            "JUMP: SPACE / (A)",
288            "QUIT: ESC",
289        ]
290        widest = max(lines, key=len)
291        scale = fit(widest, target_w=sw * 0.30, max_scale=2)
292        widest_w = renderer.text_width(widest, scale)
293        pad = 6.0
294        panel_w = widest_w + pad * 2
295        panel_h = line_h(scale) * len(lines) + pad * 2
296        panel_x = sw - panel_w - 8
297        panel_y = sh - panel_h - 8
298        renderer.rect((panel_x, panel_y), (panel_w, panel_h), fill=CONTROLS_PLATE)
299        y = panel_y + pad
300        for line in lines:
301            renderer.text(line, (panel_x + pad, y), fill=CONTROLS_COLOUR, scale=scale)
302            y += line_h(scale)
303
304
305def main() -> None:
306    headless = "--test" in sys.argv or os.environ.get("SIMVX_HEADLESS") == "1"
307    app = App(
308        title="Squash the Creeps (SimVX)",
309        width=WINDOW_W,
310        height=WINDOW_H,
311        physics_fps=60,
312        visible=not headless,
313    )
314    if headless:
315        from simvx.graphics import save_png
316
317        out_dir = _PORT_DIR / "screenshots"
318        out_dir.mkdir(parents=True, exist_ok=True)
319        frames = app.run_headless(
320            SquashTheCreeps(),
321            frames=120,
322            capture_frames=[60, 119],
323        )
324        for idx, frame_no in enumerate([60, 119]):
325            save_png(frames[idx], str(out_dir / f"frame_{frame_no:03d}.png"))
326        print(f"Headless screenshots written to {out_dir}")
327        app.quit()
328        return
329
330    app.run(SquashTheCreeps())
331
332
333if __name__ == "__main__":
334    main()