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())