Odin & raylib · Lesson 03 · 25 min
Many Units, One Array
Put eighteen units in a dynamic array and drag a selection box over them — the interaction that makes a game an RTS.
One box that moves is a demo. An RTS is many things that move, and the moment it starts to feel like the genre is the moment you drag a rectangle over a group of units and they all light up.
That is what you are building here. It also forces the first question Odin makes you answer that Go and Rust answered for you: who owns this memory, and when does it go away?
The program
package main
import rl "vendor:raylib"
WINDOW_WIDTH :: 960
WINDOW_HEIGHT :: 540
UNIT_RADIUS :: 12
Unit :: struct {
pos: rl.Vector2,
selected: bool,
}
selection_rect :: proc(a, b: rl.Vector2) -> rl.Rectangle {
return {
x = min(a.x, b.x),
y = min(a.y, b.y),
width = abs(a.x - b.x),
height = abs(a.y - b.y),
}
}
main :: proc() {
rl.SetConfigFlags({.VSYNC_HINT})
rl.InitWindow(WINDOW_WIDTH, WINDOW_HEIGHT, "Selection")
defer rl.CloseWindow()
units: [dynamic]Unit
defer delete(units)
for row in 0 ..< 3 {
for col in 0 ..< 6 {
append(&units, Unit{pos = {200 + f32(col) * 60, 160 + f32(row) * 70}})
}
}
drag_start: rl.Vector2
dragging := false
for !rl.WindowShouldClose() {
mouse := rl.GetMousePosition()
if rl.IsMouseButtonPressed(.LEFT) {
drag_start = mouse
dragging = true
}
box := selection_rect(drag_start, mouse)
if rl.IsMouseButtonReleased(.LEFT) {
for &unit in units {
unit.selected = rl.CheckCollisionCircleRec(unit.pos, UNIT_RADIUS, box)
}
dragging = false
}
rl.BeginDrawing()
defer rl.EndDrawing()
rl.ClearBackground({34, 36, 40, 255})
for unit in units {
rl.DrawCircleV(unit.pos, UNIT_RADIUS, rl.SKYBLUE)
if unit.selected {
rl.DrawCircleLinesV(unit.pos, UNIT_RADIUS + 4, rl.LIME)
}
}
if dragging {
rl.DrawRectangleRec(box, rl.Fade(rl.LIME, 0.15))
rl.DrawRectangleLinesEx(box, 1, rl.LIME)
}
}
}
Run it
odin run .
Drag a rectangle across the grid. On release, every unit the box touched gets a green ring; everything else loses it. Drag on empty ground to deselect.
[dynamic]Unit — a growable array you own
units: [dynamic]Unit
defer delete(units)
append(&units, Unit{pos = {200, 160}})
Three things are happening, and each has a Go or Rust equivalent worth naming so you can stop translating and start reading.
| Odin | Go | Rust |
|---|---|---|
units: [dynamic]Unit |
var units []Unit |
let mut units: Vec<Unit> |
append(&units, u) |
units = append(units, u) |
units.push(u) |
delete(units) |
(GC handles it) | (drop handles it) |
The third row is the one that matters. Odin has no garbage collector and no borrow
checker. Nothing frees units for you and nothing tracks who else is holding it. The
memory is yours until you call delete.
Odin’s answer to that is not a type-system feature, it is a habit: write the delete
on the line after the thing is created.
units: [dynamic]Unit
defer delete(units) // written now, runs when main returns
defer puts the release next to the acquire, where you can see both at once. It is
weaker than Rust’s guarantee — you can still forget — but it is visible, and a reviewer
scanning the file can check it without leaving the screen.
Why append takes &units
A [dynamic]T is a pointer, a length, and a capacity. When it outgrows its
capacity, append allocates a bigger buffer and rewrites the pointer
— so it needs the address of the array itself, not a copy of it. That is the whole reason
for the &, and it is the same reason Go makes you write
units = append(units, u).
It also means a pointer into a dynamic array can be invalidated by an append. Hold on to that. It is the bug you will meet when units start spawning mid-game.
for &unit in units — iterating by pointer
Look closely at the two loops. They are different on purpose.
for &unit in units { // by POINTER — writes go back to the array
unit.selected = ...
}
for unit in units { // by VALUE — a copy, read-only in effect
rl.DrawCircleV(unit.pos, ...)
}
Odin iterates by value by default: for unit in units hands you a copy of each Unit.
Prefix the name with & and you get a pointer instead, so writes land in the array.
The good news is that Odin does not let you get this wrong quietly. Drop the & from the
update loop and the compiler stops you, by name:
Error: Cannot assign to 'unit.selected'
for unit in units { unit.selected = ... }
^~~^
'unit' is immutable, declare it as '&unit' to make it mutable
The loop value is immutable, not merely a copy — so the mistake is a compile error
with the fix written into the message, rather than a bug you find twenty minutes later.
Coming from Rust this will feel familiar: it is the same distinction as iter() versus
iter_mut(), enforced at the same moment, just spelled with one character.
What Odin does not do is decide for you. The update loop needs to write, so it takes
&. The draw loop only reads, so it does not. Making that call deliberately, loop by
loop, is where “data-oriented” starts to mean something in practice.
Press, release, and the rectangle in between
The selection box needs three pieces of state, and it is worth seeing why:
if rl.IsMouseButtonPressed(.LEFT) { // the ONE frame the button goes down
drag_start = mouse
dragging = true
}
box := selection_rect(drag_start, mouse) // recomputed every frame while dragging
if rl.IsMouseButtonReleased(.LEFT) { // the ONE frame it comes up
// commit the selection
}
Pressed and Released are edges — true for exactly one frame. Down is a level — true
for as long as you hold. Selection is an edge-triggered interaction with a level-triggered
preview in the middle, and getting that wrong is why hand-rolled selection boxes so often
feel broken.
selection_rect exists because rl.Rectangle cannot represent a negative width. Drag
up-and-left and mouse - drag_start is negative on both axes, so you normalise with
min and abs — both builtins in Odin, no import:
return {
x = min(a.x, b.x),
y = min(a.y, b.y),
width = abs(a.x - b.x),
height = abs(a.y - b.y),
}
That bare { ... } is a composite literal with no type name in front of it. Odin
knows the procedure returns rl.Rectangle, so it infers the type from context — the same
inference that lets you write .LEFT instead of rl.MouseButton.LEFT.
Recall
What happens if you drop the & from the update loop?
- It fails to compile, naming the fix
- It compiles, and the writes vanish
Odin iterates by value and the loop variable is immutable, so assigning to it is a compile error that tells you to write &unit. Odin makes you choose, but it does not let you choose wrong silently.
Recall
What frees the memory behind units: [dynamic]Unit?
- Your own call to
delete - The runtime, once nothing refers
There is no garbage collector and no ownership tracking. defer delete(units) written next to the declaration is the convention that keeps it visible.
Prove it
Three changes, in order
- Break the pointer rule on purpose. Change the update loop from
for &unit in unitstofor unit in unitsand build it. Read the error the compiler gives you, in full — that message is the one you will meet most often while learning Odin, and recognising it on sight is worth more than the thirty seconds it takes. Put the&back. - Add shift to extend the selection. Right now every drag replaces the
selection. Make it so that holding Shift adds to it instead:
rl.IsKeyDown(.LEFT_SHIFT), and inside the loop use|=rather than=. - Show the count. Draw "N selected" in the corner. You need a
cstring, so this isfmt.ctprintf— see the Odin cheatsheet. Addfree_all(context.temp_allocator)as the last statement in the loop body, and be ready to explain to yourself why it belongs there.
Change 3 is the first time you allocate every single frame. Leave the free_all out, run
it for a minute, and watch the process memory climb — that is the lesson, and it is worth
seeing once with your own eyes.
The bug waiting for you
Everything above stores units in one flat array and identifies them by position in that array. That works exactly until units start dying. Delete unit 3 from the middle and every index after it shifts, so any "selected unit index" you were holding now points at the wrong unit — or off the end.
Odin ships a container built for precisely this, at
/usr/lib/odin/core/container/handle_map/. You do not need it yet. You will.
Primary source
The Odin Overview —
Dynamic arrays, Slices, and the for section
covering & iteration. Then, when you want the argument behind all of it,
Mike Acton's "Data-Oriented Design
and C++": it is a C++ talk, but Odin was designed by someone who agrees with it, and
it explains why your units live in one array rather than as eighteen separate objects.
Previous: The Game Loop and Delta Time · Reference: Odin cheatsheet · raylib quick reference
Next up is move orders — right-click, and the selected units actually go there. Before that: if change 1 surprised you, or if you want to know why Odin chose a convention where Rust chose a compiler, ask me. That conversation is worth more than the next lesson.