Chapter 8: Rocks and bullets: spawning, shooting, and collisions
This chapter: fill the field with splitting asteroids, add bullets with a cooldown and recoil, and build the collision systems, including the elastic asteroid-vs-asteroid bounce, and scoring.
Where we left off: a ship flies around an empty field.
8.1 The asteroid
Each rock carries a size (3/2/1 = large/medium/small) and a per-asteroid array of radius multipliers so every one is a unique lumpy polygon:
SHAPE_N :: 12 // vertices per outline
Asteroid :: struct {
pos: Vec2,
vel: Vec2,
angle: f32,
spin: f32,
radius: f32,
size: int,
shape: [SHAPE_N]f32, // per-vertex wobble, 0.72..1.08
active: bool,
}
MAX_ASTEROIDS :: 256
asteroids: [MAX_ASTEROIDS]Asteroid
AST_R_LARGE :: 46.0
AST_R_MED :: 26.0
AST_R_SMALL :: 14.0
8.2 Spawning waves
spawn_asteroid finds a free slot and randomises everything; next_level seeds each wave, bigger than the last, along the edges (away from the ship):
spawn_asteroid :: proc(pos: Vec2, size: int) {
for &a in asteroids {
if a.active { continue }
a.active = true
a.pos = pos
a.size = size
switch size {
case 3: a.radius = AST_R_LARGE
case 2: a.radius = AST_R_MED
case: a.radius = AST_R_SMALL
}
speed := rand.float32_range(24, 60) * (1.0 + f32(3 - size) * 0.35) // smaller = faster
dir := rand.float32_range(0, TAU)
a.vel = {math.cos(dir) * speed, math.sin(dir) * speed}
a.angle = rand.float32_range(0, TAU)
a.spin = rand.float32_range(-1.6, 1.6)
for i in 0 ..< SHAPE_N { a.shape[i] = rand.float32_range(0.72, 1.08) }
return
}
}
next_level :: proc() {
level += 1
count := 4 + level * 2
for _ in 0 ..< count {
p: Vec2
if rand.float32() < 0.5 {
p = {rand.float32() * CANVAS_W, rand.float32() < 0.5 ? 0 : CANVAS_H}
} else {
p = {rand.float32() < 0.5 ? 0 : CANVAS_W, rand.float32() * CANVAS_H}
}
spawn_asteroid(p, 3)
}
}
update_asteroids :: proc(dt: f32) {
for &a in asteroids {
if !a.active { continue }
a.pos += a.vel * dt
a.angle += a.spin * dt
wrap(&a.pos)
}
}
8.3 Drawing a rock
The outline is SHAPE_N points around the centre, each pushed out by radius * shape[i], rotated by the asteroid's angle:
draw_asteroids :: proc() {
batch.set_stroke(&cmd, "#c9d4e6")
batch.set_line_width(&cmd, 1.6)
for &a in asteroids {
if !a.active { continue }
batch.begin_path(&cmd)
for i in 0 ..< SHAPE_N {
ang := a.angle + TAU * f32(i) / f32(SHAPE_N)
r := a.radius * a.shape[i]
x := a.pos.x + math.cos(ang) * r
y := a.pos.y + math.sin(ang) * r
if i == 0 { batch.move_to(&cmd, x, y) } else { batch.line_to(&cmd, x, y) }
}
batch.close_path(&cmd)
batch.stroke(&cmd)
}
}
8.4 Bullets
Six bullets, a fire cooldown, a little recoil, and a short lifetime:
MAX_BULLETS :: 6
BULLET_SPEED :: 520.0
BULLET_LIFE :: 1.15
FIRE_COOLDOWN :: 0.16
Bullet :: struct { pos, vel: Vec2, life: f32, active: bool }
bullets: [MAX_BULLETS]Bullet
fire_bullet :: proc() {
for &b in bullets {
if b.active { continue }
b.active = true
dir := Vec2{math.cos(ship.angle), math.sin(ship.angle)}
b.pos = ship.pos + dir * SHIP_RADIUS
b.vel = ship.vel + dir * BULLET_SPEED // inherit ship velocity
b.life = BULLET_LIFE
ship.vel -= dir * (BULLET_SPEED * 0.05) // recoil kick
play_sound(SND_FIRE)
return
}
}
update_bullets :: proc(dt: f32) {
for &b in bullets {
if !b.active { continue }
b.pos += b.vel * dt
wrap(&b.pos)
b.life -= dt
if b.life <= 0 { b.active = false }
}
}
Firing is a continuous action gated by the cooldown, driven from update_ship:
if keys[KEY_FIRE] && ship.cooldown <= 0 {
fire_bullet()
ship.cooldown = FIRE_COOLDOWN
}
Each shot draws as a short streak stretched back along its velocity: motion reads clearly even at high speed (full draw_bullets in Appendix B).
8.5 Collisions
For a threshold test you never need the actual distance: compare squared distances and skip the square root:
dist2 :: proc(a, b: Vec2) -> f32 {
d := a - b
return d.x * d.x + d.y * d.y
}
handle_collisions runs three passes: bullets vs asteroids, asteroids vs the ship, and asteroids vs each other.
handle_collisions :: proc() {
// Bullets vs asteroids.
for &b in bullets {
if !b.active { continue }
for &a in asteroids {
if !a.active { continue }
if dist2(b.pos, a.pos) <= a.radius * a.radius {
b.active = false
split_asteroid(&a)
break
}
}
}
// Asteroids vs ship (unless spawn-protected).
if ship.alive && ship.invuln <= 0 {
for &a in asteroids {
if !a.active { continue }
r := a.radius + SHIP_RADIUS
if dist2(ship.pos, a.pos) <= r * r { kill_ship(); break }
}
}
// Asteroids vs asteroids.
for i in 0 ..< MAX_ASTEROIDS {
a := &asteroids[i]
if !a.active { continue }
for j in i + 1 ..< MAX_ASTEROIDS {
b := &asteroids[j]
if !b.active { continue }
resolve_asteroid_pair(a, b)
}
}
}
8.6 Splitting
A hit awards points by size and spawns two smaller rocks (or none, if it was already small). We'll add the explosion effects and sound in Chapters 9 and 11; for now, the mechanic:
split_asteroid :: proc(a: ^Asteroid) {
switch a.size {
case 3: score += 20
case 2: score += 50
case: score += 100
}
// (Chapter 9 adds particles/debris/shockwave/shake here; Chapter 11 adds the bang sound.)
if a.size > 1 {
spawn_asteroid(a.pos, a.size - 1)
spawn_asteroid(a.pos, a.size - 1)
}
a.active = false
}
8.7 The elastic bounce
Rocks that drift through each other look like ghosts. A standard elastic-collision impulse, with mass proportional to radius² (area), separates the overlap and reflects the velocities along the collision normal:
resolve_asteroid_pair :: proc(a, b: ^Asteroid) {
delta := a.pos - b.pos
d2 := delta.x * delta.x + delta.y * delta.y
min_dist := a.radius + b.radius
if d2 >= min_dist * min_dist || d2 < 0.0001 { return }
dist := math.sqrt(d2)
n := delta / dist // collision normal
m1 := a.radius * a.radius // mass ~ area
m2 := b.radius * b.radius
total := m1 + m2
overlap := min_dist - dist // push apart, weighted by mass
a.pos += n * (overlap * (m2 / total))
b.pos -= n * (overlap * (m1 / total))
rel := a.vel - b.vel
vn := rel.x * n.x + rel.y * n.y
if vn >= 0 { return } // already separating
imp := (2 * vn) / total
a.vel -= n * (imp * m2)
b.vel += n * (imp * m1)
}
*Why this, when the original Asteroids had none? Pure feel. Rocks that shove each other read as solid objects with weight. It's a small amount of real physics for a large gain in how alive the field looks, and a good example of adding a mechanic the source material never had because it plays* better.
8.8 Clearing the wave
At the end of update_game, if nothing's left, advance:
if count_asteroids() == 0 { next_level() }
./run.sh, press to start, and you have a game: fly, shoot, split rocks that bounce, clear a wave, face a harder one.
Checkpoint
The core loop is playable: asteroids spawn in waves, bullets fire on a cooldown with recoil, collisions split rocks and cost lives, rocks bounce off each other elastically, and clearing a wave brings a bigger one. Score counts (we render it in Chapter 10).
Exercises
- Give large asteroids a faint second, inner outline for depth.
- Make bullets pass through rocks but mark them for destruction, destroying all hit that frame: how does the feel change versus stopping at the first hit?
- Add a
MAX_BULLETSof 3 and then 10. How does the game's difficulty and rhythm shift?
Further reading
- Elastic collision (2D impulse): https://en.wikipedia.org/wiki/Elastic_collision
- Odin
core:math/rand: https://pkg.odin-lang.org/core/math/rand/