Odin & raylib · Lesson 02 · 20 min
The Game Loop and Delta Time
Move a box with the arrow keys at the same speed on any monitor, and meet Odin's array programming along the way.
Lesson 1 drew something. This one makes it move — and move at the same speed whether you are on a 60 Hz laptop panel or a 144 Hz monitor. That last clause is the whole lesson. Nearly every “my game runs at double speed on my friend’s machine” bug is the thing you are about to learn to avoid.
The program
Start a new folder and a new main.odin:
package main
import rl "vendor:raylib"
WINDOW_WIDTH :: 960
WINDOW_HEIGHT :: 540
PLAYER_SIZE :: 32
PLAYER_SPEED :: 420 // pixels per second
main :: proc() {
rl.SetConfigFlags({.VSYNC_HINT})
rl.InitWindow(WINDOW_WIDTH, WINDOW_HEIGHT, "Moving box")
defer rl.CloseWindow()
player := rl.Vector2{WINDOW_WIDTH / 2, WINDOW_HEIGHT / 2}
for !rl.WindowShouldClose() {
dt := rl.GetFrameTime()
input: rl.Vector2
if rl.IsKeyDown(.LEFT) { input.x -= 1 }
if rl.IsKeyDown(.RIGHT) { input.x += 1 }
if rl.IsKeyDown(.UP) { input.y -= 1 }
if rl.IsKeyDown(.DOWN) { input.y += 1 }
player += input * PLAYER_SPEED * dt
rl.BeginDrawing()
defer rl.EndDrawing()
rl.ClearBackground(rl.RAYWHITE)
rl.DrawRectangleV(player, {PLAYER_SIZE, PLAYER_SIZE}, rl.MAROON)
rl.DrawFPS(8, 8)
}
}
Run it
odin run .
Arrow keys move the box. The FPS counter in the corner should sit at your monitor's refresh rate.
Notice the shape of the loop body. It is the shape every game loop has, forever:
- Measure how long the last frame took.
- Read input.
- Update the world by that much time.
- Draw the world.
Why * dt
rl.GetFrameTime() returns the length of the previous frame in seconds — roughly
0.0167 at 60 fps, 0.0069 at 144 fps. Everyone calls it dt, for delta time.
PLAYER_SPEED :: 420 is not “420 pixels per frame”. It is 420 pixels per second, and
speed * dt converts a rate into “how far in this frame”:
| Refresh rate | dt |
pixels moved this frame | pixels per second |
|---|---|---|---|
| 60 Hz | 0.0167 | 7.0 | 420 |
| 144 Hz | 0.0069 | 2.9 | 420 |
| 30 Hz (struggling) | 0.0333 | 14.0 | 420 |
Different work per frame, identical result per second. Without * dt the box would move
420 pixels per frame, which is 25,200 px/s at 60 Hz and 60,480 px/s at 144 Hz — the
classic bug.
The habit
Any quantity that describes a rate — movement, rotation, cooldowns, fuel drain,
score decay — gets multiplied by dt. Write speeds in per-second units and the
multiplication becomes automatic. Anything you write in per-frame units is a bug waiting
for a faster monitor.
rl.SetConfigFlags({.VSYNC_HINT}) asks the GPU to pace frames to the monitor, which is
generally better than SetTargetFPS: smoother, and it stops the loop burning a whole CPU
core. It must be called before InitWindow, because it configures the window that is
about to be created.
Three Odin ideas doing the work
Vector2 is a fixed-length array, and arrays do arithmetic
This is the line worth staring at:
player += input * PLAYER_SPEED * dt
In the bindings, Vector2 :: [2]f32 — a plain two-element array of f32. Odin supports
array programming: arithmetic operators work component-wise on fixed-length arrays,
and a scalar on one side is applied to every component.
a := [2]f32{1, 2}
b := [2]f32{10, 20}
a + b // {11, 22} component-wise
a * b // {10, 40} component-wise, NOT a dot product
a * 3 // {3, 6} scalar applied to both components
So input * PLAYER_SPEED * dt scales both components by two scalars, and += adds the
result component-wise. No vector class, no operator overloading, no library — it is in the
language. .x and .y also work as component accessors on any array of length 4 or
under, which is why input.x -= 1 is legal.
Variables are zero-initialised, always
input: rl.Vector2
No = {0, 0}. In Odin every variable starts zeroed unless you say otherwise, and the
language is designed so that zero is a useful default: a zeroed Vector2 is the origin, a
zeroed bool is false, a zeroed slice is empty and safe to range over. Coming from a
higher-level language this feels normal; coming from C it is the thing people miss most.
If you explicitly want uninitialised memory you must ask: input: rl.Vector2 = ---.
The enum type is inferred, so you write .LEFT
rl.IsKeyDown(.LEFT)
rl.SetConfigFlags({.VSYNC_HINT})
IsKeyDown takes a KeyboardKey, so Odin infers the type and .LEFT is enough — this is
an implicit selector expression. You never type rl.KeyboardKey.LEFT.
The second line is doing something extra. SetConfigFlags takes a
bit_set[ConfigFlag; c.int] — a set of enum values, stored as bits in an integer, with
set literal syntax. Where C would hand you int flags and trust you to OR the right
constants together, Odin gives you a type that only accepts members of that one enum:
rl.SetConfigFlags({.VSYNC_HINT, .MSAA_4X_HINT, .WINDOW_RESIZABLE})
Same machine code as C’s bit flags, but you cannot pass a flag from the wrong enum, and you cannot pass a number.
Recall
What unit is PLAYER_SPEED :: 420 expressed in?
- Pixels moved per elapsed second
- Pixels moved per drawn frame
Multiplying by dt — seconds elapsed — is what turns a per-second rate into a per-frame distance. Rates go in per-second units; dt does the conversion.
Recall
In Odin, what does [2]f32{1, 2} * [2]f32{10, 20} produce?
- Another array, holding {10, 40}
- A single scalar, holding 50.0
Array operators are component-wise, not linear algebra. A dot product is linalg.dot in core:math/linalg.
Prove it
Three experiments, in order
- See the bug you just avoided. Delete
* dtfrom the movement line and changePLAYER_SPEEDto4. Run it, then replace theSetConfigFlagsline withrl.SetTargetFPS(15)and run again. Same code, different speed. Put it all back. - Keep the box on screen. Odin has a builtin
clamp(value, lo, hi)— no import needed. Clampplayer.xandplayer.yafter the movement line so the box cannot leave the window. Remember the box is drawn from its top-left corner, so the upper bound isWINDOW_WIDTH - PLAYER_SIZE. - Find the diagonal bug. Hold → and ↓ together.
The box moves about 1.41× faster than along an axis, because
{1, 1}is longer than{1, 0}. Fix it by normalising the input vector to length 1 before scaling:
Useimport "core:math/linalg" // ... player += linalg.normalize0(input) * PLAYER_SPEED * dtnormalize0, notnormalize— the0version returns a zero vector instead of dividing by zero when no key is held.
Experiment 3 is a real bug that ships in real games. Now it is one you recognise on sight.
Primary source
The Odin Overview again — the Array programming, Enumerations, and Bit sets sections. They are short, and they cover the three ideas above with non-game examples, which is exactly the kind of second exposure that makes something stick.
Then the raylib
cheatsheet: skim the core module's input and timing sections so you know
what is available before you need it.
Next: Many Units, One Array — eighteen units, a drag-selection box, and the first question Odin makes you answer that Go and Rust answered for you.
Keep the Odin cheatsheet and the raylib quick reference open while you work.
Stuck on the clamp, or the normalise, or wondering why Vector2 is an array
rather than a struct? Ask me — working through your actual broken code is worth more than
another page of prose.