Odin't Know That · Appendix B

Appendix B: Full source listings

The complete, verified Sköll project. These files are the destination the chapters build toward; the copy in code/ is identical. Type-checks with odin check ., builds with odin build . -o:speed, runs as a single native binary. One package main, files at the project root.

Build and run:

cd code
./run.sh          # odin run .
./build.sh        # optimised release binary

main.odin

// main.odin: Sköll, an endless runner, built to teach Odin with vendor:raylib.
// Thin by design: bootstrap the window and audio, load the saved best, then run the
// loop (input, update, draw) until the window is closed. All the game lives in the
// other files of this package.
package main

import rl "vendor:raylib"

main :: proc() {
	rl.InitWindow(WINDOW_W, WINDOW_H, "Sköll")
	defer rl.CloseWindow() // paired teardown: runs however main returns

	rl.SetTargetFPS(60)

	init_audio()
	defer shutdown_audio()

	g.best = load_best()
	init_title()

	for !rl.WindowShouldClose() { // false until Esc or the close button
		dt := min(rl.GetFrameTime(), 0.05) // clamp the spike after a stall/window drag

		handle_input()
		update_game(dt)

		rl.BeginDrawing()
		draw_game()
		rl.EndDrawing()
	}
}

game.odin

// game.odin, the game's tuning constants, top-level state, and the update step that
// dispatches on the current GameState. This is the spine everything else hangs off.
package main

import rl "vendor:raylib"
import "core:math/rand"

// ---- Window ----------------------------------------------------------------------
WINDOW_W :: 960
WINDOW_H :: 540

// ---- World / player tuning (all in pixels and seconds) ---------------------------
GROUND_Y      :: 400.0 // world-y of the ground surface; the wolf's feet rest here
CAMERA_ANCHOR :: 320.0 // the wolf's fixed screen-x; the camera scrolls the world past it
PLAYER_W      :: 46.0
PLAYER_H      :: 40.0

START_SPEED :: 320.0 // run speed at the start of a run
MAX_SPEED   :: 760.0 // run speed cap
RAMP_DIST   :: 14000.0
SPEED_K     :: (MAX_SPEED - START_SPEED) / RAMP_DIST // speed added per pixel travelled

GRAVITY     :: 2200.0
JUMP_SPEED  :: 780.0 // initial upward speed of a jump
JUMP_CUT    :: 0.45  // release early to cut the jump short (variable height)
COYOTE_TIME :: 0.10  // grace period to still jump just after leaving the ground
JUMP_BUFFER :: 0.12  // press slightly early and the jump still fires on landing

RUNE_R     :: 14.0
RUNE_SCORE :: 25

FALL_KILL_Y :: 820.0 // fall below this (into a gap) and the run ends

SPAWN_AHEAD :: 1200.0 // generate this far past the right edge of the screen
CULL_BEHIND :: 320.0  // deactivate entities this far behind the camera

DAY_LENGTH :: 45.0 // seconds for a full day→night→day colour cycle

// ---- Capacities (fixed-size arrays: no per-frame allocation) ----------------------
MAX_OBSTACLES :: 64
MAX_RUNES     :: 160
MAX_GAPS      :: 32
MAX_PARTICLES :: 512

// Set non-zero to force a fixed, reproducible course every run (handy for testing).
SEED_FIXED :: 0

GameState :: enum {
	Title,
	Playing,
	Dead,
}

Game :: struct {
	state:        GameState,
	player:       Player,
	obstacles:    [MAX_OBSTACLES]Obstacle,
	runes:        [MAX_RUNES]Rune,
	gaps:         [MAX_GAPS]Gap,
	particles:    [MAX_PARTICLES]Particle,
	camera:       rl.Camera2D,
	rng:          rand.Default_Random_State,
	run_seq:      u64, // varies the seed between runs
	spawn_cursor: f32, // world-x up to which the course has been generated
	speed:        f32,
	distance:     f32,
	score:        int,
	runes_got:    int,
	best:         int,
	new_best:     bool,
	run_time:     f32,
	shake:        f32, // current screen-shake magnitude
	shake_off:    Vec2,
}

// The whole game is one global. Fixed-size arrays mean it lives in BSS, zero-initialised.
g: Game

// Nudge `a` toward `b` by at most `step`. Used for smoothing squash, wind, etc.
approach :: proc(a, b, step: f32) -> f32 {
	if a < b do return min(b, a + step)
	if a > b do return max(b, a - step)
	return a
}

