Chapter 2: Enough Odin to be dangerous
This chapter:
- A fast, code-first tour of exactly the Odin the game uses, no more.
- Every feature is motivated by a snippet you'll meet again in Sköll.
Where we left off: you have a working toolchain and a blank window.
This is the one chapter that isn't building the game; it's loading the language into your head so the rest reads easily. Everything here compiles. Make a throwaway package to try things in:
mkdir scratch && cd scratch
Put a main.odin in it with a main :: proc() and run snippets with odin run .. Where a snippet prints, it uses core:fmt; import it with import "core:fmt".
2.1 Packages and imports
A directory is a package. Every .odin file in it begins with package <name> and they share one namespace: no per-file imports leaking across, no header files. You import from collections:
import "core:fmt" // the standard library
import "core:math"
import "core:math/rand"
import rl "vendor:raylib" // the bundled library, renamed to `rl`
core: is Odin's standard library; base: is the lowest-level runtime; vendor: is the bundled third-party bindings, raylib among them. You refer to a package's contents by its last path element, such as fmt.println, math.sqrt, rand.float32, or by the name you gave it in the import, like rl.
Odin note. Odin has no separate "module system" to learn: a package is just a folder. Our whole game is one package (
main) spread over ten files. Order between files never matters; you can call a procedure defined inworld.odinfromplayer.odinwith no forward declaration.
2.2 Declarations and procedures
Odin has two core declaration forms: :: binds a compile-time constant (including procedures and types), and := binds a variable with its type inferred.
MAX_OBSTACLES :: 64 // constant
count := 0 // variable, type inferred as int
speed: f32 = 320 // variable, explicit type
A procedure is just a constant whose value is a proc:
add :: proc(a, b: int) -> int {
return a + b
}
Procedures can return multiple values and take default arguments, both of which the standard library uses heavily:
// From core:strconv, roughly:
parse_int :: proc(s: string, base := 0) -> (value: int, ok: bool) { ... }
n, ok := parse_int("42") // n = 42, ok = true
That (value: int, ok: bool) "result, did-it-work" pair is a common Odin idiom; you'll use it when reading the save file in Chapter 11.
2.3 Types and conversion
Odin has the fixed-width numeric types you'd expect: i8 i16 i32 i64, u8 u16 u32 u64, f32 f64, plus int/uint (pointer-sized) and bool, and it does not convert between them implicitly. You convert explicitly:
n: int = 46
r := f32(n) // int -> f32, explicit
i := int(r) // f32 -> int, explicit (truncates toward zero)
This feels strict for about a day, then becomes a comfort: the compiler never silently reinterprets your numbers. It matters especially at the raylib boundary, because C has its own integer types. raylib procedures that take a C int use Odin's c.int (from core:c), and you'll sometimes convert to or from it:
import "core:c"
w: c.int = rl.GetScreenWidth() // raylib hands back a c.int
half := int(w) / 2 // convert to Odin's int to do int maths
Gotcha: untyped constants have a default type. A constant expression like
2 * 780 / 2200hasn't committed to a type yet; when you finally assign it to a variable with:=, a float constant defaults tof64. That bit us in the real game:air := 2 * JUMP_SPEED / GRAVITYbecame anf64, and multiplying it by anf32speed was a type error. The fix is to pin the type:air: f32 = 2 * JUMP_SPEED / GRAVITY. Keep this in your back pocket for Chapter 4.
2.4 Structs
Structs group fields. Crucially, every type has a zero value, and a freshly declared struct is fully zero-initialised; no constructor needed, no uninitialised memory:
Obstacle :: struct {
active: bool,
kind: ObstacleKind,
x: f32,
}
o: Obstacle // all zero: active=false, kind=0th value, x=0
o2 := Obstacle{active = true, x = 120} // set some fields by name; the rest zero
The game's entities are all plain structs like this. Because active defaults to false, an array of Obstacle starts life as "all slots empty" for free, a fact we'll build the whole entity system on.
2.5 Arrays, three ways
Odin distinguishes three array-like things, and the difference is the difference between allocating and not:
fixed: [64]Obstacle // fixed-size array: 64 obstacles, inline, no allocation
slice: []u8 // slice: a (pointer, length) view into memory someone else owns
dynamic: [dynamic]int // dynamic array: growable, heap-allocated, needs an allocator
- A fixed-size array
[N]Thas its length baked into the type. It lives inline (inside a struct, it's part of the struct; as a global, it's in static memory). No allocator is ever involved. - A slice
[]Tis a lightweight view, a pointer and a length, into an array or buffer that lives elsewhere.buf[:]makes a slice of the whole ofbuf. - A dynamic array
[dynamic]Tcan grow, which means it allocates.
Why the game is almost all fixed-size arrays. A 60fps game must not allocate on the hot path; an allocation mid-frame is a latency spike waiting to happen. So Sköll pre-declares its worlds as fixed arrays:
obstacles: [MAX_OBSTACLES]Obstacle,particles: [MAX_PARTICLES]Particle, and so on. We neverneworappendduring play; we find an empty slot and reuse it. The whole game state is one big struct of fixed arrays, and it costs zero heap allocations per frame. You'll build this pattern in Chapter 6.
2.6 Array programming: [2]f32 is a vector
Here's Odin's quiet superpower for game code. A fixed-size array of numbers supports component-wise arithmetic and scalar broadcast directly:
a := [2]f32{3, 4}
b := [2]f32{1, 1}
c := a + b // {4, 5}, component-wise
d := a * 2 // {6, 8}, scalar broadcast
e := a * b // {3, 4}, component-wise multiply
x := a.x // 3, .x/.y/.z/.w accessors
yx := a.yx // {4, 3}, swizzles
So a 2D position or velocity is just a [2]f32, and moving a thing is pos += vel * dt, no helper functions, no operator overloading you had to write. The whole game leans on this.
And the best part: raylib's own Vector2 type is defined as exactly [2]f32. So we can write
Vec2 :: rl.Vector2 // literally the same type as [2]f32
and our game maths and every raylib call that takes a Vector2 share one type, with no wrapping and no conversion. p.pos += p.vel * dt in our code, then rl.DrawCircleV(p.pos, 10, colour) straight into raylib: same value, no glue.
2.7 Enums, switch, and distinct
An enum is a named set of values:
ObstacleKind :: enum u8 { Rock, Boulder, Stump } // Rock = 0, Boulder = 1, Stump = 2
GameState :: enum { Title, Playing, Dead }
A switch branches on one; Odin's switch does not fall through by default, so no stray break statements, and a switch over an enum is checked for completeness:
switch g.state {
case .Title: draw_title()
case .Playing: draw_run_hud()
case .Dead: draw_run_hud(); draw_game_over()
}
Note .Title: inside a context that already knows the type, you can name a value with a leading dot and drop the ObstacleKind./GameState. prefix.
A neat trick we'll use: an enumerated array [Enum]T has one slot per enum value, and the compiler makes you fill them all:
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}},
}
d := OBSTACLE_DEFS[o.kind] // look up this obstacle's size and colour
That's our single source of truth for obstacle stats: to add a new obstacle kind, add an enum value and the compiler forces you to add its row here.
A distinct type is a fresh type with the same representation as another, so the compiler stops you mixing them up. raylib uses it for colours: Color :: distinct [4]u8. You get array behaviour, but a Color won't silently pass where some other [4]u8 is expected.
2.8 Pointers, and mutating in place
^T is "pointer to T"; &x takes the address of x; p^ dereferences. The place you'll use this constantly is iterating a fixed array and changing each element:
for &o in g.obstacles { // &o is a POINTER to each element; changes stick
if o.active {
o.x -= 100 // mutates the real obstacle in the array
}
}
Without the &, for o in g.obstacles gives you a copy of each element and your writes would evaporate. Every per-entity update loop in the game uses for &thing in ....
You also pass pointers to let a procedure modify your data:
update_player :: proc(dt: f32) {
p := &g.player // a pointer to the one player; write through it
p.pos.x += g.speed * dt
}
2.9 defer, control flow, ranges
You've seen defer pair a setup with its teardown. It runs its statement when the current scope exits, and multiple defers run in reverse order:
init_audio()
defer shutdown_audio() // runs last, when main returns
Odin's for covers every loop shape. A range loop counts:
for i in 0 ..< n { // i = 0, 1, ..., n-1 (half-open: ..< excludes n)
// ...
}
for i in 0 ..= n { // i = 0, 1, ..., n (closed: ..= includes n)
And there's a compact one-line conditional form Odin borrows readability from: do:
if !ok do return 0 // single-statement if
for &r in g.runes do r.bob += dt // single-statement for
We use do for the short, obvious cases and braces for anything with more than one line.
2.10 context and calling conventions (a first look)
Two Odin features underpin how we talk to raylib, introduced gently here and paid off in Chapter 3 and Chapter 10.
context is an implicit, per-goroutine bundle of "ambient" settings, most importantly the current memory allocator and the random number generator, that every ordinary Odin procedure receives invisibly. You rarely touch it, but you can set it for a scope:
context.random_generator = rand.default_random_generator(&g.rng)
// now every rand.* call in this scope (and procs it calls) uses OUR seeded generator
That one line is how the game makes its randomness deterministic (Chapter 6 and 11).
Calling conventions describe how a procedure expects to be called at the machine level. Odin procedures are proc "odin" by default. A C library expects proc "c". raylib's binding handles this for the calls we make into it. But when raylib calls back into us (the audio callback in Chapter 10), we must declare our procedure proc "c", and such a procedure doesn't automatically get a context, so we set one up by hand:
wind_callback :: proc "c" (buffer: rawptr, frames: c.uint) {
context = runtime.default_context() // re-establish an Odin context on this thread
// ...
}
Don't worry about the details yet: just register that "c" and context exist and are about talking to C. We'll meet both properly when they matter.
2.11 The standard library we'll use
A short map of what the game imports from core:/base:, so the names are familiar when they appear:
core:math:sin,cos,exp,floor, and constantsPI,TAU.core:math/rand: seeded random numbers:float32,float32_range,int_max.core:strconv: numbers ↔ text without heap:parse_int,write_int.core:os: reading and writing the save file.core:fmt: formatting; notablyfmt.ctprintfto build a temporarycstringfor raylib text.core:c: the C numeric types (c.int,c.uint) raylib speaks.base:runtime: low-level bits, includingruntime.default_context()for the audio thread.
Browse any of them at https://pkg.odin-lang.org/; the built-in min, max, clamp and abs need no import at all.
Checkpoint
You can read every Odin construct the rest of the book uses: packages, procs with multiple returns, explicit conversions, structs with zero values, the three array kinds, [2]f32 array programming, enums and switch and enumerated arrays, pointers and for &x, defer, ranges, and a first sense of context and calling conventions. Nothing game-shaped yet, but no syntax will surprise you from here.
Exercises
- Make a
Vec2 :: [2]f32, add two of them, scale one by0.5, and print.xand a swizzle. - Declare
[ObstacleKind]intgiving each kind a score, and print the score for.Boulder. - Write a
for &n in arrloop that doubles every element of a[5]int, then print the array, then remove the&and see the difference. - Trigger the untyped-constant gotcha on purpose:
x := 3 / 2versusx: f32 = 3 / 2. Explain the two results.
Further reading
- Odin overview (deep-linked per feature): https://odin-lang.org/docs/overview/
- Package docs: https://pkg.odin-lang.org/
- Odin FAQ: https://odin-lang.org/docs/faq/