Asteroids 2DΒΆ

The classic arcade shooter in one small file.

β–Ά Run in browser

Tags: game collision wrap shooting

Thrust, turn and shoot through waves of splitting asteroids on a wrap-around playfield, with a title screen, a HUD, lives and a game-over screen: a whole arcade game loop with no physics engine.

Every actor is a plain Node2D that integrates its own velocity and calls wrap_screen to come back on the far edge. Collision is a circle test: Body2D.overlapping(group) compares radii against the nodes in a scene-tree group, which leaves out a node destroyed earlier in the frame. The demo also shows:

  • Immediate-mode drawing from on_draw: polygons, circles and centred text anchored on a point with align="centre", shrunk by fit_scale to fit a narrow window

  • Scene flow with tree.change_scene between title, game and game over

  • Class-level Signals: the ship calls fired() and died() and the game decides what a shot and a death mean

  • Timer children for the fire cooldown, the bullet lifetime and the respawn shield

  • Property descriptors for the ship’s handling and the difficulty

  • The same input_context on every scene, with turn a single signed Axis1D action that keys and a stick both drive

  • A TouchController in the game scene, so a phone plays it with two thumbs

Controls: W or Up thrusts, A/D or Left/Right turn, Space fires, Enter starts. On a controller or the on-screen pad the stick turns, B thrusts and A fires. Hold the mouse, or a finger anywhere off the on-screen pad, to steer toward the pointer while thrusting and firing. A click or tap starts the game.

SourceΒΆ

  1#!/usr/bin/env python3
  2# /// simvx
  3# tags = ["game", "collision", "wrap", "shooting"]
  4# ///
  5"""Asteroids 2D: The classic arcade shooter in one small file.
  6
  7Thrust, turn and shoot through waves of splitting asteroids on a wrap-around
  8playfield, with a title screen, a HUD, lives and a game-over screen: a whole
  9arcade game loop with no physics engine.
 10
 11Every actor is a plain ``Node2D`` that integrates its own ``velocity`` and
 12calls ``wrap_screen`` to come back on the far edge. Collision is a circle test:
 13``Body2D.overlapping(group)`` compares radii against the nodes in a scene-tree
 14group, which leaves out a node destroyed earlier in the frame. The demo also shows:
 15
 16- Immediate-mode drawing from ``on_draw``: polygons, circles and centred text
 17  anchored on a point with ``align="centre"``, shrunk by ``fit_scale`` to fit
 18  a narrow window
 19- Scene flow with ``tree.change_scene`` between title, game and game over
 20- Class-level ``Signal``s: the ship calls ``fired()`` and ``died()`` and the
 21  game decides what a shot and a death mean
 22- ``Timer`` children for the fire cooldown, the bullet lifetime and the
 23  respawn shield
 24- ``Property`` descriptors for the ship's handling and the difficulty
 25- The same ``input_context`` on every scene, with ``turn`` a single signed
 26  ``Axis1D`` action that keys and a stick both drive
 27- A ``TouchController`` in the game scene, so a phone plays it with two thumbs
 28
 29Controls: W or Up thrusts, A/D or Left/Right turn, Space fires, Enter starts.
 30On a controller or the on-screen pad the stick turns, B thrusts and A fires.
 31Hold the mouse, or a finger anywhere off the on-screen pad, to steer toward the
 32pointer while thrusting and firing. A click or tap starts the game.
 33"""
 34
 35import math
 36import random
 37
 38from simvx.core import Axis1D, Controller, Input, Key, Node, Node2D, Pointer, Property, Signal, Timer, Vec2, on_input
 39from simvx.core.ui import TouchController
 40from simvx.graphics import App
 41
 42INPUT_ACTIONS = {
 43    "thrust": [Key.W, Key.UP, Controller.B],
 44    "turn": [Axis1D(low=Key.A, high=Key.D), Axis1D(low=Key.LEFT, high=Key.RIGHT), Controller.LEFT_X],
 45    "fire": [Key.SPACE, Controller.A],
 46    "pointer_fly": [Pointer.PRIMARY],
 47    "start": [Key.ENTER, Pointer.PRIMARY, Controller.A, Controller.START],
 48}
 49
 50WHITE = (1.0, 1.0, 1.0, 1.0)
 51GREY = (0.75, 0.75, 0.75, 1.0)
 52SHIP_SHAPE = [Vec2(0, -12), Vec2(-8, 10), Vec2(8, 10)]
 53FLAME_SHAPE = [Vec2(-5, 10), Vec2(0, 18), Vec2(5, 10)]
 54# Asteroid radius -> points for shooting one; anything larger than the smallest splits in two.
 55SCORES = {40: 20, 20: 50, 10: 100}
 56
 57
 58def centred_text(renderer, node, text, y, scale, colour):
 59    """Draw each line of ``text`` centred at the fraction ``y`` of the height, shrunk to fit the width."""
 60    width, height = node.viewport_size
 61    scale = renderer.fit_scale(text, width * 0.9, base_scale=scale)
 62    renderer.text(text, (width / 2, height * y), fill=colour, scale=scale, align="centre")
 63
 64
 65class Body2D(Node2D):
 66    """An actor that moves by its own velocity, wraps at the edges and has a round hitbox."""
 67
 68    radius = Property(8.0)
 69
 70    def __init__(self, **kwargs):
 71        super().__init__(**kwargs)
 72        self.velocity = Vec2()
 73
 74    def on_fixed_update(self, dt: float):
 75        self.position += self.velocity * dt
 76        self.wrap_screen(margin=self.radius)
 77
 78    def overlapping(self, group: str) -> list[Body2D]:
 79        # A body destroyed this frame still runs until the frame ends, so it
 80        # stops colliding here: one bullet must not score twice.
 81        if self.destroying:
 82            return []
 83        return [
 84            body
 85            for body in self.tree.group(group)
 86            if (body.world_position - self.world_position).length() < body.radius + self.radius
 87        ]
 88
 89
 90class Ship(Body2D):
 91    fired = Signal()
 92    died = Signal()
 93
 94    radius = Property(10.0)
 95    # Written in degrees, stored in radians like every engine angle.
 96    turn_speed = Property(200.0, coerce=math.radians, range=(math.radians(50), math.radians(400)))
 97    thrust_power = Property(300.0, range=(50, 800))
 98    max_speed = Property(400.0, range=(100, 1000))
 99    drag = Property(0.98, range=(0.9, 1.0))