// Called once at boot to set up the idle title scene.
init_title :: proc() {
	g.state = .Title
	g.player = Player{pos = {0, GROUND_Y}, on_ground = true}
	g.camera = rl.Camera2D{target = {0, 0}, offset = {CAMERA_ANCHOR, 0}, zoom = 1}
	g.run_time = 8 // start the sky at a pleasant daytime
	set_wind_target(0)
}

// Reset all run state and begin playing. Reseeds the deterministic generator.
start_run :: proc() {
	seed: u64 = SEED_FIXED
	if seed == 0 {
		seed = u64(rl.GetTime() * 1_000_000) + g.run_seq * 2654435761
	}
	g.run_seq += 1
	g.rng = rand.create_u64(seed)

	g.obstacles = {}
	g.runes = {}
	g.gaps = {}
	g.particles = {}

	g.player = Player{pos = {0, GROUND_Y}, on_ground = true}
	g.speed = START_SPEED
	g.distance = 0
	g.score = 0
	g.runes_got = 0
	g.run_time = 0
	g.shake = 0
	g.shake_off = {}
	g.new_best = false
	g.spawn_cursor = 220 // leave the wolf a little clear run-up

	g.camera = rl.Camera2D{target = {0, 0}, offset = {CAMERA_ANCHOR, 0}, zoom = 1}
	g.state = .Playing
	set_wind_target(1)

	context.random_generator = rand.default_random_generator(&g.rng)
	advance_generation() // fill the first screen before the player sees it
}

// End the run: shake, burst, sound, and record a new best.
kill_player :: proc() {
	if g.state != .Playing do return
	g.state = .Dead
	trigger_shake(18)
	spawn_burst(player_center(), 42)
	play_sound(.Hit)
	set_wind_target(0)
	if g.score > g.best {
		g.best = g.score
		g.new_best = true
		save_best(g.best)
	}
}

// Read the keyboard. raylib's IsKeyPressed is edge-triggered, so a press that starts a
// run can't leak into a jump on the next frame, but we still guard each state.
handle_input :: proc() {
	jump_pressed := rl.IsKeyPressed(.SPACE) || rl.IsKeyPressed(.UP) || rl.IsKeyPressed(.ENTER)
	switch g.state {
	case .Title:
		if jump_pressed do start_run()
	case .Playing:
		if rl.IsKeyPressed(.SPACE) || rl.IsKeyPressed(.UP) do g.player.jump_buffer = JUMP_BUFFER
		g.player.jump_held = rl.IsKeyDown(.SPACE) || rl.IsKeyDown(.UP)
		if rl.IsKeyPressed(.M) do toggle_mute()
	case .Dead:
		if jump_pressed || rl.IsKeyPressed(.R) do start_run()
	}
}

update_game :: proc(dt: f32) {
	context.random_generator = rand.default_random_generator(&g.rng)

	switch g.state {
	case .Playing:
		g.run_time += dt
		g.distance = g.player.pos.x
		g.speed = min(MAX_SPEED, START_SPEED + g.distance * SPEED_K)
		update_player(dt)
		advance_generation()
		cull_entities()
		update_runes(dt)
		check_collisions()
		g.score = int(g.distance / 10) + g.runes_got * RUNE_SCORE
		frac := (g.speed - START_SPEED) / (MAX_SPEED - START_SPEED)
		set_wind_target(0.3 + 0.7 * frac)
	case .Title:
		g.run_time += dt * 0.3
		g.player.anim += dt * 6 // jog in place behind the title
	case .Dead:
		g.run_time += dt
	}

	update_particles(dt)
	update_shake(dt)
	update_wind(dt)

	// Camera follows the wolf horizontally; screen shake rides on the offset.
	g.camera.target.x = g.player.pos.x
	g.camera.offset = {CAMERA_ANCHOR + g.shake_off.x, g.shake_off.y}
}

player_center :: proc() -> Vec2 {
	return {g.player.pos.x, g.player.pos.y - PLAYER_H * 0.5}
}

entities.odin

// entities.odin, the game's data types and the single-source-of-truth obstacle table.
// Everything the world is made of lives here as a plain struct; systems in the other
// files act on these. Note Vec2 IS raylib's Vector2 (both are [2]f32), so game maths and
// raylib calls share one type with no conversion.
package main

import rl "vendor:raylib"

Vec2 :: rl.Vector2 // == [2]f32

// The kinds of obstacle the wolf must jump. Adding one is: enum value + table row + a
// draw case in draw.odin, nothing else.
ObstacleKind :: enum u8 {
	Rock,
	Boulder,
	Stump,
}

ObstacleDef :: struct {
	w, h:  f32,      // hitbox size; the obstacle sits on the ground
	color: rl.Color,
}

