Space Invaders 3D¶
the arcade classic built from 3D meshes.
â–¶ Run in browserTags: game 3d collision waves
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. Clear the fleet and a fresh one takes its place; lose three lives, or let the fleet reach you, and it is over.
Every actor is a plain Node3D moved by setting position directly
(bullets and the cannon move themselves, the game marches the fleet), seen from
a Camera3D looking straight down: there is no physics world. Collision is a
sphere test, first_hit(node, group), which compares radii against the
nodes in a scene-tree group; tree.group leaves out a node destroyed earlier
in the frame, so two bullets arriving in the same tick never score the same
alien. The demo also shows:
Procedural meshes (
Mesh.cone,Mesh.box,Mesh.sphere) built once at import and shared by every alien, bullet and barrier block, with oneMaterialper alien kindCamera3D.ray_atandRay.intersect_plane, which turn the pointer into a point on the play plane so a mouse or a finger steers the cannonA class-level
Signal: the cannon callsfired()and the game decides what a shot meansTimerchildren for the cannon’s cooldown and the aliens’ volleysAudioSynthbaking the shoot, explosion and hit sounds when a game starts, with no asset files, played through oneAudioPlayerper soundScene flow with
tree.change_scenebetween the title card, the game and the game-over card, each declaring the sameinput_context;moveis a singleAxis1Daction that keys and a stick both driveAn anchored
LabelHUD on aCanvasLayer, and immediate-mode text on the cards, shrunk byfit_scaleto fit a narrow windowA
TouchControllerin the game, so a phone plays it with two thumbs
Controls: A/D or Left/Right move, Space fires, Enter starts. On a controller or the on-screen pad the stick moves and A fires. Hold the mouse, or a single finger away from the on-screen pad, to slide the cannon toward the pointer and fire. A click or tap starts the game.
Source¶
1# /// simvx
2# tags = ["game", "3d", "collision", "waves"]
3# web = { width = 1024, height = 768 }
4# ///
5"""Space Invaders 3D: the arcade classic built from 3D meshes.
6
7Five rows of aliens march in formation, speed up as their ranks thin, drop a
8step closer every time the formation touches an edge, and shoot back at random.
9Four barriers soak up fire block by block. Clear the fleet and a fresh one
10takes its place; lose three lives, or let the fleet reach you, and it is over.
11
12Every actor is a plain ``Node3D`` moved by setting ``position`` directly
13(bullets and the cannon move themselves, the game marches the fleet), seen from
14a ``Camera3D`` looking straight down: there is no physics world. Collision is a
15sphere test, ``first_hit(node, group)``, which compares radii against the
16nodes in a scene-tree group; ``tree.group`` leaves out a node destroyed earlier
17in the frame, so two bullets arriving in the same tick never score the same
18alien. The demo also shows:
19
20- Procedural meshes (``Mesh.cone``, ``Mesh.box``, ``Mesh.sphere``) built once
21 at import and shared by every alien, bullet and barrier block, with one
22 ``Material`` per alien kind
23- ``Camera3D.ray_at`` and ``Ray.intersect_plane``, which turn the pointer into
24 a point on the play plane so a mouse or a finger steers the cannon
25- A class-level ``Signal``: the cannon calls ``fired()`` and the game decides
26 what a shot means
27- ``Timer`` children for the cannon's cooldown and the aliens' volleys
28- ``AudioSynth`` baking the shoot, explosion and hit sounds when a game starts,
29 with no asset files, played through one ``AudioPlayer`` per sound
30- Scene flow with ``tree.change_scene`` between the title card, the game and
31 the game-over card, each declaring the same ``input_context``; ``move`` is a
32 single ``Axis1D`` action that keys and a stick both drive
33- An anchored ``Label`` HUD on a ``CanvasLayer``, and immediate-mode text on
34 the cards, shrunk by ``fit_scale`` to fit a narrow window
35- A ``TouchController`` in the game, so a phone plays it with two thumbs
36
37Controls: A/D or Left/Right move, Space fires, Enter starts. On a controller or
38the on-screen pad the stick moves and A fires. Hold the mouse, or a single
39finger away from the on-screen pad, to slide the cannon toward the pointer and
40fire. A click or tap starts the game.
41"""
42
43import random
44from typing import NamedTuple
45
46from simvx.core import (
47 ADSR,
48 AudioPlayer,
49 AudioSynth,
50 Axis1D,
51 Camera3D,
52 CanvasLayer,
53 Controller,
54 Input,
55 Key,
56 Linear,
57 Material,
58 Mesh,
59 MeshInstance3D,
60 Node,
61 Node3D,
62 Oscillator,
63 Pointer,
64 Property,
65 Signal,
66 Timer,
67 Vec3,
68 on_input,
69)
70from simvx.core.ui import Anchor, Label, TouchController
71from simvx.graphics import App
72
73INPUT_ACTIONS = {
74 "move": [Axis1D(low=Key.A, high=Key.D), Axis1D(low=Key.LEFT, high=Key.RIGHT), Controller.LEFT_X],
75 "fire": [Key.SPACE, Controller.A],
76 "steer": [Pointer.PRIMARY],
77 "start": [Key.ENTER, Pointer.PRIMARY, Controller.A, Controller.START],
78}
79
80HALF_W, HALF_H = 15.0, 12.0 # the play area spans x in [-15, 15] and z in [-12, 12]
81PLAYER_START = Vec3(0, 0, HALF_H - 2)
82WHITE = (1.0, 1.0, 1.0, 1.0)
83GREY = (0.75, 0.75, 0.75, 1.0)
84
85
86class AlienKind(NamedTuple):
87 name: str
88 mesh: Mesh
89 material: Material
90 points: int
91
92
93SQUID = AlienKind("SQUID", Mesh.cone(0.4, 0.8, segments=8), Material(colour=(1.0, 0.3, 0.3, 1.0)), 30)
94CRAB = AlienKind("CRAB", Mesh.box(0.7), Material(colour=(0.3, 1.0, 0.3, 1.0)), 20)
95OCTO = AlienKind("OCTO", Mesh.sphere(0.4, rings=6, segments=8), Material(colour=(0.3, 0.5, 1.0, 1.0)), 10)
96FLEET_ROWS = [SQUID, CRAB, CRAB, OCTO, OCTO] # back rank first
97FLEET_COLS = 11
98FLEET_SIZE = len(FLEET_ROWS) * FLEET_COLS
99
100BULLET_MESH = Mesh.sphere(0.12, rings=4, segments=4)
101BLOCK_MESH = Mesh.box(0.35)
102BLOCK_MATERIAL = Material(colour=(0.2, 0.8, 0.4, 1.0))
103BARRIER_PATTERN = [
104 " ##### ",
105 " ####### ",
106 "#########",
107 "#########",
108 "### ###",
109 "## ##",
110]
111
112
113def bake_sfx() -> dict:
114 shoot = AudioSynth()
115 blip = ADSR(attack=0.002, decay=0.08, sustain=0.0, release=0.02)
116 shoot.add(Oscillator.square(660.0, duty=0.35), envelope=blip, gain=0.35)
117 explosion = AudioSynth()
118 explosion.add(Oscillator.noise.white(), envelope=Linear(start=1.0, end=0.0), gain=0.45)
119 explosion.add(Oscillator.square(90.0), envelope=Linear(start=0.8, end=0.0), gain=0.25)
120 hit = AudioSynth()
121 hit.add(Oscillator.noise.white(), envelope=Linear(start=1.0, end=0.0), gain=0.4)
122 hit.add(Oscillator.saw(70.0), envelope=Linear(start=1.0, end=0.0), gain=0.3)
123 return {"shoot": shoot.bake(0.12), "explosion": explosion.bake(0.3), "hit": hit.bake(0.6)}
124
125
126def first_hit(node, group: str):
127 """The first node in ``group`` whose sphere overlaps ``node``'s, or None."""
128 for other in node.tree.group(group):
129 if (other.world_position - node.world_position).length() < other.radius + node.radius:
130 return other
131 return None
132
133
134class Alien(Node3D):
135 radius = 0.5
136
137 def __init__(self, kind: AlienKind, **kwargs):
138 super().__init__(**kwargs)
139 self.kind = kind
140
141 def on_ready(self):
142 self.add_to_group("aliens")
143 self.add_child(MeshInstance3D(mesh=self.kind.mesh, material=self.kind.material))
144
145
146class Bullet(Node3D):
147 radius = 0.15
148 direction = Property(-1.0, hint="-1 flies up the screen (the player's), +1 down (an alien's)")
149 speed = Property(25.0)
150
151 def on_ready(self):
152 mine = self.direction < 0
153 self.add_to_group("player_bullets" if mine else "alien_bullets")
154 colour = (1.0, 1.0, 0.3, 1.0) if mine else (1.0, 0.5, 0.2, 1.0)
155 self.add_child(MeshInstance3D(mesh=BULLET_MESH, material=Material(colour=colour)))
156
157 def on_fixed_update(self, dt):
158 self.position.z += self.direction * self.speed * dt
159 if abs(self.position.z) > HALF_H + 2:
160 self.destroy()
161
162
163class Player(Node3D):
164 fired = Signal()
165
166 radius = 0.5
167 speed = Property(15.0, range=(1, 50))
168
169 def __init__(self, camera: Camera3D, **kwargs):
170 super().__init__(**kwargs)
171 self.camera = camera
172
173 def on_ready(self):
174 self.add_to_group("player")
175 self.gun = self.add_child(Timer(duration=0.4))
176 self.add_child(MeshInstance3D(mesh=Mesh.box(0.8), material=Material(colour=(0.3, 1.0, 0.3, 1.0))))
177
178 def on_fixed_update(self, dt):
179 step = self.speed * dt
180 x = self.position.x + Input.value("move") * step
181 steering = Input.pressed("steer")
182 if steering:
183 target = self.camera.ray_at(Input.mouse_position).intersect_plane((0, 0, 0), (0, 1, 0))
184 if target is not None:
185 x += max(-step, min(step, target.x - x))
186 self.position.x = max(-HALF_W + 1, min(HALF_W - 1, x))
187
188 if (Input.pressed("fire") or steering) and self.gun.stopped:
189 self.gun.start()
190 self.fired.emit()
191
192
193class Barrier(Node3D):
194 """A bunker of small blocks; a bullet knocks out the blocks around where it lands."""
195
196 def on_ready(self):
197 spacing = 0.4
198 left = -(len(BARRIER_PATTERN[0]) - 1) * spacing / 2
199 top = -(len(BARRIER_PATTERN) - 1) * spacing / 2
200 for row, line in enumerate(BARRIER_PATTERN):
201 for col, cell in enumerate(line):
202 if cell == "#":
203 position = (left + col * spacing, 0, top + row * spacing)
204 self.add_child(MeshInstance3D(mesh=BLOCK_MESH, material=BLOCK_MATERIAL, position=position))
205
206 def hit(self, point) -> bool:
207 """Knock out the blocks within reach of ``point``; True if there were any."""
208 blocks = [b for b in self.children if not b.destroying and (b.world_position - point).length() < 0.7]
209 for block in blocks:
210 block.destroy()
211 return bool(blocks)
212
213
214class Game(Node3D):
215 input_context = INPUT_ACTIONS
216
217 lives = Property(3, range=(1, 10))
218
219 def on_ready(self):
220 self.score = 0
221 self.lives_left = self.lives
222 self.march = 1.0 # +1 while the fleet marches right, -1 left
223
224 camera = self.add_child(Camera3D(position=(0, 35, 0), fov=60))
225 camera.look_at((0, 0, 0), up=Vec3(0, 0, -1))
226 self.player = self.add_child(Player(camera, name="Player", position=PLAYER_START))
227 self.player.fired.connect(self.player_fire)
228 for i in range(4):
229 self.add_child(Barrier(position=(-HALF_W + (i + 1) * 2 * HALF_W / 5, 0, HALF_H - 5)))
230 self.spawn_fleet()
231 self.add_child(Timer(duration=1.0, one_shot=False, autostart=True)).timeout.connect(self.alien_fire)
232
233 # One player per sound: replaying restarts it, as the arcade's single voice did.
234 self.sfx = {name: self.add_child(AudioPlayer(stream=clip, bus="SFX")) for name, clip in bake_sfx().items()}
235
236 hud = self.add_child(CanvasLayer())
237 self.score_label = hud.add_child(Label(anchor=Anchor.TOP_LEFT, inset=10, font_size=28))
238 self.lives_label = hud.add_child(Label(anchor=Anchor.TOP_RIGHT, inset=10, font_size=28))
239 self.show_hud()
240 self.add_child(TouchController(buttons=(Controller.A,)))
241
242 def show_hud(self):
243 self.score_label.text = f"SCORE {self.score:05d}"
244 self.lives_label.text = f"LIVES {self.lives_left}"
245
246 def spawn_fleet(self):
247 for row, kind in enumerate(FLEET_ROWS):
248 for col in range(FLEET_COLS):
249 position = ((col - (FLEET_COLS - 1) / 2) * 1.5, 0, -HALF_H + 3 + row * 1.4)
250 self.add_child(Alien(kind, position=position))
251
252 def player_fire(self):
253 self.add_child(Bullet(direction=-1, position=self.player.position - Vec3(0, 0, 1)))
254 self.sfx["shoot"].play()
255
256 def alien_fire(self):
257 if aliens := self.tree.group("aliens"):
258 shooter = random.choice(aliens)
259 self.add_child(Bullet(direction=1, position=shooter.position + Vec3(0, 0, 0.5)))
260
261 def on_fixed_update(self, dt):
262 aliens = self.tree.group("aliens")
263 if not aliens:
264 self.spawn_fleet()
265 return
266
267 # The fleet speeds up as it thins, and steps down each time it touches an edge.
268 speed = 2.0 + (FLEET_SIZE - len(aliens)) * 0.3
269 for alien in aliens:
270 alien.position.x += self.march * speed * dt
271 if any(abs(alien.position.x) > HALF_W - 1 for alien in aliens):
272 self.march = -self.march
273 for alien in aliens:
274 alien.position.z += 0.8
275 if any(alien.position.z > HALF_H - 3 for alien in aliens):
276 self.tree.change_scene(game_over(self.score))
277 return
278
279 barriers = self.find_all(Barrier, direct=True)
280 for bullet in self.tree.group("player_bullets"):
281 if alien := first_hit(bullet, "aliens"):
282 self.score += alien.kind.points
283 self.show_hud()
284 alien.destroy()
285 bullet.destroy()
286 self.sfx["explosion"].play()
287 elif any(barrier.hit(bullet.world_position) for barrier in barriers):
288 bullet.destroy()
289
290 for bullet in self.tree.group("alien_bullets"):
291 if first_hit(bullet, "player"):
292 bullet.destroy()
293 self.lose_life()
294 return
295 if any(barrier.hit(bullet.world_position) for barrier in barriers):
296 bullet.destroy()
297
298 def lose_life(self):
299 self.lives_left -= 1
300 self.show_hud()
301 self.sfx["hit"].play()
302 if self.lives_left == 0:
303 self.tree.change_scene(game_over(self.score))
304 else:
305 self.player.position = PLAYER_START
306
307
308class Card(Node):
309 """A full-screen text card, the title and the game over: a press moves on to ``then()``."""
310
311 dynamic = True # the prompt blinks
312 input_context = INPUT_ACTIONS
313
314 def __init__(self, lines, prompt: str, then, **kwargs):
315 super().__init__(**kwargs)
316 self.lines = lines # (text, height fraction, scale, colour)
317 self.prompt = prompt
318 self.then = then
319
320 @on_input("start")
321 def start(self, event):
322 self.tree.change_scene(self.then())
323
324 def on_draw(self, renderer):
325 width, height = self.viewport_size
326 lines = list(self.lines)
327 if int(self.tree.now * 2) % 2 == 0:
328 lines.append((self.prompt, 0.62, 2, WHITE))
329 for text, y, scale, colour in lines:
330 fitted = renderer.fit_scale(text, width * 0.9, base_scale=scale)
331 renderer.text(text, (width / 2, height * y), fill=colour, scale=fitted, align="centre")
332
333
334def title_screen() -> Card:
335 legend = [
336 (f"{kind.name} = {kind.points} PTS", 0.3 + 0.07 * i, 2, kind.material.colour)
337 for i, kind in enumerate((SQUID, CRAB, OCTO))
338 ]
339 lines = [("SPACE INVADERS 3D", 0.12, 4, WHITE), *legend, (CONTROLS_HELP, 0.82, 1.4, GREY)]
340 return Card(lines, "PRESS ENTER OR TAP TO START", then=Game, name="TitleScreen")
341
342
343def game_over(score: int) -> Card:
344 lines = [("GAME OVER", 0.28, 4, (1.0, 0.2, 0.2, 1.0)), (f"SCORE {score:05d}", 0.46, 2.5, WHITE)]
345 return Card(lines, "PRESS ENTER OR TAP TO CONTINUE", then=title_screen, name="GameOver")
346
347
348# Example help overlay: not part of the technique shown.
349CONTROLS_HELP = (
350 "A/D OR ARROWS MOVE SPACE FIRES\nHOLD THE MOUSE OR A FINGER TO STEER AND FIRE\nPAD: STICK MOVES, A FIRES"
351)
352
353
354if __name__ == "__main__":
355 App(title="Space Invaders 3D", width=1024, height=768).run(title_screen())