Chapter 8: Juice: particles, shake and a living sky
This chapter:
- Add particles: dust on landing, a burst on death, a sparkle on pickup.
- Shake the screen through the camera, so the HUD stays still.
- Squash-and-stretch the wolf, and drift the sky from day to night.
Where we left off: a fully playable game that looks a bit flat.
None of this chapter changes a single rule. All of it changes how the game feels: the difference between "functional" and "alive". This is the fun part.
8.1 Particles
Particles reuse Chapter 6's allocation-free pattern exactly: a fixed array, an active flag, spawn into a free slot, deactivate when spent.
// entities.odin
Particle :: struct {
active: bool,
pos, vel: Vec2,
life, max_life: f32,
size: f32,
color: rl.Color,
}
// game.odin
MAX_PARTICLES :: 512
// (particles: [MAX_PARTICLES]Particle lives in Game)
One spawn primitive, then three flavours that use it:
// effects.odin
spawn_particle :: proc(pos, vel: Vec2, life, size: f32, color: rl.Color) {
for &p in g.particles {
if !p.active {
p = Particle{active = true, pos = pos, vel = vel, life = life, max_life = life, size = size, color = color}
return
}
}
}
spawn_dust :: proc(pos: Vec2, count: int) { // grey puffs on take-off/landing
for _ in 0 ..< count {
vel := Vec2{rand.float32_range(-70, 70), rand.float32_range(-140, -20)}
spawn_particle(pos, vel, rand.float32_range(0.25, 0.5), rand.float32_range(3, 6), {170, 172, 178, 255})
}
}
spawn_burst :: proc(pos: Vec2, count: int) { // a colourful blast on death
for _ in 0 ..< count {
ang := rand.float32_range(0, math.TAU)
spd := rand.float32_range(80, 360)
vel := Vec2{math.cos(ang) * spd, math.sin(ang) * spd - 60}
cols := [3]rl.Color{{240, 180, 70, 255}, {230, 90, 70, 255}, {245, 240, 220, 255}}
spawn_particle(pos, vel, rand.float32_range(0.4, 0.9), rand.float32_range(3, 7), cols[rand.int_max(3)])
}
}
spawn_sparkle :: proc(pos: Vec2) { // a gold shimmer on rune pickup
for _ in 0 ..< 8 {
ang := rand.float32_range(0, math.TAU)
spd := rand.float32_range(40, 140)
vel := Vec2{math.cos(ang) * spd, math.sin(ang) * spd}
spawn_particle(pos, vel, rand.float32_range(0.3, 0.55), rand.float32_range(2, 5), RUNE_COLOR)
}
}
These are the calls we stubbed in Chapters 4 and 7: spawn_dust from the jump and landing, spawn_burst from kill_player, spawn_sparkle from a rune pickup. Replace the stubs now.
Updating them is one loop: gravity, drag, move, fade, and note the drag line uses array programming on the whole Vec2 at once:
update_particles :: proc(dt: f32) {
for &p in g.particles {
if !p.active do continue
p.vel.y += 900 * dt // gravity
p.vel *= 1 - min(1, 4 * dt) // drag: scalar broadcast over both components
p.pos += p.vel * dt
p.life -= dt
if p.life <= 0 do p.active = false
}
}
Drawing fades them out by remaining life, using rl.Fade to apply alpha:
draw_particles :: proc() {
for p in g.particles {
if !p.active do continue
a := p.life / p.max_life
rl.DrawCircleV(p.pos, p.size * a, rl.Fade(p.color, a))
}
}
Particles are drawn inside Mode2D: their positions are world coordinates, so they scroll with the world they were born in.
8.2 Screen shake: through the camera
When the run ends, the screen should kick. The clean way to shake everything in the world at once is to jitter the camera's offset, because the whole world is drawn through the camera, and the HUD (drawn outside it) doesn't move at all.
// effects.odin
SHAKE_DECAY :: 55.0
trigger_shake :: proc(mag: f32) {
g.shake = max(g.shake, mag) // a small hit never cuts off a bigger one
}
update_shake :: proc(dt: f32) {
g.shake = max(0, g.shake - SHAKE_DECAY * dt)
if g.shake > 0 {
g.shake_off = {rand.float32_range(-g.shake, g.shake), rand.float32_range(-g.shake, g.shake)}
} else {
g.shake_off = {}
}
}
The magnitude decays smoothly; each frame it produces a random offset within that magnitude. The update applies it to the camera:
// game.odin, end of update_game
g.camera.offset = {CAMERA_ANCHOR + g.shake_off.x, g.shake_off.y}
Why route shake through the camera. The naïve alternative is to add a jitter to every draw call; that is tedious and easy to get inconsistent. One offset on the camera shakes the entire world coherently, and because the HUD is drawn in screen space outside
Mode2D, your score and messages stay rock-steady while the world rattles. That screen/world split from Chapter 5 pays off here. Notetrigger_shaketakes the max, so a little collision thud can't stomp on a big death shake.
8.3 Squash-and-stretch, finished
Chapter 4 gave the wolf a squash value and the drawing that responds to it; here's the whole idea in one place. squash is set positive on take-off and negative on landing, and relaxes to zero:
// in update_player: on jump
p.squash = 0.5
// on landing
p.squash = -0.5
// every frame
p.squash = approach(p.squash, 0, dt * 4)
And the drawing (Chapter 4) turns that one number into "tall and thin" then "short and wide":
sx := 1 - p.squash * 0.25
sy := 1 + p.squash * 0.35
It's a handful of lines for a disproportionate amount of life. That's the hallmark of good juice: cheap to add, big to feel.
8.4 A living sky
Finally, drift the world from day to night and back. One function gives a 0…1 "how much daytime" factor that cycles slowly with run time:
// game.odin (tuning block)
DAY_LENGTH :: 45.0 // seconds for a full cycle
// effects.odin
day_factor :: proc() -> f32 {
ph := g.run_time / DAY_LENGTH
return 0.5 + 0.5 * math.sin(ph * math.TAU)
}
Every palette slot is then a ColorLerp between a night colour and a day colour, driven by that factor: the sky bands and parallax hills (Chapter 5) already take it as their argument f. Add a sun/moon disc to draw_sky that arcs across and changes colour with the hour:
ph := g.run_time / DAY_LENGTH
sx := f32(WINDOW_W) * (0.5 + 0.42 * math.cos(ph * math.TAU))
sy := 150 - 70 * math.sin(ph * math.TAU)
disc := rl.ColorLerp(rl.Color{230, 230, 235, 255}, rl.Color{255, 236, 170, 255}, f)
rl.DrawCircleV({sx, sy}, 26, disc)
All colour, no assets: the sky breathes as a run goes on, and a long run visibly rolls from morning into dusk.
A word on restraint. Juice is easy to overdo. The particle counts are capped by the fixed arrays, the shake decays fast, the squash is subtle, the day cycle is slow. Every one of these is a constant at the top of its file; turn them up to see too much, then bring them back. Taste is the tuning.
Checkpoint
Sköll is alive: landings puff dust, deaths burst into colour and kick the screen, the wolf squashes and stretches, runes shimmer when grabbed, and the sky drifts from day to night while a sun arcs overhead, the HUD sitting perfectly still through all of it. What the game still lacks is a face: a title, a game-over screen, and the score on screen. That's next.
Exercises
- Call
trigger_shake(6)on every rune pickup for a tiny tactile pop. Too much? Tune it. - Add exhaust dust behind the wolf while running by spawning a little
spawn_dusteach frame it's on the ground. - Shorten
DAY_LENGTHto10to watch the cycle race, then set it back. - Give particles a subtle glow by drawing a second, larger, more transparent circle behind each.
Further reading
- raylib cheatsheet (
Fade,ColorLerp, Camera2D offset): https://www.raylib.com/cheatsheet/cheatsheet.html - Odin
core:math(sin,cos,TAU) andcore:math/rand: https://pkg.odin-lang.org/core/math/ - On "juice": search for the talk "Juice it or lose it" (Martin Jonasson & Petri Purho).