// One row per ObstacleKind. The compiler checks the array is complete.
OBSTACLE_DEFS := [ObstacleKind]ObstacleDef {
	.Rock    = {46, 40, {120, 122, 134, 255}},
	.Boulder = {66, 74, {96, 98, 112, 255}},
	.Stump   = {42, 58, {112, 80, 50, 255}},
}

Obstacle :: struct {
	active: bool,
	kind:   ObstacleKind,
	x:      f32, // world-space centre; y is derived from the ground line and its height
}

Rune :: struct {
	active: bool,
	pos:    Vec2, // world-space centre
	bob:    f32,  // animation phase for the float/spin
}

Gap :: struct {
	active: bool,
	x0, x1: f32, // world-space span with no ground under it
}

Particle :: struct {
	active:            bool,
	pos, vel:          Vec2,
	life, max_life:    f32,
	size:              f32,
	color:             rl.Color,
}

// Geometry helpers (drawing lives in draw.odin, these are shared by collision too).
obstacle_rect :: proc(o: Obstacle) -> rl.Rectangle {
	d := OBSTACLE_DEFS[o.kind]
	return {o.x - d.w * 0.5, GROUND_Y - d.h, d.w, d.h}
}

rune_rect :: proc(r: Rune) -> rl.Rectangle {
	return {r.pos.x - RUNE_R, r.pos.y - RUNE_R, RUNE_R * 2, RUNE_R * 2}
}

player.odin

// player.odin: the wolf. Auto-runs to the right; the only control is the jump, but the
// jump has real game feel: variable height, coyote time and a jump buffer.
package main

import rl "vendor:raylib"

Player :: struct {
	pos:         Vec2, // x = world centre, y = feet (bottom); x only ever grows
	vel:         Vec2, // only y is used (vertical); horizontal comes from g.speed
	on_ground:   bool,
	coyote:      f32,  // time left in which a jump is still allowed after leaving ground
	jump_buffer: f32,  // time left in which a queued press will fire on landing
	jump_held:   bool, // is the jump key currently down? (for variable height)
	jump_active: bool, // are we in the rising, still-cuttable part of a jump?
	squash:      f32,   // >0 stretch (take-off), <0 squash (landing), decays to 0
	anim:        f32,   // run-cycle accumulator, drives the legs
}

update_player :: proc(dt: f32) {
	p := &g.player

	// Auto-run: the wolf's world-x advances at the current speed. Legs swing faster
	// the faster we go.
	p.pos.x += g.speed * dt
	p.anim += dt * g.speed / 42

	// Coyote time and the jump buffer both count down in real seconds.
	if p.on_ground {
		p.coyote = COYOTE_TIME
	} else {
		p.coyote = max(0, p.coyote - dt)
	}
	p.jump_buffer = max(0, p.jump_buffer - dt)

	// Start a jump if one is buffered and we're on the ground (or within coyote time).
	if p.jump_buffer > 0 && p.coyote > 0 {
		p.vel.y = -JUMP_SPEED
		p.on_ground = false
		p.coyote = 0
		p.jump_buffer = 0
		p.jump_active = true
		p.squash = 0.5
		play_sound(.Jump)
		spawn_dust(p.pos, 8)
	}

	// Variable height: releasing the key while still rising cuts the upward speed once.
	if p.jump_active && !p.jump_held && p.vel.y < 0 {
		p.vel.y *= JUMP_CUT
		p.jump_active = false
	}
	if p.vel.y >= 0 do p.jump_active = false

	// Gravity, then integrate vertical position.
	p.vel.y += GRAVITY * dt
	p.pos.y += p.vel.y * dt

	// The squash/stretch relaxes back to neutral.
	p.squash = approach(p.squash, 0, dt * 4)

	// Land only where there is ground (a gap has none). pos.y past GROUND_Y while
	// descending means the feet have reached the surface.
	solid := ground_solid_at(p.pos.x)
	if solid && p.pos.y >= GROUND_Y && p.vel.y >= 0 {
		if !p.on_ground {
			p.squash = -0.5
			spawn_dust(p.pos, 10)
		}
		p.pos.y = GROUND_Y
		p.vel.y = 0
		p.on_ground = true
	} else {
		p.on_ground = false
	}

	// Fall into a gap → the run ends.
	if p.pos.y > FALL_KILL_Y do kill_player()
}

// A slightly generous hitbox: smaller than the drawing, so near-misses feel fair.
player_rect :: proc() -> rl.Rectangle {
	INSET :: 8.0
	p := g.player
	return {p.pos.x - PLAYER_W * 0.5 + INSET, p.pos.y - PLAYER_H + INSET, PLAYER_W - 2 * INSET, PLAYER_H - 2 * INSET}
}

world.odin

