Yo Odin ยท Chapter 2

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/:

PackageFor
core:fmtprinting (native code only, not in the wasm game)
core:mathsin, cos, sqrt, pow_f32, TAU
core:math/randseeded randomness for spawns and effects
core:strconvintegers โ†’ text without fmt (for the HUD in the wasm build)
core:net, core:osthe web server (Chapter 4)
base:runtimeruntime.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

  1. Write a Vec2 length proc and a normalise proc (divide by length; guard length 0).
  2. Make a [8]Bullet fixed array, set every third one's active to true with for &b, i in bullets, and count how many are active.
  3. Add a Size.Huge case to the enum and let the compiler show you the switch it now flags as non-exhaustive (if you write #partial, it won't; try both).

Further reading