Space Invaders 3D¶
Space Invaders 3D — Classic arcade game with 3D meshes (Vulkan Backend). Same gameplay as the SDL3 version, rendered with Vulkan.
Run: uv run python packages/graphics/examples/spaceinvaders3d.py
Controls: A/D or Left/Right - Move Space - Fire Enter - Start / Restart
Source Code¶
1"""
2Space Invaders 3D — Classic arcade game with 3D meshes (Vulkan Backend).
3Same gameplay as the SDL3 version, rendered with Vulkan.
4
5Run: uv run python packages/graphics/examples/spaceinvaders3d.py
6
7Controls:
8 A/D or Left/Right - Move
9 Space - Fire
10 Enter - Start / Restart
11"""
12
13
14import random
15
16from simvx.core import (
17 Camera3D,
18 CharacterBody3D,
19 Input,
20 InputMap,
21 Key,
22 Material,
23 Mesh,
24 MeshInstance3D,
25 Node3D,
26 Property,
27 Signal,
28 Text2D,
29 Vec3,
30)
31from simvx.graphics import App
32
33WIDTH, HEIGHT = 1024, 768
34AREA_W = 30.0 # X extent (-15 to +15)
35AREA_H = 24.0 # Z extent (-12 to +12)
36
37# Alien type definitions: (mesh_factory, colour, points)
38ALIEN_TYPES = [
39 ("cone", (1.0, 0.3, 0.3, 1.0), 30), # Squid — red cone
40 ("cube", (0.3, 1.0, 0.3, 1.0), 20), # Crab — green cube
41 ("sphere", (0.3, 0.5, 1.0, 1.0), 10), # Octopus — blue sphere
42]
43
44# Shared meshes (created on first use)
45_meshes: dict[str, Mesh] = {}
46
47
48def _get_mesh(name: str) -> Mesh:
49 if name not in _meshes:
50 if name == "cone":
51 _meshes[name] = Mesh.cone(0.4, 0.8, segments=8)
52 elif name == "cube":
53 _meshes[name] = Mesh.cube(0.7)
54 elif name == "sphere":
55 _meshes[name] = Mesh.sphere(0.4, rings=6, segments=8)
56 elif name == "bullet":
57 _meshes[name] = Mesh.sphere(0.12, rings=4, segments=4)
58 elif name == "player":
59 _meshes[name] = Mesh.cube(0.8)
60 elif name == "barrier_block":
61 _meshes[name] = Mesh.cube(0.35)
62 return _meshes[name]
63
64
65# ============================================================================
66# Alien
67# ============================================================================
68
69
70class Alien(CharacterBody3D):
71 def __init__(self, alien_type=0, **kwargs):
72 super().__init__(collision=0.5, **kwargs)
73 self.add_to_group("aliens")
74 mesh_name, colour, points = ALIEN_TYPES[min(alien_type, 2)]
75 self.points = points
76 self.alien_type = alien_type
77 self.add_child(
78 MeshInstance3D(
79 name="Mesh",
80 mesh=_get_mesh(mesh_name),
81 material=Material(colour=colour),
82 )
83 )
84
85
86# ============================================================================
87# Player
88# ============================================================================
89
90
91class Player(CharacterBody3D):
92 speed = Property(15.0)
93
94 def __init__(self, **kwargs):
95 super().__init__(collision=0.5, **kwargs)
96 self.add_to_group("player")
97 self._cooldown = 0.0
98 self.fired = Signal()
99 self.add_child(
100 MeshInstance3D(
101 name="Mesh",
102 mesh=_get_mesh("player"),
103 material=Material(colour=(0.3, 1.0, 0.3, 1.0)),
104 )
105 )
106
107 def physics_process(self, dt):
108 if Input.is_action_pressed("move_left"):
109 self.position.x -= self.speed * dt
110 if Input.is_action_pressed("move_right"):
111 self.position.x += self.speed * dt
112 self.position.x = max(-AREA_W / 2 + 1, min(AREA_W / 2 - 1, self.position.x))
113
114 self._cooldown -= dt
115 if Input.is_action_pressed("fire") and self._cooldown <= 0:
116 self._cooldown = 0.4
117 self.fired()
118
119
120# ============================================================================
121# Bullet
122# ============================================================================
123
124
125class Bullet(CharacterBody3D):
126 def __init__(self, direction=-1, **kwargs):
127 super().__init__(collision=0.15, **kwargs)
128 self.direction = direction
129 self.speed = 25.0
130 if direction < 0:
131 self.add_to_group("player_bullets")
132 colour = (1.0, 1.0, 0.3, 1.0)
133 else:
134 self.add_to_group("alien_bullets")
135 colour = (1.0, 0.5, 0.2, 1.0)
136 self.add_child(
137 MeshInstance3D(
138 name="Mesh",
139 mesh=_get_mesh("bullet"),
140 material=Material(colour=colour),
141 )
142 )
143
144 def physics_process(self, dt):
145 self.position.z += self.direction * self.speed * dt
146 if abs(self.position.z) > AREA_H / 2 + 2:
147 self.destroy()
148
149
150# ============================================================================
151# Barrier
152# ============================================================================
153
154BARRIER_PATTERN = [
155 " ##### ",
156 " ####### ",
157 "#########",
158 "#########",
159 "### ###",
160 "## ##",
161]
162
163
164class Barrier(Node3D):
165 def __init__(self, **kwargs):
166 super().__init__(**kwargs)
167 self._blocks: list[MeshInstance3D] = []
168
169 def ready(self):
170 mesh = _get_mesh("barrier_block")
171 mat = Material(colour=(0.2, 0.8, 0.4, 1.0))
172 bw = len(BARRIER_PATTERN[0])
173 bh = len(BARRIER_PATTERN)
174 spacing = 0.4
175 ox = -(bw - 1) * spacing / 2
176 oz = -(bh - 1) * spacing / 2
177 for row_i, row_str in enumerate(BARRIER_PATTERN):
178 for col_i, ch in enumerate(row_str):
179 if ch == "#":
180 block = self.add_child(
181 MeshInstance3D(
182 name=f"B_{row_i}_{col_i}",
183 mesh=mesh,
184 material=mat,
185 position=Vec3(ox + col_i * spacing, 0, oz + row_i * spacing),
186 )
187 )
188 self._blocks.append(block)
189
190 def hit(self, pos):
191 """Remove barrier blocks near the hit position."""
192 hit_any = False
193 for block in list(self._blocks):
194 bpos = self.position + block.position
195 dx = pos.x - bpos.x
196 dz = pos.z - bpos.z
197 if dx * dx + dz * dz < 0.5:
198 block.destroy()
199 self._blocks.remove(block)
200 hit_any = True
201 return hit_any
202
203
204# ============================================================================
205# MainMenu
206# ============================================================================
207
208
209class MainMenu(Node3D):
210 def __init__(self, **kwargs):
211 super().__init__(name="MainMenu", **kwargs)
212 self._blink_timer = 0.0
213
214 self.add_child(
215 Camera3D(
216 name="Camera",
217 position=Vec3(0, 35, 0),
218 fov=60,
219 )
220 ).look_at(Vec3(0, 0, 0), up=Vec3(0, 0, -1))
221
222 # Title
223 self.add_child(Text2D(text="SPACE INVADERS 3D", x=280, y=80, font_scale=4.0))
224
225 # Legend
226 self.add_child(Text2D(text="SQUID = 30 PTS", x=350, y=240, font_scale=2.0, font_colour=(1.0, 0.31, 0.31)))
227 self.add_child(Text2D(text="CRAB = 20 PTS", x=350, y=290, font_scale=2.0, font_colour=(0.31, 1.0, 0.31)))
228 self.add_child(Text2D(text="OCTO = 10 PTS", x=350, y=340, font_scale=2.0, font_colour=(0.31, 0.51, 1.0)))
229
230 # Blink prompt
231 self._prompt = self.add_child(
232 Text2D(
233 text="PRESS ENTER TO START",
234 x=320,
235 y=500,
236 font_scale=2.0,
237 )
238 )
239
240 def ready(self):
241 InputMap.add_action("move_left", [Key.A, Key.LEFT])
242 InputMap.add_action("move_right", [Key.D, Key.RIGHT])
243 InputMap.add_action("fire", [Key.SPACE])
244 InputMap.add_action("start", [Key.ENTER])
245
246 def process(self, dt):
247 self._blink_timer += dt
248 self._prompt.text = "PRESS ENTER TO START" if int(self._blink_timer * 2) % 2 == 0 else ""
249 if Input.is_action_just_pressed("start"):
250 self.tree.change_scene(Game())
251
252
253# ============================================================================
254# Game
255# ============================================================================
256
257
258class Game(Node3D):
259 def __init__(self, **kwargs):
260 super().__init__(name="Game", **kwargs)
261
262 # Camera
263 self.camera = self.add_child(
264 Camera3D(
265 name="Camera",
266 position=Vec3(0, 35, 0),
267 fov=60,
268 )
269 )
270 self.camera.look_at(Vec3(0, 0, 0), up=Vec3(0, 0, -1))
271
272 self.player = self.add_child(
273 Player(
274 name="Player",
275 position=Vec3(0, 0, AREA_H / 2 - 2),
276 )
277 )
278
279 self.score = 0
280 self.lives = 3
281 self._alien_dir = 1
282 self._alien_speed = 2.0
283 self._alien_shoot_timer = 0.0
284 self._alien_shoot_interval = 1.0
285
286 # HUD
287 self._score_text = self.add_child(
288 Text2D(
289 text="SCORE 00000",
290 x=10,
291 y=10,
292 font_scale=2.0,
293 )
294 )
295 self._lives_text = self.add_child(
296 Text2D(
297 text="LIVES 3",
298 x=880,
299 y=10,
300 font_scale=2.0,
301 )
302 )
303
304 def ready(self):
305 @self.player.fired.connect
306 def on_fire():
307 self.add_child(
308 Bullet(
309 direction=-1,
310 name="PBullet",
311 position=Vec3(self.player.position.x, 0, self.player.position.z - 1),
312 )
313 )
314
315 self._spawn_aliens()
316 self._spawn_barriers()
317
318 def _spawn_aliens(self):
319 row_types = [0, 1, 1, 2, 2]
320 start_x = -5 * 1.5
321 start_z = -AREA_H / 2 + 3
322 for row in range(5):
323 for col in range(11):
324 x = start_x + col * 1.5
325 z = start_z + row * 1.4
326 self.add_child(
327 Alien(
328 alien_type=row_types[row],
329 name=f"Alien_{row}_{col}",
330 position=Vec3(x, 0, z),
331 )
332 )
333
334 def _spawn_barriers(self):
335 spacing = AREA_W / 5
336 for i in range(4):
337 bx = -AREA_W / 2 + spacing * (i + 1)
338 self.add_child(
339 Barrier(
340 name=f"Barrier_{i}",
341 position=Vec3(bx, 0, AREA_H / 2 - 5),
342 )
343 )
344
345 def process(self, dt):
346 tree = self.tree
347 if not tree:
348 return
349 aliens = tree.get_group("aliens")
350 if not aliens:
351 self._alien_speed = 2.0
352 self._spawn_aliens()
353 return
354
355 # Speed scales inversely with remaining count
356 self._alien_speed = 2.0 + (55 - len(aliens)) * 0.3
357
358 # Move aliens horizontally
359 dx = self._alien_dir * self._alien_speed * dt
360 edge_hit = False
361 for alien in aliens:
362 alien.position.x += dx
363 if alien.position.x > AREA_W / 2 - 1 or alien.position.x < -AREA_W / 2 + 1:
364 edge_hit = True
365
366 if edge_hit:
367 self._alien_dir *= -1
368 for alien in aliens:
369 alien.position.z += 0.8
370 for alien in aliens:
371 if alien.position.z > AREA_H / 2 - 3:
372 tree.change_scene(GameOver(self.score))
373 return
374
375 # Alien shooting
376 self._alien_shoot_timer -= dt
377 if self._alien_shoot_timer <= 0 and aliens:
378 self._alien_shoot_timer = self._alien_shoot_interval
379 shooter = random.choice(list(aliens))
380 self.add_child(
381 Bullet(
382 direction=1,
383 name="ABullet",
384 position=Vec3(shooter.position.x, 0, shooter.position.z + 0.5),
385 )
386 )
387
388 # Update HUD
389 self._score_text.text = f"SCORE {self.score:05d}"
390 self._lives_text.text = f"LIVES {self.lives}"
391
392 def physics_process(self, dt):
393 tree = self.tree
394 if not tree:
395 return
396
397 barriers = self.find_all(Barrier, recursive=False)
398
399 # Player bullets vs aliens
400 for bullet in list(tree.get_group("player_bullets")):
401 hits = bullet.get_overlapping(group="aliens")
402 if hits:
403 alien = hits[0]
404 self.score += alien.points
405 alien.destroy()
406 bullet.destroy()
407 continue
408 for barrier in barriers:
409 if barrier.hit(bullet.position):
410 bullet.destroy()
411 break
412
413 # Alien bullets vs player
414 for bullet in list(tree.get_group("alien_bullets")):
415 hits = bullet.get_overlapping(group="player")
416 if hits:
417 bullet.destroy()
418 self.lives -= 1
419 if self.lives <= 0:
420 tree.change_scene(GameOver(self.score))
421 return
422 self.player.position = Vec3(0, 0, AREA_H / 2 - 2)
423 break
424 for barrier in barriers:
425 if barrier.hit(bullet.position):
426 bullet.destroy()
427 break
428
429
430# ============================================================================
431# GameOver
432# ============================================================================
433
434
435class GameOver(Node3D):
436 def __init__(self, score=0, **kwargs):
437 super().__init__(name="GameOver", **kwargs)
438 self.score = score
439 self._blink_timer = 0.0
440
441 self.add_child(
442 Camera3D(
443 name="Camera",
444 position=Vec3(0, 35, 0),
445 fov=60,
446 )
447 ).look_at(Vec3(0, 0, 0), up=Vec3(0, 0, -1))
448
449 self.add_child(
450 Text2D(
451 text="GAME OVER",
452 x=350,
453 y=200,
454 font_scale=4.0,
455 font_colour=(1.0, 0.2, 0.2),
456 )
457 )
458 self.add_child(
459 Text2D(
460 text=f"SCORE {score:05d}",
461 x=380,
462 y=340,
463 font_scale=2.5,
464 )
465 )
466 self._prompt = self.add_child(
467 Text2D(
468 text="PRESS ENTER TO CONTINUE",
469 x=300,
470 y=460,
471 font_scale=2.0,
472 )
473 )
474
475 def process(self, dt):
476 self._blink_timer += dt
477 self._prompt.text = "PRESS ENTER TO CONTINUE" if int(self._blink_timer * 2) % 2 == 0 else ""
478 if Input.is_action_just_pressed("start"):
479 self.tree.change_scene(MainMenu())
480
481
482# ============================================================================
483# Main
484# ============================================================================
485
486
487if __name__ == "__main__":
488 App(title="Space Invaders 3D (Vulkan)", width=WIDTH, height=HEIGHT, physics_fps=60).run(MainMenu())