Learning

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:

  1. Measure how long the last frame took.
  2. Read input.
  3. Update the world by that much time.
  4. 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?

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?

Array operators are component-wise, not linear algebra. A dot product is linalg.dot in core:math/linalg.

Prove it

Three experiments, in order

  1. See the bug you just avoided. Delete * dt from the movement line and change PLAYER_SPEED to 4. Run it, then replace the SetConfigFlags line with rl.SetTargetFPS(15) and run again. Same code, different speed. Put it all back.
  2. Keep the box on screen. Odin has a builtin clamp(value, lo, hi) — no import needed. Clamp player.x and player.y after the movement line so the box cannot leave the window. Remember the box is drawn from its top-left corner, so the upper bound is WINDOW_WIDTH - PLAYER_SIZE.
  3. 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:
    import "core:math/linalg"
    // ...
    player += linalg.normalize0(input) * PLAYER_SPEED * dt
    Use normalize0, not normalize — the 0 version 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.