100
101    def on_ready(self):
102        self.thrusting = False
103        self.gun = self.add_child(Timer(duration=0.15))
104        self.shield = self.add_child(Timer(duration=2.0))
105        self.position = self.viewport_size / 2
106
107    def on_fixed_update(self, dt: float):
108        self.rotation += self.turn_speed * Input.value("turn") * dt
109
110        pointer_held = Input.pressed("pointer_fly")
111        if pointer_held:
112            to_pointer = Input.mouse_position - self.world_position
113            if to_pointer.length() > self.radius * 2:
114                target = math.atan2(to_pointer.x, -to_pointer.y)
115                diff = (target - self.rotation + math.pi) % math.tau - math.pi
116                step = self.turn_speed * dt
117                self.rotation += max(-step, min(step, diff))
118
119        self.thrusting = Input.pressed("thrust") or pointer_held
120        if self.thrusting:
121            self.velocity += self.forward * (self.thrust_power * dt)
122            if self.velocity.length() > self.max_speed:
123                self.velocity = self.velocity.normalized() * self.max_speed
124        self.velocity *= self.drag
125        super().on_fixed_update(dt)
126
127        if (Input.pressed("fire") or pointer_held) and self.gun.stopped:
128            self.gun.start()
129            self.fired.emit()
130
131        if self.shield.stopped and self.overlapping("asteroids"):
132            self.died.emit()
133            self.position = self.viewport_size / 2
134            self.velocity = Vec2()
135            self.rotation = 0.0
136            self.shield.start()
137
138    def on_draw(self, renderer):
139        if int(self.shield.time_left * 10) % 2:
140            return  # blink while the shield is up
141        renderer.polygon(SHIP_SHAPE, stroke=WHITE)
142        if self.thrusting:
143            renderer.polygon(FLAME_SHAPE, stroke=WHITE)
144
145
146class Bullet(Body2D):
147    radius = Property(2.0)
148    speed = Property(500.0)
149
150    def on_ready(self):
151        self.add_to_group("bullets")
152        self.velocity = self.forward * self.speed
153        self.add_child(Timer(duration=1.5, autostart=True)).timeout.connect(self.destroy)
154
155    def on_draw(self, renderer):
156        renderer.circle((0, 0), self.radius, stroke=WHITE, segments=6)
157
158
159class Asteroid(Body2D):
160    radius = Property(40.0)
161
162    def on_ready(self):
163        self.add_to_group("asteroids")
164        self.velocity = Vec2.from_angle(random.uniform(0, math.tau)) * random.uniform(40, 120)
165        self.spin = math.radians(random.uniform(-90, 90))
166        corners = [i / 10 * math.tau for i in range(10)]
167        self.shape = [Vec2.from_angle(a) * self.radius * random.uniform(0.7, 1.3) for a in corners]
168
169    def on_fixed_update(self, dt: float):
170        super().on_fixed_update(dt)
171        self.rotation += self.spin * dt
172
173    def on_draw(self, renderer):
174        renderer.polygon(self.shape, stroke=WHITE)
175
176
177class MainMenu(Node):
178    dynamic = True  # the prompt blinks
179    input_context = INPUT_ACTIONS
180
181    @on_input("start")
182    def start(self, event):
183        self.tree.change_scene(AsteroidsGame())
184
185    def on_draw(self, renderer):
186        width, height = self.viewport_size
187        centred_text(renderer, self, "ASTEROIDS", 0.12, 6, WHITE)
188        size = min(width, height) / 250
189        renderer.polygon([p * size + Vec2(width / 2, height * 0.42) for p in SHIP_SHAPE], stroke=WHITE)
190        centred_text(renderer, self, CONTROLS_HELP, 0.62, 2, GREY)
191        if int(self.tree.now * 2) % 2 == 0:
192            centred_text(renderer, self, "PRESS ENTER OR TAP TO START", 0.85, 3, GREY)
193
194
195class AsteroidsGame(Node2D):
196    dynamic = True  # the HUD reads plain counters
197    input_context = INPUT_ACTIONS
198
199    start_asteroids = Property(4, range=(1, 12))
200    lives = Property(3, range=(1, 10))
201
202    def on_ready(self):
203        self.score = 0
204        self.lives_left = self.lives
205        self.wave = 0
206        self.ship = self.add_child(Ship(name="Ship"))
207        self.ship.fired.connect(self.shoot)
208        self.ship.died.connect(self.lose_life)
209        self.add_child(TouchController())
210
211    def shoot(self):
212        self.add_child(Bullet(position=self.ship.position + self.ship.forward * 15, rotation=self.ship.rotation))
213
214    def lose_life(self):
215        self.lives_left -= 1
216        if self.lives_left == 0:
217            self.tree.change_scene(GameOver(score=self.score))
218
219    def spawn_wave(self):
220        self.wave += 1
221        width, height = self.viewport_size
222        band = min(width, height) * 0.2
223        for _ in range(self.start_asteroids + self.wave - 1):
224            # Spawn near the corners, away from the ship in the middle.
225            x = random.choice([random.uniform(0, band), random.uniform(width - band, width)])
226            y = random.choice([random.uniform(0, band), random.uniform(height - band, height)])
227            self.add_child(Asteroid(position=(x, y)))
228
229    def on_fixed_update(self, dt: float):
230        for bullet in self.tree.group("bullets"):
231            if hits := bullet.overlapping("asteroids"):
232                rock = hits[0]
233                self.score += SCORES[rock.radius]
234                if rock.radius > min(SCORES):
235                    for _ in range(2):
236                        self.add_child(Asteroid(radius=rock.radius / 2, position=rock.position))
237                rock.destroy()
238                bullet.destroy()
239        if not self.tree.group("asteroids"):
240            self.spawn_wave()
241
242    def on_draw(self, renderer):
243        renderer.text(f"SCORE {self.score:05d}", (10, 10), fill=WHITE, scale=2)
244        width = self.viewport_size.x
245        for i in range(self.lives_left):
246            renderer.polygon([p + Vec2(width - 30 - i * 25, 18) for p in SHIP_SHAPE], stroke=WHITE)
247
248
249class GameOver(Node):
250    dynamic = True  # the prompt blinks
251    input_context = INPUT_ACTIONS
252
253    score = Property(0)
254
255    @on_input("start")
256    def start(self, event):
257        self.tree.change_scene(MainMenu())
258
259    def on_draw(self, renderer):
260        centred_text(renderer, self, "GAME OVER", 0.28, 5, (1.0, 0.2, 0.2, 1.0))
261        centred_text(renderer, self, f"SCORE  {self.score:05d}", 0.5, 3, WHITE)
262        if int(self.tree.now * 2) % 2 == 0:
263            centred_text(renderer, self, "PRESS ENTER OR TAP TO CONTINUE", 0.68, 2, GREY)
264
265
266# Example help overlay: not part of the technique shown.
267CONTROLS_HELP = (
268    "W/UP THRUST    A/D TURN    SPACE FIRE\nHOLD OR DRAG ANYWHERE TO FLY AND FIRE\nPAD: STICK TURNS, B THRUSTS, A FIRES"
269)
270
271
272if __name__ == "__main__":
273    App(title="Asteroids", width=800, height=600).run(MainMenu())