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/restoreblock means one line (translate) shakes hundreds of shapes, and the HUD drawn afterrestoreis 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
- Add a colour shift to particles as they age (warm to cool) by choosing the fill from
life / max_life. - Make
trigger_shakescale with impact speed for ship–asteroid collisions. - Drop
STAR_COUNTto 40 and raise it to 1500. Watch the frame time: batching is why 1500 barely moves it.
Further reading
- MDN: Canvas transforms: https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/setTransform
- MDN: globalAlpha: https://developer.mozilla.org/en-US/docs/Web/API/CanvasRenderingContext2D/globalAlpha
- Juice it or lose it (game-feel talk): https://www.youtube.com/watch?v=Fy0aCDmgnxg