// world.odin: the endless course: spawn features ahead of the wolf, cull them behind,
// and resolve collisions. All randomness flows through g.rng (seeded per run) for
// deterministic, reproducible courses.
package main

import "core:math"
import "core:math/rand"
import rl "vendor:raylib"

// Is there solid ground under this world-x? (A gap has none.)
ground_solid_at :: proc(x: f32) -> bool {
	for gp in g.gaps {
		if gp.active && x > gp.x0 && x < gp.x1 do return false
	}
	return true
}

// --- Spawning into the first free slot of each fixed-size array -------------------
add_obstacle :: proc(kind: ObstacleKind, x: f32) {
	for &o in g.obstacles {
		if !o.active {
			o = Obstacle{active = true, kind = kind, x = x}
			return
		}
	}
}

add_rune :: proc(pos: Vec2) {
	for &r in g.runes {
		if !r.active {
			r = Rune{active = true, pos = pos}
			return
		}
	}
}

add_gap :: proc(x0, x1: f32) {
	for &gp in g.gaps {
		if !gp.active {
			gp = Gap{active = true, x0 = x0, x1 = x1}
			return
		}
	}
}

// Runes strung in a shallow arc, e.g. tempting the player across a gap.
spawn_rune_arc :: proc(cx, half: f32) {
	N :: 5
	for i in 0 ..< N {
		t := f32(i) / f32(N - 1) // 0..1
		x := cx + (t - 0.5) * 2 * half
		y := GROUND_Y - 70 - math.sin(t * math.PI) * 90
		add_rune({x, y})
	}
}

// Keep generating features until the course reaches SPAWN_AHEAD past the right edge.
advance_generation :: proc() {
	right := g.player.pos.x + (WINDOW_W - CAMERA_ANCHOR) + SPAWN_AHEAD
	air: f32 = 2 * JUMP_SPEED / GRAVITY // seconds of hang time in a full jump

	for g.spawn_cursor < right {
		reach := g.speed * air // how far the wolf travels during one jump
		roll := rand.float32()

		switch {
		case g.distance > 1500 && roll < 0.20:
			// A gap to jump, sized to be clearable at the current speed.
			gw := reach * rand.float32_range(0.40, 0.62)
			x0 := g.spawn_cursor + 70
			x1 := x0 + gw
			add_gap(x0, x1)
			spawn_rune_arc((x0 + x1) * 0.5, gw * 0.5)
			// leave at least a full jump's landing room after the gap (fairness)
			g.spawn_cursor = x1 + reach * rand.float32_range(1.0, 1.5) + 90

		case roll < 0.70:
			// A single obstacle, sometimes with a reward hanging above it.
			kind := ObstacleKind(rand.int_max(int(max(ObstacleKind)) + 1))
			add_obstacle(kind, g.spawn_cursor + 40)
			if rand.float32() < 0.45 {
				add_rune({g.spawn_cursor + 40, GROUND_Y - 120 - rand.float32_range(0, 60)})
			}
			// at least a full jump's landing room before the next feature (fairness)
			g.spawn_cursor += reach * rand.float32_range(1.05, 1.6) + 60

		case:
			// An open stretch with a line of runes to collect.
			n := 3 + rand.int_max(4)
			base := g.spawn_cursor + 40
			for i in 0 ..< n {
				add_rune({base + f32(i) * 44, GROUND_Y - 80 - rand.float32_range(0, 50)})
			}
			g.spawn_cursor = base + f32(n) * 44 + reach * rand.float32_range(0.4, 0.8) + 60
		}
	}
}

// Deactivate anything that has scrolled well off the left edge.
cull_entities :: proc() {
	left := g.player.pos.x - CAMERA_ANCHOR - CULL_BEHIND
	for &o in g.obstacles do if o.active && o.x < left do o.active = false
	for &r in g.runes do if r.active && r.pos.x < left do r.active = false
	for &gp in g.gaps do if gp.active && gp.x1 < left do gp.active = false
}

update_runes :: proc(dt: f32) {
	for &r in g.runes do if r.active do r.bob += dt * 3
}

check_collisions :: proc() {
	pr := player_rect()

	for &o in g.obstacles {
		if !o.active do continue
		if rl.CheckCollisionRecs(pr, obstacle_rect(o)) {
			kill_player()
			return
		}
	}

	for &r in g.runes {
		if !r.active do continue
		if rl.CheckCollisionRecs(pr, rune_rect(r)) {
			r.active = false
			g.runes_got += 1
			play_sound(.Rune)
			spawn_sparkle(r.pos)
		}
	}
}

draw.odin

