Yo Odin · Chapter 9

Chapter 9: Juice: effects that make it feel like a game

This chapter: particle sparks, spinning debris, shockwave rings, screen shake, a twinkling starfield, and hyperspace, wired into the gameplay hooks stubbed in Chapter 8.

Where we left off: shooting a rock splits it, silently and without ceremony.


9.1 Three effect pools

Same fixed-array pattern as everything else: pre-sized pools with active flags:

MAX_PARTICLES  :: 700
MAX_DEBRIS     :: 160
MAX_SHOCKWAVES :: 16

Particle  :: struct { pos, vel: Vec2, life, max_life: f32, active: bool }
Debris    :: struct { pos, vel: Vec2, angle, spin, len, life, max_life: f32, active: bool }
Shockwave :: struct { pos: Vec2, radius, max_radius, life, max_life: f32, active: bool }

particles:  [MAX_PARTICLES]Particle
debris:     [MAX_DEBRIS]Debris
shockwaves: [MAX_SHOCKWAVES]Shockwave

Spawners fill up to n free slots; updaters advance and retire them. Particles are point sparks; debris are spinning line-shards for heavier explosions; shockwaves are expanding rings.

spawn_particles :: proc(pos: Vec2, n: int, spread: f32) {
	made := 0
	for &p in particles {
		if made >= n { break }
		if p.active { continue }
		p.active = true
		p.pos = pos
		dir := rand.float32_range(0, TAU)
		speed := rand.float32_range(30, spread)
		p.vel = {math.cos(dir) * speed, math.sin(dir) * speed}
		p.max_life = rand.float32_range(0.4, 1.0)
		p.life = p.max_life
		made += 1
	}
}

update_particles :: proc(dt: f32) {
	for &p in particles {
		if !p.active { continue }
		p.pos += p.vel * dt
		p.vel *= math.pow_f32(0.2, dt)   // quick decay
		p.life -= dt
		if p.life <= 0 { p.active = false }
	}
}

spawn_debris and spawn_shockwave follow the same shape (full source in Appendix B). Each drawer fades with alpha = life / max_life:

draw_particles :: proc() {
	batch.set_shadow(&cmd, "#ffce7a", 4)   // a glow, scoped by save/restore elsewhere
	batch.set_fill(&cmd, "#ffce7a")
	for &p in particles {
		if !p.active { continue }
		batch.set_global_alpha(&cmd, p.life / p.max_life)
		batch.fill_rect(&cmd, p.pos.x - 1, p.pos.y - 1, 2, 2)
	}
	batch.set_global_alpha(&cmd, 1)
	batch.clear_shadow(&cmd)
}

9.2 Screen shake, cleanly

Shake is one decaying magnitude plus a per-frame random offset:

shake_mag, shake_x, shake_y: f32

trigger_shake :: proc(amount: f32) {
	if amount > shake_mag { shake_mag = amount }   // never shrink an in-progress shake
}

update_shake :: proc(dt: f32) {
	if shake_mag > 0.05 {
		shake_x = rand.float32_range(-shake_mag, shake_mag)
		shake_y = rand.float32_range(-shake_mag, shake_mag)
		shake_mag *= math.pow_f32(0.02, dt)
	} else {
		shake_mag, shake_x, shake_y = 0, 0, 0
	}
}

The trick is what shakes. In draw_game, wrap the world, but not the HUD, in a save/translate/restore, so everything jitters together while the readout stays rock-steady:

draw_game :: proc() {
	batch.reset(&cmd)
	batch.set_fill(&cmd, "#05070d"); batch.fill_rect(&cmd, 0, 0, CANVAS_W, CANVAS_H)

	batch.save(&cmd)
	batch.translate(&cmd, shake_x, shake_y)      // <- the whole world jitters
	draw_stars(); draw_shockwaves(); draw_particles(); draw_debris()
	draw_asteroids(); draw_bullets()
	if ship.alive { draw_ship() }
	batch.restore(&cmd)

	draw_hud()                                   // <- HUD unaffected (Chapter 10)
	batch.flush(ctx, &cmd)
}

