Chapter 2: Enough Odin to be dangerous
This chapter: a fast, code-first tour of exactly the Odin the game uses. Not the whole language, just the parts you'll meet again in Destroids. Everything here compiles; type it into a scratch package and run it with odin run scratch.
Where we left off: you have a working toolchain and a hello-world.
2.1 Packages and imports
A directory is a package. Files in it share a namespace and start with package <name>. You import the standard library from the core: collection:
import "core:fmt"
import "core:math"
import "core:math/rand"
Refer to a package's contents by its last path element: fmt.println, math.sqrt, rand.float32. Later we'll import our own batch package by relative path (import batch "../batch").
2.2 Declarations and procedures
Odin's core syntax is name :: value for compile-time constants (including procedures and types) and name := value for variables:
MAX_BULLETS :: 6 // constant
count := 0 // variable, type inferred (int)
speed: f32 = 520 // variable, explicit type
add :: proc(a, b: int) -> int {
return a + b
}
Procedures can return multiple values and take default arguments:
spawn_ship :: proc(protected: bool = false) -> (ok: bool) {
return true
}
2.3 Types and conversion
Odin has the fixed-width numerics you'd expect (i32, u32, f32, f64, int, uint, bool) and does not implicitly convert between them. You convert explicitly, which prevents a whole class of silent bugs:
n: int = 46
r := f32(n) // int -> f32, explicit
i := int(r) // f32 -> int, explicit (truncates)
This feels strict at first and quickly becomes a comfort: the compiler never quietly reinterprets your numbers.
2.4 Structs
Structs group fields. Every type has a zero value, so a freshly declared struct is fully initialised to zeros/false without you writing a constructor:
Ship :: struct {
pos: Vec2,
vel: Vec2,
angle: f32,
alive: bool,
}
s: Ship // all fields zero/false
s.pos = {10, 20} // {10, 20} is a compound literal, inferred as Vec2
2.5 Arrays, three ways
This distinction matters more in Odin than in most languages, because it decides whether memory is allocated.
- Fixed-size array
[N]T: N is part of the type; lives inline (on the stack, or inside a struct/global). No allocation. - Slice
[]T: a view, a pointer + a length. Borrows storage it doesn't own. - Dynamic array
[dynamic]T: grows at runtime; allocates from an allocator.
bullets: [6]Bullet // fixed: six bullets, inline, no allocation
xs := []int{1, 2, 3} // slice literal
view := bullets[:] // a slice viewing the whole fixed array
Destroids uses fixed arrays almost everywhere: every ship, bullet, asteroid and particle lives in a [N]T with an active flag. No dynamic arrays, no allocator, no per-frame heap churn. We'll return to this pattern in Chapter 7; it's the backbone of the game's memory model.
2.6 Array programming: [2]f32 as a vector
An Odin fixed array of a numeric type is a vector: arithmetic operators work component-wise, and scalars broadcast. This is the single most useful thing in the language for 2D games. We name it:
Vec2 :: [2]f32
a := Vec2{3, 4}
b := Vec2{1, 1}
c := a + b * 2 // => {5, 6} (component-wise, scalar broadcast)
mag := math.sqrt(a.x * a.x + a.y * a.y) // => 5
You index components with .x/.y and can swizzle:
a.yx // => {4, 3}
Every position, velocity and force in the game is a Vec2, and updates read like the physics they model: ship.pos += ship.vel * dt.
2.7 Enums, switch, and distinct types
Size :: enum { Small, Medium, Large }
sz := Size.Large
r: f32
switch sz {
case .Small: r = 14
case .Medium: r = 26
case .Large: r = 46
}
switch has no implicit fallthrough: each case stands alone (use fallthrough if you really want it). Inside a switch on an enum you can abbreviate Size.Large to .Large.
A distinct type is a fresh type with the same representation as its base, useful for a value that's "just a number" but shouldn't be mixed with other numbers:
Handle :: distinct u32 // an opaque id; not interchangeable with a plain u32
h := Handle(7)
raw := u32(h) // convert explicitly when you actually want the number
We'll use exactly this for Handle, the opaque id of a browser object, in Chapter 5.
2.8 Pointers and iterating by reference
^T is a pointer to T; &x takes an address; p^ dereferences. To mutate elements while iterating, bind them by reference with &:
move :: proc(s: ^Ship, d: Vec2) {
s.pos += d // no explicit deref needed for field access
}
ships: [4]Ship
for &sh in ships { // &sh is ^Ship, writes go back into the array
sh.alive = true
}
Without the &, for sh in ships gives you a copy each iteration and your writes vanish. You'll see for &x in xs throughout the game.
2.9 Control flow, ranges, and defer
for i in 0 ..< 6 { } // 0,1,2,3,4,5 (half-open)
for i in 0 ..= 6 { } // 0..6 (inclusive)
for x in xs { } // over a slice/array (by value)
if speed > MAX { speed = MAX }
defer schedules a statement to run at scope exit, last-scheduled-first. It's how you keep cleanup next to acquisition:
data, _ := os.read_entire_file_from_path(path, context.allocator)
defer delete(data) // runs when the procedure returns
2.10 Context and calling conventions (a first look)
Two Odin features exist mainly because we're about to talk to JavaScript, so just meet them now; Chapter 3 uses them in anger.
Every ordinary Odin procedure carries an implicit context, a hidden parameter holding the current allocator, logger, and so on. You rarely touch it directly.
A procedure's calling convention can be overridden in quotes:
foo :: proc "c" () { } // C ABI, and NO implicit context
bar :: proc "contextless" () { } // Odin ABI but no context either
proc "c" is what JavaScript can call and what can call C-like foreign functions; it drops the implicit context, which is why WASM entry points must re-establish it with context = runtime.default_context(). That line will appear at the top of every exported procedure from Chapter 3 onward; you'll understand exactly why by the end of it.
2.11 The standard library we'll use
Only a handful of core: packages, all documented at https://pkg.odin-lang.org/:
| Package | For |
|---|---|
core:fmt | printing (native code only, not in the wasm game) |
core:math | sin, cos, sqrt, pow_f32, TAU |
core:math/rand | seeded randomness for spawns and effects |
core:strconv | integers โ text without fmt (for the HUD in the wasm build) |
core:net, core:os | the web server (Chapter 4) |
base:runtime | runtime.default_context() at the WASM boundary |
Checkpoint
You can read and write Odin's declarations, procedures, structs, the three array kinds, Vec2 array programming, enums/switch/distinct, pointers and for &x, and you've met context and calling conventions. That's every language feature Destroids needs.
Exercises
- Write a
Vec2lengthproc and anormaliseproc (divide by length; guard length 0). - Make a
[8]Bulletfixed array, set every third one'sactiveto true withfor &b, i in bullets, and count how many are active. - Add a
Size.Hugecase to the enum and let the compiler show you theswitchit now flags as non-exhaustive (if you write#partial, it won't; try both).
Further reading
- The Odin Overview: https://odin-lang.org/docs/overview/, deep sections on arrays, structs, enums, procedures,
context, and calling conventions. - Package documentation: https://pkg.odin-lang.org/
- Karl Zylinski, Understanding the Odin Programming Language: https://odinbook.com/, for everything this chapter left out.