// draw.odin: everything you see, built from raylib shape primitives. Screen-space
// backdrop and HUD are drawn outside the 2D camera; the world is drawn inside it.
package main

import "core:math"
import rl "vendor:raylib"

draw_game :: proc() {
	f := day_factor()

	draw_sky(f)
	draw_parallax(f)

	rl.BeginMode2D(g.camera)
	draw_world(f)
	draw_player()
	draw_particles()
	rl.EndMode2D()

	draw_hud() // screen space, steady while the world shakes and scrolls
}

// A vertical gradient made of horizontal bands, plus a sun/moon disc.
draw_sky :: proc(f: f32) {
	top := rl.ColorLerp(SKY_NIGHT_TOP, SKY_DAY_TOP, f)
	bot := rl.ColorLerp(SKY_NIGHT_BOT, SKY_DAY_BOT, f)
	BANDS :: 24
	bh := f32(WINDOW_H) / BANDS
	for i in 0 ..< BANDS {
		t := f32(i) / f32(BANDS - 1)
		rl.DrawRectangleRec({0, f32(i) * bh, f32(WINDOW_W), bh + 1}, rl.ColorLerp(top, bot, t))
	}

	// The sun by day, the moon by night, tracking a low arc across the sky.
	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)
}

// Rounded hills built from overlapping circles above a filled base, scrolled at a
// fraction of the camera's world-x so distant layers drift slower (parallax).
draw_hill_layer :: proc(factor, base_y, radius, spacing: f32, color: rl.Color) {
	scroll := g.camera.target.x * factor
	start := math.floor((scroll - radius) / spacing) * spacing
	x := start
	for x < scroll + f32(WINDOW_W) + radius {
		rl.DrawCircleV({x - scroll, base_y}, radius, color)
		x += spacing
	}
	rl.DrawRectangleRec({0, base_y, f32(WINDOW_W), f32(WINDOW_H) - base_y}, color)
}

draw_parallax :: proc(f: f32) {
	draw_hill_layer(0.20, 300, 150, 210, rl.ColorLerp(HILL_FAR_NIGHT, HILL_FAR_DAY, f))
	draw_hill_layer(0.45, 350, 120, 170, rl.ColorLerp(HILL_NEAR_NIGHT, HILL_NEAR_DAY, f))
}

// The ground (with gaps punched out), obstacles and runes: all in world space.
draw_world :: proc(f: f32) {
	ground := rl.ColorLerp(GROUND_NIGHT, GROUND_DAY, f)
	grass := rl.ColorLerp(rl.Color{40, 60, 40, 255}, rl.Color{110, 170, 96, 255}, f)

	cam_l := g.player.pos.x - CAMERA_ANCHOR - 40
	width := f32(WINDOW_W) + 80
	depth := f32(WINDOW_H) + 200 - GROUND_Y

	rl.DrawRectangleRec({cam_l, GROUND_Y, width, depth}, ground)
	rl.DrawRectangleRec({cam_l, GROUND_Y, width, 8}, grass) // grass line

	for gp in g.gaps {
		if !gp.active do continue
		rl.DrawRectangleRec({gp.x0, GROUND_Y, gp.x1 - gp.x0, depth}, PIT_COLOR)
	}

	for o in g.obstacles {
		if o.active do draw_obstacle(o)
	}

	for r in g.runes {
		if !r.active do continue
		y := r.pos.y + math.sin(r.bob) * 4
		spin := 45 + math.sin(r.bob) * 12
		rl.DrawPoly({r.pos.x, y}, 4, RUNE_R, spin, RUNE_COLOR)
		rl.DrawPoly({r.pos.x, y}, 4, RUNE_R * 0.5, spin, rl.Color{250, 245, 220, 255})
	}
}

draw_obstacle :: proc(o: Obstacle) {
	d := OBSTACLE_DEFS[o.kind]
	rec := obstacle_rect(o)
	switch o.kind {
	case .Rock:
		rl.DrawRectangleRec(rec, d.color)
		rl.DrawCircleV({o.x, GROUND_Y - d.h + 8}, d.w * 0.5, d.color)
	case .Boulder:
		rl.DrawCircleV({o.x, GROUND_Y - d.h * 0.45}, d.w * 0.5, d.color)
		rl.DrawRectangleRec({rec.x, GROUND_Y - d.h * 0.45, rec.width, d.h * 0.45}, d.color)
	case .Stump:
		rl.DrawRectangleRec(rec, d.color)
		rl.DrawRectangleRec({rec.x, rec.y, rec.width, 7}, rl.Color{140, 104, 66, 255})
	}
	rl.DrawRectangleLinesEx(rec, 2, rl.Fade(rl.BLACK, 0.15))
}

