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