Why translate the world, not the camera? There's no camera: we translate the canvas. Scoping the offset to a save/restore block means one line (translate) shakes hundreds of shapes, and the HUD drawn after restore is untouched. This is the single cheapest, highest-impact piece of juice in the game.

9.3 The starfield

Hundreds of points, each with a brightness and a twinkle phase, and the bulk of the frame's draw ops, which is exactly the load batching was built to shrug off:

STAR_COUNT :: 380
Star :: struct { pos: Vec2, brightness, twinkle: f32 }
stars: [STAR_COUNT]Star

// in init_game:
for &s in stars {
	s.pos = {rand.float32() * CANVAS_W, rand.float32() * CANVAS_H}
	s.brightness = rand.float32_range(0.15, 0.9)
	s.twinkle = rand.float32_range(0, TAU)
}
anim_time: f32   // advanced each drawn frame

draw_stars :: proc() {
	for &s in stars {
		tw := 0.55 + 0.45 * math.sin(anim_time * 2.0 + s.twinkle)
		batch.set_fill(&cmd, grey(s.brightness * tw))
		sz: f32 = s.brightness > 0.7 ? 2 : 1
		batch.fill_rect(&cmd, s.pos.x, s.pos.y, sz, sz)
	}
}

(grey builds an "rgb(v,v,v)" string without fmt; we cover that helper in Chapter 10 with the rest of the HUD text.)

9.4 Wiring the hooks

Now fill in the stubs from Chapter 8. split_asteroid gets its explosion and shake:

split_asteroid :: proc(a: ^Asteroid) {
	switch a.size {
	case 3: score += 20;  trigger_shake(7)
	case 2: score += 50;  trigger_shake(4)
	case:   score += 100; trigger_shake(2)
	}
	spawn_particles(a.pos, a.size * 6 + 6, a.radius * 4)
	spawn_debris(a.pos, a.size * 2 + 2, a.radius * 3, f32(a.size))
	spawn_shockwave(a.pos, a.radius * 1.8)
	if a.size > 1 {
		spawn_asteroid(a.pos, a.size - 1)
		spawn_asteroid(a.pos, a.size - 1)
	}
	a.active = false
}

The ship's thruster also emits exhaust sparks (throttled by a timer) in update_ship:

thrust_particle_timer -= dt
if thrust_particle_timer <= 0 {
	back := ship.pos - Vec2{math.cos(ship.angle), math.sin(ship.angle)} * SHIP_RADIUS
	spawn_particles(back, 2, 40)
	thrust_particle_timer = 0.03
}

And update_game drives all the effect updates each frame:

update_particles(dt); update_debris(dt); update_shockwaves(dt); update_shake(dt)

9.5 Hyperspace

The classic risky escape: teleport to a random point, with a small chance of a fatal malfunction. A one-shot action, so it reads the latch:

hyperspace_jump :: proc() {
	spawn_particles(ship.pos, 14, 220)
	if rand.float32() < 0.14 { kill_ship(); return }      // 14% malfunction
	ship.pos = {rand.float32_range(40, CANVAS_W-40), rand.float32_range(40, CANVAS_H-40)}
	ship.vel *= 0.2
	if ship.invuln < 0.4 { ship.invuln = 0.4 }
	spawn_particles(ship.pos, 14, 220)
}

Driven from update_ship via a consumed latch and a short cooldown:

if just_hyper && hyper_cooldown <= 0 {
	hyperspace_jump()
	hyper_cooldown = 0.5
}

Checkpoint

The game is alive: hits throw sparks, debris and a shockwave; the screen kicks; the starfield breathes; thrusters trail exhaust; hyperspace flings the ship across the field, occasionally into disaster. All effect pools are fixed-size with no per-frame allocation.

Exercises

  1. Add a colour shift to particles as they age (warm to cool) by choosing the fill from life / max_life.
  2. Make trigger_shake scale with impact speed for ship–asteroid collisions.
  3. Drop STAR_COUNT to 40 and raise it to 1500. Watch the frame time: batching is why 1500 barely moves it.

Further reading