// The wolf, assembled from rectangles, triangles, circles and lines. Squash/stretch
// scales it; the legs swing from the run-cycle accumulator.
draw_player :: proc() {
	p := g.player
	sx := 1 - p.squash * 0.25 // take-off → narrower; landing → wider
	sy := 1 + p.squash * 0.35 // take-off → taller;   landing → shorter
	w := PLAYER_W * sx
	h := PLAYER_H * sy
	cx := p.pos.x
	feet := p.pos.y
	body_top := feet - h
	body_bot := feet - 8 // leave room for legs

	// Legs: four swinging lines, planted when airborne so they don't flail.
	airborne := !p.on_ground
	for i in 0 ..< 4 {
		lx := cx - w * 0.32 + f32(i) * (w * 0.64 / 3)
		swing := f32(6)
		if !airborne do swing = math.sin(p.anim + f32(i) * 1.6) * 9
		rl.DrawLineEx({lx, body_bot}, {lx + swing, feet}, 4, WOLF_DARK)
	}

	// Body and back.
	rl.DrawRectangleRec({cx - w * 0.5, body_top + h * 0.28, w, body_bot - (body_top + h * 0.28)}, WOLF_COLOR)
	rl.DrawTriangle({cx - w * 0.5, body_top + h * 0.30}, {cx - w * 0.5, body_bot}, {cx - w * 0.72, body_bot}, WOLF_DARK) // tail

	// Head at the front (the wolf faces +x).
	head_c := Vec2{cx + w * 0.34, body_top + h * 0.30}
	rl.DrawRectangleRec({head_c.x - w * 0.18, head_c.y - h * 0.14, w * 0.40, h * 0.32}, WOLF_COLOR)
	rl.DrawTriangle({head_c.x + w * 0.22, head_c.y - h * 0.14}, {head_c.x + w * 0.22, head_c.y + h * 0.18}, {head_c.x + w * 0.42, head_c.y}, WOLF_COLOR) // snout
	rl.DrawTriangle({head_c.x - w * 0.16, head_c.y - h * 0.14}, {head_c.x - w * 0.02, head_c.y - h * 0.14}, {head_c.x - w * 0.09, head_c.y - h * 0.40}, WOLF_DARK) // ear
	rl.DrawCircleV({head_c.x + w * 0.12, head_c.y - h * 0.02}, 3, rl.BLACK) // eye
}

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))
	}
}

effects.odin

// effects.odin, the juice: particles, screen shake, and the day→night palette.
// None of it changes the rules; all of it changes how the game feels.
package main

import "core:math"
import "core:math/rand"
import rl "vendor:raylib"

// ---- Palette (all synthesised, no image files) ----------------------------------
SKY_DAY_TOP   :: rl.Color{ 92, 152, 210, 255}
SKY_DAY_BOT   :: rl.Color{182, 216, 236, 255}
SKY_NIGHT_TOP :: rl.Color{ 12,  14,  34, 255}
SKY_NIGHT_BOT :: rl.Color{ 40,  42,  74, 255}

HILL_FAR_DAY   :: rl.Color{122, 152, 122, 255}
HILL_FAR_NIGHT :: rl.Color{ 30,  40,  58, 255}
HILL_NEAR_DAY  :: rl.Color{ 82, 122,  82, 255}
HILL_NEAR_NIGHT :: rl.Color{ 22,  32,  46, 255}

GROUND_DAY   :: rl.Color{ 74, 112,  72, 255}
GROUND_NIGHT :: rl.Color{ 26,  36,  42, 255}

PIT_COLOR  :: rl.Color{ 10,  10,  16, 255}
RUNE_COLOR :: rl.Color{236, 200,  92, 255}
WOLF_COLOR :: rl.Color{132, 138, 156, 255}
WOLF_DARK  :: rl.Color{ 92,  98, 116, 255}

// 0 at deepest night, 1 at full noon; cycles smoothly with run time.
day_factor :: proc() -> f32 {
	ph := g.run_time / DAY_LENGTH
	return 0.5 + 0.5 * math.sin(ph * math.TAU)
}

// ---- Particles -------------------------------------------------------------------
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
		}
	}
}

// Grey puffs kicked up on take-off and landing.
spawn_dust :: proc(pos: Vec2, count: int) {
	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})
	}
}

// A colourful blast when the run ends.
spawn_burst :: proc(pos: Vec2, count: int) {
	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)])
	}
}

// A small gold shimmer when a rune is collected.
spawn_sparkle :: proc(pos: Vec2) {
	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)
	}
}

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 (component-wise on the Vec2)
		p.pos += p.vel * dt
		p.life -= dt
		if p.life <= 0 do p.active = false
	}
}

