Space Invaders 3D¶

the arcade classic rebuilt with the 3D pipeline.

â–¶ Run in browser

Tags: game 3d collision waves

The same fleet, barriers and pacing as the 2D demo, but every actor is a 3D mesh seen from a top-down camera. Five rows of aliens march in formation, speed up as their ranks thin, drop a step closer every time the formation touches an edge, and shoot back at random; four barriers soak up fire block by block until there is nothing left to hide behind. Clear the fleet and a fresh one takes its place.

Along the way the demo shows:

  • Procedural mesh primitives (Mesh.cone/cube/sphere) shared by every instance

  • Scene-tree groups driving the collision queries, with no physics world in sight

  • screen_to_ray unprojection so a mouse or a touch steers the ship on the play plane

  • Signal decoupling the ship’s fire event from the game rules

  • tree.change_scene for the menu, game and game-over flow

  • Declarative input_actions, re-registered for every scene the tree swaps in

  • AudioSynth baking the shoot and explosion effects in-process, with no asset files

  • Text2D menus and HUD in screen space, laid out from tree.screen_resized

Controls: A/D or Left/Right move, Space fires, Enter starts. With a mouse or a touchscreen, point to steer the ship and click or tap to fire and to start.

Source¶

  1"""Space Invaders 3D: the arcade classic rebuilt with the 3D pipeline.
  2
  3# /// simvx
  4# tags = ["game", "3d", "collision", "waves"]
  5# web = { width = 1024, height = 768, root = "MainMenu" }
  6# ///
  7
  8The same fleet, barriers and pacing as the 2D demo, but every actor is a 3D mesh
  9seen from a top-down camera. Five rows of aliens march in formation, speed up as
 10their ranks thin, drop a step closer every time the formation touches an edge,
 11and shoot back at random; four barriers soak up fire block by block until there
 12is nothing left to hide behind. Clear the fleet and a fresh one takes its place.
 13
 14Along the way the demo shows:
 15
 16- Procedural mesh primitives (``Mesh.cone``/``cube``/``sphere``) shared by every instance
 17- Scene-tree groups driving the collision queries, with no physics world in sight
 18- ``screen_to_ray`` unprojection so a mouse or a touch steers the ship on the play plane
 19- ``Signal`` decoupling the ship's fire event from the game rules
 20- ``tree.change_scene`` for the menu, game and game-over flow
 21- Declarative ``input_actions``, re-registered for every scene the tree swaps in
 22- ``AudioSynth`` baking the shoot and explosion effects in-process, with no asset files
 23- ``Text2D`` menus and HUD in screen space, laid out from ``tree.screen_resized``
 24
 25Controls: A/D or Left/Right move, Space fires, Enter starts. With a mouse or a
 26touchscreen, point to steer the ship and click or tap to fire and to start.
 27"""
 28
 29import random
 30
 31from simvx.core import (
 32    ADSR,
 33    AudioClip,
 34    AudioPlayer,
 35    AudioSynth,
 36    Camera3D,
 37    Input,
 38    Key,
 39    Linear,
 40    Material,
 41    Mesh,
 42    MeshInstance3D,
 43    MouseButton,
 44    Node3D,
 45    Oscillator,
 46    Property,
 47    Signal,
 48    Text2D,
 49    Vec3,
 50    screen_to_ray,
 51)
 52from simvx.graphics import App
 53
 54# Declared on every root scene below: the tree bulk-registers a root's
 55# ``input_actions`` on mount and again after each ``change_scene``, so no scene
 56# depends on another having run first.
 57INPUT_ACTIONS = {
 58    "move_left": [Key.A, Key.LEFT],
 59    "move_right": [Key.D, Key.RIGHT],
 60    "fire": [Key.SPACE, MouseButton.LEFT],
 61    "start": [Key.ENTER, MouseButton.LEFT],
 62}
 63
 64
 65class Body3D(Node3D):
 66    """A manually integrated 3D actor with a collision radius + group overlap query.
 67
 68    Space Invaders is an arcade game with NO physics simulation: aliens, bullets
 69    and the player move by directly setting ``position`` and collisions are plain
 70    sphere-vs-sphere radius tests. This is deliberately NOT a ``CharacterBody3D``
 71    (these entities need no collider at all); it is a light
 72    ``Node3D`` carrying a radius and a group-scoped overlap poll (the durable
 73    replacement for the old arcade ``CharacterBody3D.get_overlapping(group=)``).
 74    """
 75
 76    def __init__(self, radius: float = 0.5, **kwargs):
 77        super().__init__(**kwargs)
 78        self.radius = float(radius)
 79
 80    def get_overlapping(self, group: str) -> list[Body3D]:
 81        """Other ``Body3D`` nodes in ``group`` whose radius overlaps ours."""
 82        if not self.tree:
 83            return []
 84        hits: list[Body3D] = []
 85        for b in self.tree.group(group):
 86            if b is self or not isinstance(b, Body3D):
 87                continue
 88            d = b.world_position - self.world_position
 89            rr = self.radius + b.radius
 90            if float(d.x) ** 2 + float(d.y) ** 2 + float(d.z) ** 2 <= rr * rr:
 91                hits.append(b)
 92        return hits
 93
 94
 95WIDTH, HEIGHT = 1024, 768
 96AREA_W = 30.0  # X extent (-15 to +15)
 97AREA_H = 24.0  # Z extent (-12 to +12)
 98
 99# Alien type definitions: (mesh_factory, colour, points)
