Space Invaders 3D¶
the arcade classic rebuilt with the 3D pipeline.
â–¶ Run in browserTags: 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 instanceScene-tree groups driving the collision queries, with no physics world in sight
screen_to_rayunprojection so a mouse or a touch steers the ship on the play planeSignaldecoupling the ship’s fire event from the game rulestree.change_scenefor the menu, game and game-over flowDeclarative
input_actions, re-registered for every scene the tree swaps inAudioSynthbaking the shoot and explosion effects in-process, with no asset filesText2Dmenus and HUD in screen space, laid out fromtree.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())