// ---- Screen shake ----------------------------------------------------------------
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 = {}
	}
}

hud.odin

// hud.odin: the HUD and the title/game-over overlays. Text uses raylib's built-in
// default font (no font file shipped) and the temp allocator for per-frame cstrings.
package main

import "core:fmt"
import rl "vendor:raylib"

draw_hud :: proc() {
	switch g.state {
	case .Title:
		draw_title()
	case .Playing:
		draw_run_hud()
	case .Dead:
		draw_run_hud()
		draw_game_over()
	}
}

// fmt.ctprintf formats into a temporary cstring, freed automatically each frame: no
// manual allocation, and exactly what raylib's text procs want.
draw_run_hud :: proc() {
	rl.DrawText(fmt.ctprintf("SCORE %d", g.score), 20, 18, 30, rl.WHITE)
	rl.DrawText(fmt.ctprintf("RUNES %d", g.runes_got), 20, 54, 22, RUNE_COLOR)

	best := fmt.ctprintf("BEST %d", g.best)
	bw := rl.MeasureText(best, 24)
	rl.DrawText(best, i32(WINDOW_W) - 20 - bw, 20, 24, rl.Color{200, 205, 220, 255})

	// A thin speed bar along the bottom.
	frac := (g.speed - START_SPEED) / (MAX_SPEED - START_SPEED)
	rl.DrawRectangleRec({0, WINDOW_H - 6, f32(WINDOW_W), 6}, rl.Fade(rl.BLACK, 0.3))
	rl.DrawRectangleRec({0, WINDOW_H - 6, f32(WINDOW_W) * frac, 6}, rl.Color{240, 180, 70, 255})
}

draw_center :: proc(text: cstring, y, size: i32, color: rl.Color) {
	w := rl.MeasureText(text, size)
	rl.DrawText(text, i32(WINDOW_W) / 2 - w / 2, y, size, color)
}

draw_title :: proc() {
	rl.DrawRectangle(0, 0, WINDOW_W, WINDOW_H, rl.Fade(rl.BLACK, 0.25))
	draw_center("SKÖLL", 150, 96, rl.WHITE)
	draw_center("the wolf that chases the sun", 250, 24, rl.Color{210, 214, 228, 255})
	draw_center("press SPACE to run", 330, 30, rl.Color{240, 200, 90, 255})
	draw_center("SPACE / UP jump   -   M mute", 386, 20, rl.Color{170, 176, 190, 255})
}

draw_game_over :: proc() {
	rl.DrawRectangle(0, 0, WINDOW_W, WINDOW_H, rl.Fade(rl.BLACK, 0.5))
	draw_center("YOU FELL", 140, 80, rl.WHITE)
	draw_center(fmt.ctprintf("score %d", g.score), 240, 34, rl.Color{220, 224, 236, 255})
	if g.new_best {
		draw_center("NEW BEST!", 292, 30, rl.Color{240, 200, 90, 255})
	} else {
		draw_center(fmt.ctprintf("best %d", g.best), 292, 26, rl.Color{190, 196, 210, 255})
	}
	draw_center("press SPACE to run again", 372, 26, rl.Color{170, 176, 190, 255})
}

sound.odin

// sound.odin: every sound synthesised at runtime, no audio files. One-shot effects are
// baked into a Wave and played; the continuous wind rumble is generated live by an
// AudioStream callback.
package main

import "base:runtime"
import "core:c"
import "core:math"
import "core:math/rand"
import rl "vendor:raylib"

SAMPLE_RATE :: 22050

Snd :: enum {
	Jump,
	Rune,
	Hit,
}

g_sounds:       [Snd]rl.Sound
g_stream:       rl.AudioStream
g_muted:        bool
g_wind_level:   f32 // current amplitude, 0..1 (smoothed toward the target)
g_wind_target:  f32
g_wind_freq:    f32 = 90
g_stream_phase: f32 // oscillator phase, advanced inside the callback

init_audio :: proc() {
	rl.InitAudioDevice() // GOTCHA: every audio call below must come after this
	g_sounds[.Jump] = gen_sound(.Jump)
	g_sounds[.Rune] = gen_sound(.Rune)
	g_sounds[.Hit] = gen_sound(.Hit)

	g_stream = rl.LoadAudioStream(SAMPLE_RATE, 16, 1)
	rl.SetAudioStreamCallback(g_stream, wind_callback)
	rl.PlayAudioStream(g_stream)
}

shutdown_audio :: proc() {
	for s in g_sounds do rl.UnloadSound(s)
	rl.UnloadAudioStream(g_stream)
	rl.CloseAudioDevice()
}