100ALIEN_TYPES = [
101    ("cone", (1.0, 0.3, 0.3, 1.0), 30),  # Squid: red cone
102    ("cube", (0.3, 1.0, 0.3, 1.0), 20),  # Crab: green cube
103    ("sphere", (0.3, 0.5, 1.0, 1.0), 10),  # Octopus: blue sphere
104]
105
106# One alien type per formation row (an index into ALIEN_TYPES), back rank first.
107# The fleet speeds up as it is whittled down, so the formation size drives both
108# the spawn grid and the difficulty curve.
109ROW_TYPES = [0, 1, 1, 2, 2]
110ROWS, COLS = len(ROW_TYPES), 11
111
112# Shared meshes (created on first use)
113_meshes: dict[str, Mesh] = {}
114
115
116def _get_mesh(name: str) -> Mesh:
117    if name not in _meshes:
118        if name == "cone":
119            _meshes[name] = Mesh.cone(0.4, 0.8, segments=8)
120        elif name == "cube":
121            _meshes[name] = Mesh.cube(0.7)
122        elif name == "sphere":
123            _meshes[name] = Mesh.sphere(0.4, rings=6, segments=8)
124        elif name == "bullet":
125            _meshes[name] = Mesh.sphere(0.12, rings=4, segments=4)
126        elif name == "player":
127            _meshes[name] = Mesh.cube(0.8)
128        elif name == "barrier_block":
129            _meshes[name] = Mesh.cube(0.35)
130    return _meshes[name]
131
132
133# Arcade sound effects, baked by the engine's synth on first use: no asset
134# files, and the float32 buffers play unchanged on both the Vulkan and the
135# WebGPU backend.
136_sfx: dict[str, AudioClip] = {}
137
138
139def _get_sfx() -> dict[str, AudioClip]:
140    if not _sfx:
141        shoot = AudioSynth()
142        shoot.add(
143            Oscillator.square(660.0, duty=0.35),
144            envelope=ADSR(attack=0.002, decay=0.08, sustain=0.0, release=0.02),
145            gain=0.35,
146        )
147        blast = AudioSynth()
148        blast.add(Oscillator.noise.white(), envelope=Linear(start=1.0, end=0.0), gain=0.45)
149        blast.add(Oscillator.square(90.0), envelope=Linear(start=0.8, end=0.0), gain=0.25)
150        hit = AudioSynth()
151        hit.add(Oscillator.noise.white(), envelope=Linear(start=1.0, end=0.0), gain=0.4)
152        hit.add(Oscillator.saw(70.0), envelope=Linear(start=1.0, end=0.0), gain=0.3)
153        _sfx["shoot"] = shoot.bake(0.12)
154        _sfx["explosion"] = blast.bake(0.30)
155        _sfx["hit"] = hit.bake(0.60)
156    return _sfx
157
158
159# ============================================================================
160# Alien
161# ============================================================================
162
163
164class Alien(Body3D):
165    def __init__(self, alien_type=0, **kwargs):
166        super().__init__(radius=0.5, **kwargs)
167        self.add_to_group("aliens")
168        mesh_name, colour, points = ALIEN_TYPES[min(alien_type, 2)]
169        self.points = points
170        self.alien_type = alien_type
171        self.add_child(
172            MeshInstance3D(
173                name="Mesh",
174                mesh=_get_mesh(mesh_name),
175                material=Material(colour=colour),
176            )
177        )
178
179
180# ============================================================================
181# Player
182# ============================================================================
183
184
185class Player(Body3D):
186    speed = Property(15.0)
187
188    def __init__(self, **kwargs):
189        super().__init__(radius=0.5, **kwargs)
190        self.add_to_group("player")
191        self._cooldown = 0.0
192        self.fired = Signal()
193        self.camera = None  # set by Game so we can unproject the pointer
194        self._pointer_steer = False
195        self.add_child(
196            MeshInstance3D(
197                name="Mesh",
198                mesh=_get_mesh("player"),
199                material=Material(colour=(0.3, 1.0, 0.3, 1.0)),
200            )
201        )
202
203    def _pointer_world_x(self):
204        """World-space X under the cursor on the play plane (y=0), or None."""
205        if self.camera is None:
206            return None
207        w, h = self.app.width, self.app.height
208        origin, direction = screen_to_ray(
209            Input.mouse_position, (w, h), self.camera.view_matrix, self.camera.projection_matrix(w / h)
210        )
211        if abs(direction.y) < 1e-6:
212            return None
213        t = -origin.y / direction.y
214        return float((origin + direction * t).x)
215
216    def on_fixed_update(self, dt):
217        kb_left = Input.is_action_pressed("move_left")
218        kb_right = Input.is_action_pressed("move_right")
219        # Keyboard takes priority; any pointer motion re-enables cursor steering.
220        if kb_left or kb_right:
221            self._pointer_steer = False
222        elif float(Input.mouse_delta.x) or float(Input.mouse_delta.y):
223            self._pointer_steer = True
224
225        if kb_left:
226            self.position.x -= self.speed * dt
227        if kb_right:
228            self.position.x += self.speed * dt
229
230        if self._pointer_steer:
231            target_x = self._pointer_world_x()
232            if target_x is not None:
233                step = self.speed * dt
234                dxp = target_x - self.position.x
235                if abs(dxp) > 0.05:
236                    self.position.x += max(-step, min(step, dxp))
237
238        self.position.x = max(-AREA_W / 2 + 1, min(AREA_W / 2 - 1, self.position.x))
239
240        self._cooldown -= dt
241        if Input.is_action_pressed("fire") and self._cooldown <= 0:
242            self._cooldown = 0.4
243            self.fired()
244
245
246# ============================================================================
247# Bullet
248# ============================================================================
249
250
251class Bullet(Body3D):
252    def __init__(self, direction=-1, **kwargs):
253        super().__init__(radius=0.15, **kwargs)
254        self.direction = direction
255        self.speed = 25.0
256        if direction < 0:
257            self.add_to_group("player_bullets")
258            colour = (1.0, 1.0, 0.3, 1.0)
259        else:
260            self.add_to_group("alien_bullets")
261            colour = (1.0, 0.5, 0.2, 1.0)
262        self.add_child(
263            MeshInstance3D(
264                name="Mesh",
265                mesh=_get_mesh("bullet"),
266                material=Material(colour=colour),
267            )
268        )
269
270    def on_fixed_update(self, dt):
271        self.position.z += self.direction * self.speed * dt
272        if abs(self.position.z) > AREA_H / 2 + 2:
273            self.destroy()
274
275
276# ============================================================================
277# Barrier
278# ============================================================================
279
280BARRIER_PATTERN = [
281    "  #####  ",
282    " ####### ",
283    "#########",
284    "#########",
285    "###   ###",
286    "##     ##",
287]
288
289
290class Barrier(Node3D):
291    def __init__(self, **kwargs):
292        super().__init__(**kwargs)
293        self._blocks: list[MeshInstance3D] = []
294
295    def on_ready(self):
296        mesh = _get_mesh("barrier_block")
297        mat = Material(colour=(0.2, 0.8, 0.4, 1.0))
298        bw = len(BARRIER_PATTERN[0])
299        bh = len(BARRIER_PATTERN)
300        spacing = 0.4
301        ox = -(bw - 1) * spacing / 2
302        oz = -(bh - 1) * spacing / 2
303        for row_i, row_str in enumerate(BARRIER_PATTERN):
304            for col_i, ch in enumerate(row_str):
305                if ch == "#":
306                    block = self.add_child(
307                        MeshInstance3D(
308                            name=f"B_{row_i}_{col_i}",
309                            mesh=mesh,
310                            material=mat,
311                            position=Vec3(ox + col_i * spacing, 0, oz + row_i * spacing),
312                        )
313                    )
314                    self._blocks.append(block)
315
316    def hit(self, pos):
317        """Remove barrier blocks near the hit position."""
318        hit_any = False
319        for block in list(self._blocks):
320            bpos = self.position + block.position
321            dx = pos.x - bpos.x
322            dz = pos.z - bpos.z
323            if dx * dx + dz * dz < 0.5:
324                block.destroy()
325                self._blocks.remove(block)
326                hit_any = True
327        return hit_any
328
329
330# ============================================================================
331# MainMenu
332# ============================================================================
333
334
335class MainMenu(Node3D):
336    input_actions = INPUT_ACTIONS
337
338    def __init__(self, **kwargs):
339        super().__init__(name="MainMenu", **kwargs)
340        self._blink_timer = 0.0
341
342        self.add_child(
343            Camera3D(
344                name="Camera",
345                position=Vec3(0, 35, 0),
346                fov=60,
347            )
348        ).look_at(Vec3(0, 0, 0), up=Vec3(0, 0, -1))
349
350        # Title
351        self._title = self.add_child(Text2D(text="SPACE INVADERS 3D", align="centre", font_scale=4.0))
352
353        # Legend
354        self._legend = [
355            self.add_child(Text2D(text="SQUID = 30 PTS", align="centre", font_scale=2.0, colour=(1.0, 0.31, 0.31))),
356            self.add_child(Text2D(text="CRAB  = 20 PTS", align="centre", font_scale=2.0, colour=(0.31, 1.0, 0.31))),
357            self.add_child(Text2D(text="OCTO  = 10 PTS", align="centre", font_scale=2.0, colour=(0.31, 0.51, 1.0))),
358        ]
359
360        # Blink prompt
361        self._prompt = self.add_child(Text2D(text="PRESS ENTER TO START", align="centre", font_scale=2.0))
362
363        # Controls hint
364        self._controls = self.add_child(
365            Text2D(text="MOVE  A/D  ARROWS  or  MOUSE        FIRE  SPACE  or  CLICK", align="centre", font_scale=1.4)
366        )
367
368    def _layout(self, size):
369        # Driven by the tree's screen_resized signal, so the geometry is
370        # recomputed only when the window actually changes size.
371        w, h = size
372        cx = w / 2
373        self._title.position = (cx, h * 0.12)
374        self._legend[0].position = (cx, h * 0.30)
375        self._legend[1].position = (cx, h * 0.37)
376        self._legend[2].position = (cx, h * 0.44)
377        self._prompt.position = (cx, h * 0.62)
378        self._controls.position = (cx, h - 48)
379
380    def on_ready(self):
381        self._layout(self.tree.screen_size)
382        self.tree.screen_resized.connect(self._layout)
383
384    def on_exit_tree(self):
385        self.tree.screen_resized.disconnect(self._layout)
386
387    def on_update(self, dt):
388        self._blink_timer += dt
389        self._prompt.text = "PRESS ENTER TO START" if int(self._blink_timer * 2) % 2 == 0 else ""
390        if Input.is_action_just_pressed("start"):
391            self.tree.change_scene(Game())
392
393
394# ============================================================================
395# Game
396# ============================================================================
397
398
399class Game(Node3D):
400    input_actions = INPUT_ACTIONS
401
402    def __init__(self, **kwargs):
403        super().__init__(name="Game", **kwargs)
404
405        # Camera
406        self.camera = self.add_child(
407            Camera3D(
408                name="Camera",
409                position=Vec3(0, 35, 0),
410                fov=60,
411            )
412        )
413        self.camera.look_at(Vec3(0, 0, 0), up=Vec3(0, 0, -1))
414
415        self.player = self.add_child(
416            Player(
417                name="Player",
418                position=Vec3(0, 0, AREA_H / 2 - 2),
419            )
420        )
421
422        self.score = 0
423        self.lives = 3
424        self._alien_dir = 1
425        self._alien_speed = 2.0
426        self._alien_shoot_timer = 0.0
427        self._alien_shoot_interval = 1.0
428
429        # HUD
430        self._score_text = self.add_child(Text2D(text="SCORE 00000", position=(10, 10), font_scale=2.0))
431        self._lives_text = self.add_child(Text2D(text="LIVES 3", align="right", font_scale=2.0))
432        self._controls = self.add_child(
433            Text2D(text="A/D  ARROWS  or  MOUSE  MOVE        SPACE  or  CLICK  FIRE", align="centre", font_scale=1.4)
434        )
435
436        # One reusable player per effect. Re-triggering restarts the channel,
437        # which is how the arcade original's single-voice sound behaved anyway.
438        self._sfx = {
439            name: self.add_child(AudioPlayer(name=f"Sfx_{name}", stream=clip, bus="SFX"))
440            for name, clip in _get_sfx().items()
441        }
442
443    def _layout(self, size):
444        w, h = size
445        self._lives_text.position = (w - 10, 10)
446        self._controls.position = (w / 2, h - 30)
447
448    def on_ready(self):
449        self.player.camera = self.camera
450        self._layout(self.tree.screen_size)
451        self.tree.screen_resized.connect(self._layout)
452
453        @self.player.fired.connect
454        def on_fire():
455            self.add_child(
456                Bullet(
457                    direction=-1,
458                    name="PBullet",
459                    position=Vec3(self.player.position.x, 0, self.player.position.z - 1),
460                )
461            )
462            self._sfx["shoot"].play()
463
464        self._spawn_aliens()
465        self._spawn_barriers()
466
467    def on_exit_tree(self):
468        self.tree.screen_resized.disconnect(self._layout)
469
470    def _spawn_aliens(self):
471        col_spacing, row_spacing = 1.5, 1.4
472        start_x = -(COLS - 1) * col_spacing / 2
473        start_z = -AREA_H / 2 + 3
474        for row in range(ROWS):
475            for col in range(COLS):
476                x = start_x + col * col_spacing
477                z = start_z + row * row_spacing
478                self.add_child(
479                    Alien(
480                        alien_type=ROW_TYPES[row],
481                        name=f"Alien_{row}_{col}",
482                        position=Vec3(x, 0, z),
483                    )
484                )
485
486    def _spawn_barriers(self):
487        spacing = AREA_W / 5
488        for i in range(4):
489            bx = -AREA_W / 2 + spacing * (i + 1)
490            self.add_child(
491                Barrier(
492                    name=f"Barrier_{i}",
493                    position=Vec3(bx, 0, AREA_H / 2 - 5),
494                )
495            )
496
497    def on_update(self, dt):
498        tree = self.tree
499        if not tree:
500            return
501        aliens = tree.group("aliens")
502        if not aliens:
503            self._alien_speed = 2.0
504            self._spawn_aliens()
505            return
506
507        # Speed scales inversely with remaining count
508        self._alien_speed = 2.0 + (ROWS * COLS - len(aliens)) * 0.3
509
510        # Move aliens horizontally
511        dx = self._alien_dir * self._alien_speed * dt
512        edge_hit = False
513        for alien in aliens:
514            alien.position.x += dx
515            if alien.position.x > AREA_W / 2 - 1 or alien.position.x < -AREA_W / 2 + 1:
516                edge_hit = True
517
518        if edge_hit:
519            self._alien_dir *= -1
520            for alien in aliens:
521                alien.position.z += 0.8
522            for alien in aliens:
523                if alien.position.z > AREA_H / 2 - 3:
524                    tree.change_scene(GameOver(self.score))
525                    return
526
527        # Alien shooting
528        self._alien_shoot_timer -= dt
529        if self._alien_shoot_timer <= 0 and aliens:
530            self._alien_shoot_timer = self._alien_shoot_interval
531            shooter = random.choice(list(aliens))
532            self.add_child(
533                Bullet(
534                    direction=1,
535                    name="ABullet",
536                    position=Vec3(shooter.position.x, 0, shooter.position.z + 0.5),
537                )
538            )
539
540        # Update HUD
541        self._score_text.text = f"SCORE {self.score:05d}"
542        self._lives_text.text = f"LIVES {self.lives}"
543
544    def on_fixed_update(self, dt):
545        tree = self.tree
546        if not tree:
547            return
548
549        barriers = self.find_all(Barrier, direct=True)
550
551        # Player bullets vs aliens
552        for bullet in list(tree.group("player_bullets")):
553            hits = bullet.get_overlapping(group="aliens")
554            if hits:
555                alien = hits[0]
556                self.score += alien.points
557                alien.destroy()
558                bullet.destroy()
559                self._sfx["explosion"].play()
560                continue
561            for barrier in barriers:
562                if barrier.hit(bullet.position):
563                    bullet.destroy()
564                    break
565
566        # Alien bullets vs player
567        for bullet in list(tree.group("alien_bullets")):
568            hits = bullet.get_overlapping(group="player")
569            if hits:
570                bullet.destroy()
571                self.lives -= 1
572                self._sfx["hit"].play()
573                if self.lives <= 0:
574                    tree.change_scene(GameOver(self.score))
575                    return
576                self.player.position = Vec3(0, 0, AREA_H / 2 - 2)
577                break
578            for barrier in barriers:
579                if barrier.hit(bullet.position):
580                    bullet.destroy()
581                    break
582
583
584# ============================================================================
585# GameOver
586# ============================================================================
587
588
589class GameOver(Node3D):
590    input_actions = INPUT_ACTIONS
591
592    def __init__(self, score=0, **kwargs):
593        super().__init__(name="GameOver", **kwargs)
594        self.score = score
595        self._blink_timer = 0.0
596
597        self.add_child(
598            Camera3D(
599                name="Camera",
600                position=Vec3(0, 35, 0),
601                fov=60,
602            )
603        ).look_at(Vec3(0, 0, 0), up=Vec3(0, 0, -1))
604
605        self._title = self.add_child(Text2D(text="GAME OVER", align="centre", font_scale=4.0, colour=(1.0, 0.2, 0.2)))
606        self._score_text = self.add_child(Text2D(text=f"SCORE  {score:05d}", align="centre", font_scale=2.5))
607        self._prompt = self.add_child(Text2D(text="PRESS ENTER TO CONTINUE", align="centre", font_scale=2.0))
608
609    def _layout(self, size):
610        w, h = size
611        cx = w / 2
612        self._title.position = (cx, h * 0.28)
613        self._score_text.position = (cx, h * 0.46)
614        self._prompt.position = (cx, h * 0.62)
615
616    def on_ready(self):
617        self._layout(self.tree.screen_size)
618        self.tree.screen_resized.connect(self._layout)
619
620    def on_exit_tree(self):
621        self.tree.screen_resized.disconnect(self._layout)
622
623    def on_update(self, dt):
624        self._blink_timer += dt
625        self._prompt.text = "PRESS ENTER TO CONTINUE" if int(self._blink_timer * 2) % 2 == 0 else ""
626        if Input.is_action_just_pressed("start"):
627            self.tree.change_scene(MainMenu())
628
629
630# ============================================================================
631# Main
632# ============================================================================
633
634
635if __name__ == "__main__":
636    App(title="Space Invaders 3D (Vulkan)", width=WIDTH, height=HEIGHT, physics_fps=60).run(MainMenu())