Space Invaders 3D¶

the arcade classic built from 3D meshes.

â–¶ Run in browser

Tags: 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 one Material per alien kind

  • Camera3D.ray_at and Ray.intersect_plane, which turn the pointer into a point on the play plane so a mouse or a finger steers the cannon

  • A class-level Signal: the cannon calls fired() and the game decides what a shot means

  • Timer children for the cannon’s cooldown and the aliens’ volleys

  • AudioSynth baking the shoot, explosion and hit sounds when a game starts, with no asset files, played through one AudioPlayer per sound

  • Scene flow with tree.change_scene between the title card, the game and the game-over card, each declaring the same input_context; move is a single Axis1D action that keys and a stick both drive

  • An anchored Label HUD on a CanvasLayer, and immediate-mode text on the cards, shrunk by fit_scale to fit a narrow window

  • A TouchController in 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())