// Fill a mono 16-bit Wave with a short synthesised effect, then hand it to raylib.
gen_sound :: proc(which: Snd) -> rl.Sound {
	dur, decay: f32
	switch which {
	case .Jump: dur, decay = 0.16, 18
	case .Rune: dur, decay = 0.22, 12
	case .Hit:  dur, decay = 0.35, 8
	}

	frames := int(SAMPLE_RATE * dur)
	samples := make([]i16, frames)
	defer delete(samples) // raylib copies the frames on load, so we can free ours

	for i in 0 ..< frames {
		t := f32(i) / SAMPLE_RATE
		env := math.exp(-t * decay) * min(1, t / 0.004) // fast attack, exponential decay
		s: f32
		switch which {
		case .Jump:
			freq := 380 + 300 * (t / dur) // rising blip
			s = math.sin(t * freq * math.TAU)
		case .Rune:
			s = math.sin(t * 880 * math.TAU) * 0.7 + math.sin(t * 1320 * math.TAU) * 0.3
		case .Hit:
			s = math.sin(t * 90 * math.TAU) * 0.6 + rand.float32_range(-1, 1) * 0.4
		}
		samples[i] = i16(clamp(s * env, -1, 1) * 32000)
	}

	wave := rl.Wave {
		frameCount = u32(frames),
		sampleRate = SAMPLE_RATE,
		sampleSize = 16,
		channels   = 1,
		data       = rawptr(raw_data(samples)),
	}
	return rl.LoadSoundFromWave(wave)
}

// Runs on raylib's audio thread: it must re-establish its own Odin context, and it may
// only touch a couple of plain globals (a benign read of the wind level/frequency).
wind_callback :: proc "c" (buffer: rawptr, frames: c.uint) {
	context = runtime.default_context()
	out := ([^]i16)(buffer)
	for i in 0 ..< int(frames) {
		g_stream_phase += g_wind_freq / SAMPLE_RATE
		if g_stream_phase > 1 do g_stream_phase -= 1
		s := math.sin(g_stream_phase * math.TAU) * g_wind_level * 0.5
		out[i] = i16(s * 32000)
	}
}

set_wind_target :: proc(v: f32) {
	g_wind_target = clamp(v, 0, 1)
}

// Smoothly track the target amplitude and let the pitch rise with the run speed.
update_wind :: proc(dt: f32) {
	target := g_wind_target
	if g_muted do target = 0
	g_wind_level = approach(g_wind_level, target, dt * 1.5)
	frac := (g.speed - START_SPEED) / (MAX_SPEED - START_SPEED)
	g_wind_freq = 70 + 60 * clamp(frac, 0, 1)
}

play_sound :: proc(which: Snd) {
	if !g_muted do rl.PlaySound(g_sounds[which])
}

toggle_mute :: proc() {
	g_muted = !g_muted
}

save.odin

// save.odin: persist the high score to a small text file. Loading always tolerates a
// missing or unparseable file (a first-ever run) by returning 0, never crashing.
package main

import "core:os"
import "core:strconv"

SAVE_FILE :: "skoll_best.txt"

load_best :: proc() -> int {
	data, err := os.read_entire_file_from_path(SAVE_FILE, context.allocator)
	if err != nil do return 0 // no file yet, first run
	defer delete(data)
	n, pok := strconv.parse_int(string(data))
	if !pok do return 0 // garbled, treat as unset
	return n
}

save_best :: proc(v: int) {
	buf: [32]u8
	s := strconv.write_int(buf[:], i64(v), 10)
	_ = os.write_entire_file(SAVE_FILE, transmute([]u8)s) // best-effort; ignore write errors
}

run.sh

#!/usr/bin/env sh
# run.sh, the everyday loop: compile and launch Sköll in one step.
# `odin run` builds a throwaway binary and runs it immediately.
set -e
odin run . -out:skoll

build.sh

#!/usr/bin/env sh
# build.sh, a release build for the host platform: one optimised, self-contained
# native binary. raylib is linked in from the Odin distribution, so there is nothing
# to ship beside the executable.
#
# On Windows the output is skoll.exe; cross-compile with, e.g.:
#   odin build . -out:skoll.exe -o:speed -target:windows_amd64
set -e

OUT=skoll
[ "$(uname)" = "Windows_NT" ] && OUT=skoll.exe   # (Git Bash etc.)

odin build . -out:"$OUT" -o:speed

# Soft size check: warn, don't fail.
SIZE=$(wc -c < "$OUT")
echo "Built $OUT (${SIZE} bytes)"
[ "$SIZE" -gt 8000000 ] && echo "note: binary is over 8 MB" || true