Yo Odin · Chapter 8

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

  1. Give large asteroids a faint second, inner outline for depth.
  2. 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?
  3. Add a MAX_BULLETS of 3 and then 10. How does the game's difficulty and rhythm shift?

Further reading