Asteroids 3D

Top-down arcade game with 3D objects.

▶ Run in browser

Tags: game 3d collision shooting

Same gameplay as Asteroids 2D, rendered with the 3D pipeline.

Controls: W/Up - Thrust A/D or Left/Right - Turn Space - Fire Hold click / tap - Steer toward the pointer while thrusting and firing

Source

  1"""Asteroids 3D: Top-down arcade game with 3D objects.
  2
  3# /// simvx
  4# tags = ["game", "3d", "collision", "shooting"]
  5# web = { width = 1024, height = 768, root = "AsteroidsGame" }
  6# ///
  7
  8Same gameplay as Asteroids 2D, rendered with the 3D pipeline.
  9
 10Controls:
 11    W/Up - Thrust
 12    A/D or Left/Right - Turn
 13    Space - Fire
 14    Hold click / tap - Steer toward the pointer while thrusting and firing
 15"""
 16
 17
 18import math
 19import random
 20
 21from simvx.core import (
 22    Camera3D,
 23    Input,
 24    InputMap,
 25    Key,
 26    Material,
 27    Mesh,
 28    MeshInstance3D,
 29    MouseButton,
 30    Node3D,
 31    Property,
 32    Quat,
 33    Signal,
 34    Text2D,
 35    Timer,
 36    Vec3,
 37)
 38from simvx.graphics import App
 39
 40# Play area: XZ plane
 41AREA_W = 40.0  # X extent (-20 to +20)
 42AREA_H = 30.0  # Z extent (-15 to +15)
 43
 44
 45class Body3D(Node3D):
 46    """A manually integrated 3D actor with a collision radius + group overlap query.
 47
 48    Asteroids is a wrap-around arcade game with NO physics simulation: every actor
 49    integrates ``position += velocity * dt`` itself and collisions are plain
 50    sphere-vs-sphere radius tests on the XZ plane. This is deliberately NOT a
 51    ``CharacterBody3D`` (the seam character runs a stepped collide-and-slide world);
 52    it is a light ``Node3D`` carrying a radius and a group-scoped overlap poll.
 53    """
 54
 55    def __init__(self, radius: float = 0.5, **kwargs):
 56        super().__init__(**kwargs)
 57        self.radius = float(radius)
 58        self.velocity = Vec3()
 59
 60    def get_overlapping(self, group: str) -> list[Body3D]:
 61        """Other ``Body3D`` nodes in ``group`` whose radius overlaps ours."""
 62        if not self.tree:
 63            return []
 64        hits: list[Body3D] = []
 65        for b in self.tree.get_group(group):
 66            if b is self or not isinstance(b, Body3D):
 67                continue
 68            d = b.world_position - self.world_position
 69            rr = self.radius + b.radius
 70            if float(d.x) ** 2 + float(d.y) ** 2 + float(d.z) ** 2 <= rr * rr:
 71                hits.append(b)
 72        return hits
 73
 74
 75def _wrap_xz(pos, margin=1.0):
 76    """Wrap a position on the XZ plane, keeping Y=0."""
 77    hx = AREA_W / 2 + margin
 78    hz = AREA_H / 2 + margin
 79    return Vec3(
 80        (pos.x + hx) % (2 * hx) - hx,
 81        0,
 82        (pos.z + hz) % (2 * hz) - hz,
 83    )
 84
 85
 86# ============================================================================
 87# Ship
 88# ============================================================================
 89
 90
 91class Ship(Body3D):
 92    turn_speed = Property(200.0, range=(50, 400), hint="Degrees per second")
 93    thrust_power = Property(20.0, range=(5, 50))
 94    max_speed = Property(25.0, range=(5, 60))
 95    drag = Property(0.98, range=(0.9, 1.0))
 96
 97    def __init__(self, **kwargs):
 98        super().__init__(radius=0.8, **kwargs)
 99        self.fired = Signal()
