Asteroids 2DΒΆ
The classic arcade shooter in one small file.
βΆ Run in browserTags: 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 withalign="centre", shrunk byfit_scaleto fit a narrow windowScene flow with
tree.change_scenebetween title, game and game overClass-level
Signals: the ship callsfired()anddied()and the game decides what a shot and a death meanTimerchildren for the fire cooldown, the bullet lifetime and the respawn shieldPropertydescriptors for the shipβs handling and the difficultyThe same
input_contexton every scene, withturna single signedAxis1Daction that keys and a stick both driveA
TouchControllerin 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())