100        self.died = Signal()
101        self._thrusting = False
102        self._invincible = 0.0
103        self._visible = True
104
105        self.fire_timer = self.add_child(Timer(0.15, name="FireTimer"))
106
107        # Ship body: cone
108        self._mesh = self.add_child(
109            MeshInstance3D(
110                name="Body",
111                mesh=Mesh.cone(0.5, 1.4, segments=8),
112                material=Material(colour=(0.7, 0.85, 1.0, 1.0)),
113            )
114        )
115        self._mesh.rotation = Quat.from_euler(math.radians(-90), 0, 0)
116
117        # Exhaust flame
118        self._thrust_mesh = self.add_child(
119            MeshInstance3D(
120                name="Thrust",
121                mesh=Mesh.cone(0.25, 0.7, segments=6),
122                material=Material(colour=(1.0, 0.5, 0.1, 1.0)),
123                position=Vec3(0, 0, 0.9),
124            )
125        )
126        self._thrust_mesh.rotation = Quat.from_euler(math.radians(90), 0, 0)
127
128    def on_fixed_update(self, dt: float):
129        step = math.radians(self.turn_speed) * dt
130        if Input.is_action_pressed("turn_left"):
131            self.rotate_y(step)
132        if Input.is_action_pressed("turn_right"):
133            self.rotate_y(-step)
134
135        # Pointer controls: steer toward the cursor and thrust/fire while the left
136        # button is held (web touch arrives as MouseButton.LEFT). The overhead
137        # camera maps screen +x -> world +X and screen +y -> world +Z, so the
138        # cursor offset from screen centre is the desired heading on the XZ plane.
139        pointer_held = Input.is_mouse_button_pressed(MouseButton.LEFT)
140        if pointer_held and self.app is not None:
141            m = Input.mouse_position
142            tx, tz = float(m.x) - self.app.width / 2, float(m.y) - self.app.height / 2
143            if tx * tx + tz * tz > 400:  # ignore tiny offsets near screen centre
144                target = math.atan2(-tx, -tz)
145                fwd = self.forward
146                current = math.atan2(-float(fwd.x), -float(fwd.z))
147                diff = (target - current + math.pi) % math.tau - math.pi
148                self.rotate_y(max(-step, min(step, diff)))
149
150        self._thrusting = Input.is_action_pressed("thrust") or pointer_held
151        if self._thrusting:
152            fwd = Vec3(self.forward.x, 0, self.forward.z).normalized()
153            self.velocity += fwd * (self.thrust_power * dt)
154            speed = self.velocity.length()
155            if speed > self.max_speed:
156                self.velocity = self.velocity.normalized() * self.max_speed
157
158        self.velocity = Vec3(self.velocity.x * self.drag, 0, self.velocity.z * self.drag)
159        self.position += self.velocity * dt
160        self.position = _wrap_xz(self.position)
161
162        # Show/hide thrust flame
163        self._thrust_mesh.scale = Vec3(1) if self._thrusting else Vec3(0)
164
165        # Shooting
166        if (Input.is_action_pressed("fire") or pointer_held) and self.fire_timer.stopped:
167            self.fire_timer.start()
168            self.fired()
169
170        # Invincibility blink
171        if self._invincible > 0:
172            self._invincible -= dt
173            self._visible = int(self._invincible * 10) % 2 == 0
174        else:
175            self._visible = True
176        self._mesh.scale = Vec3(1 if self._visible else 0)
177
178    def respawn(self):
179        self.position = Vec3()
180        self.velocity = Vec3()
181        self.rotation = Quat()
182        self._invincible = 2.0
183
184    @property
185    def is_invincible(self):
186        return self._invincible > 0
187
188
189# ============================================================================
190# Bullet
191# ============================================================================
192
193
194class Bullet(Body3D):
195    speed = Property(35.0)
196
197    def __init__(self, direction: Vec3 = None, **kwargs):
198        super().__init__(radius=0.2, **kwargs)
199        self.add_to_group("bullets")
200        if direction:
201            d = Vec3(direction.x, 0, direction.z).normalized()
202            self.velocity = d * self.speed
203
204        t = self.add_child(Timer(1.5, name="Lifetime"))
205        t.timeout.connect(self.destroy)
206        t.start()
207
208        self.add_child(
209            MeshInstance3D(
210                name="Mesh",
211                mesh=Mesh.sphere(0.15, rings=4, segments=4),
212                material=Material(colour=(1.0, 1.0, 0.3, 1.0)),
213            )
214        )
215
216    def on_fixed_update(self, dt: float):
217        self.position += self.velocity * dt
218        self.position = _wrap_xz(self.position)
219
220
221# ============================================================================
222# Asteroid
223# ============================================================================
224
225SIZES = {"large": 2.5, "medium": 1.3, "small": 0.6}
226SCORES = {"large": 20, "medium": 50, "small": 100}
227
228_asteroid_meshes: dict[str, Mesh] = {}
229
230
231def _get_asteroid_mesh(size_class: str) -> Mesh:
232    if size_class not in _asteroid_meshes:
233        _asteroid_meshes[size_class] = Mesh.sphere(SIZES[size_class], rings=6, segments=8)
234    return _asteroid_meshes[size_class]
235
236
237class Asteroid(Body3D):
238    size_class = Property("large", enum=["large", "medium", "small"])
239
240    def __init__(self, size_class="large", **kwargs):
241        radius = SIZES[size_class]
242        super().__init__(radius=radius, **kwargs)
243        self.size_class = size_class
244        self.add_to_group("asteroids")
245
246        self._spin_axis = Vec3(random.uniform(-1, 1), random.uniform(-1, 1), random.uniform(-1, 1)).normalized()
247        self._spin_speed = math.radians(random.uniform(30, 90))
248
249        angle = random.uniform(0, math.tau)
250        speed = random.uniform(2, 8)
251        self.velocity = Vec3(math.cos(angle) * speed, 0, math.sin(angle) * speed)
252
253        colours = {"large": (0.6, 0.5, 0.4, 1.0), "medium": (0.7, 0.6, 0.4, 1.0), "small": (0.8, 0.7, 0.5, 1.0)}
254        self.add_child(
255            MeshInstance3D(
256                name="Mesh",
257                mesh=_get_asteroid_mesh(size_class),
258                material=Material(colour=colours[size_class]),
259            )
260        )
261
262    def on_fixed_update(self, dt: float):
263        self.position += self.velocity * dt
264        self.position = _wrap_xz(self.position, margin=SIZES[self.size_class])
265        self.rotate(self._spin_axis, self._spin_speed * dt)
266
267    def split(self) -> list[Asteroid]:
268        next_size = {"large": "medium", "medium": "small"}.get(self.size_class)
269        if not next_size:
270            return []
271        return [
272            Asteroid(name="Asteroid", size_class=next_size, position=Vec3(self.position.x, 0, self.position.z))
273            for _ in range(2)
274        ]
275
276
277# ============================================================================
278# Game Scene
279# ============================================================================
280
281
282class AsteroidsGame(Node3D):
283    start_asteroids = Property(4, range=(1, 12))
284    lives = Property(3, range=(1, 10))
285
286    def __init__(self, **kwargs):
287        super().__init__(name="AsteroidsGame", **kwargs)
288
289        # Fixed overhead camera
290        self.camera = self.add_child(
291            Camera3D(
292                name="Camera",
293                position=Vec3(0, 35, 0),
294                fov=60,
295            )
296        )
297        self.camera.look_at(Vec3(0, 0, 0), up=Vec3(0, 0, -1))
298
299        self.ship = self.add_child(Ship(name="Ship"))
300        self._score = 0
301        self._lives = self.lives
302        self._wave = 0
303        self._game_over = False
304
305        # HUD
306        self._score_text = self.add_child(
307            Text2D(
308                text="SCORE 0  LIVES 3  WAVE 1",
309                position=(10, 10), font_scale=2.0,
310            )
311        )
312        # Centred over the play area; anchored to the live window size each frame.
313        self._status_text = self.add_child(
314            Text2D(
315                text="",
316                align="centre", font_scale=3.0,
317            )
318        )
319        # Controls hint pinned to the bottom of the window (repositioned per frame).
320        self._controls_text = self.add_child(
321            Text2D(
322                text="W/UP THRUST    A/D TURN    SPACE FIRE    HOLD CLICK/TAP TO FLY + FIRE",
323                align="centre", font_scale=1.2, colour=(0.75, 0.75, 0.75, 1.0),
324            )
325        )
326
327    def on_ready(self):
328        InputMap.add_action("thrust", [Key.W, Key.UP])
329        InputMap.add_action("turn_left", [Key.A, Key.LEFT])
330        InputMap.add_action("turn_right", [Key.D, Key.RIGHT])
331        InputMap.add_action("fire", [Key.SPACE])
332
333        @self.ship.fired.connect
334        def on_fire():
335            fwd = self.ship.forward
336            spawn = self.ship.position + fwd * 1.0
337            self.add_child(
338                Bullet(
339                    name="Bullet",
340                    position=Vec3(spawn.x, 0, spawn.z),
341                    direction=fwd,
342                )
343            )
344
345        @self.ship.died.connect
346        def on_died():
347            self._lives -= 1
348            if self._lives <= 0:
349                self._game_over = True
350                self._status_text.text = "GAME OVER\nPRESS FIRE OR TAP TO RESTART"
351            else:
352                self.ship.respawn()
353
354        self._spawn_wave()
355
356    def _spawn_wave(self):
357        self._wave += 1
358        for i in range(self.start_asteroids + self._wave - 1):
359            edge = random.choice(["left", "right", "top", "bottom"])
360            if edge == "left":
361                x, z = -AREA_W / 2, random.uniform(-AREA_H / 2, AREA_H / 2)
362            elif edge == "right":
363                x, z = AREA_W / 2, random.uniform(-AREA_H / 2, AREA_H / 2)
364            elif edge == "top":
365                x, z = random.uniform(-AREA_W / 2, AREA_W / 2), -AREA_H / 2
366            else:
367                x, z = random.uniform(-AREA_W / 2, AREA_W / 2), AREA_H / 2
368            self.add_child(Asteroid(name=f"Asteroid{i}", position=Vec3(x, 0, z)))
369
370    def on_fixed_update(self, dt: float):
371        if self._game_over:
372            return
373
374        # Bullet-asteroid collisions
375        for bullet in self.tree.get_group("bullets"):
376            for asteroid in bullet.get_overlapping(group="asteroids"):
377                self._score += SCORES[asteroid.size_class]
378                for piece in asteroid.split():
379                    self.add_child(piece)
380                asteroid.destroy()
381                bullet.destroy()
382                break
383
384        # Ship-asteroid collisions
385        if not self.ship.is_invincible:
386            if self.ship.get_overlapping(group="asteroids"):
387                self.ship.died()
388
389        # Next wave
390        if not self.tree.get_group("asteroids") and not self._game_over:
391            self._spawn_wave()
392
393    def _restart(self):
394        for asteroid in list(self.tree.get_group("asteroids")):
395            asteroid.destroy()
396        for bullet in list(self.tree.get_group("bullets")):
397            bullet.destroy()
398        self._score = 0
399        self._lives = self.lives
400        self._wave = 0
401        self._game_over = False
402        self._status_text.text = ""
403        self.ship.respawn()
404        self._spawn_wave()
405
406    def on_update(self, dt: float):
407        self._score_text.text = f"SCORE {self._score}  LIVES {self._lives}  WAVE {self._wave}"
408
409        # Keep the centred status + bottom controls hint pinned to the live window.
410        if self.app is not None:
411            self._status_text.position = (self.app.width / 2, self.app.height / 2)
412            self._controls_text.position = (self.app.width / 2, self.app.height - 28)
413
414        if self._game_over and (
415            Input.is_action_just_pressed("fire") or Input.is_mouse_button_just_pressed(MouseButton.LEFT)
416        ):
417            self._restart()
418
419
420# ============================================================================
421# Main
422# ============================================================================
423
424
425if __name__ == "__main__":
426    App(title="Asteroids 3D (Vulkan)", width=1024, height=768, physics_fps=60).run(AsteroidsGame())