Odin't Know That · Chapter 3

Chapter 3: A window, a loop, and the raylib mental model

This chapter:

  • Understand raylib as a C library that Odin calls, and what "immediate mode" means.
  • Nail the frame lifecycle with defer, colours, and the coordinate system.
  • Get delta-time from GetFrameTime, and clamp its spikes.
  • Meet the one interop trap that bites everyone: cstring lifetimes.

Where we left off: a blank window that opens and closes.


3.1 What "immediate mode" means

Most game engines ask you to build a scene: you create objects, add them to a tree, and the engine redraws them for you. raylib works the other way: immediate mode. There is no scene. Each frame you clear the screen and issue drawing commands, and whatever you draw is the frame. Next frame, you do it all again.

That sounds wasteful and turns out to be liberating. The picture on screen is always exactly what your code drew this frame, in the order it drew it. There's no retained state to get out of sync, no "why is that object still on screen": if you didn't draw it this frame, it isn't there. The whole of Sköll's rendering is one draw_game() procedure called once per frame, top to bottom.

3.2 Odin is calling C

raylib is written in C. The thing under vendor:raylib is a set of Odin bindings: declarations that say "there is a C procedure called InitWindow that takes these arguments", plus the prebuilt raylib library to link against. When you call rl.InitWindow(...), you're calling straight into C.

Most of the time this is invisible: you call rl.DrawCircleV(centre, r, colour) and it just works. But three C-isms leak through, and it's worth naming them now:

  • cstring: C strings are zero-terminated pointers, not Odin's (pointer, length) string. raylib text and titles are cstring. (More in §3.6: it has a sharp edge.)
  • c.int / c.uint: raylib's integer parameters are C ints. From core:c. You'll convert to and from Odin's int occasionally.
  • The value types are shared. raylib's Vector2 ([2]f32), Rectangle (a struct of four f32) and Color (distinct [4]u8) are plain data, and we use them directly as our own types.

Why this is the boundary lesson. Every language that talks to the outside world has a boundary where its guarantees meet someone else's rules. In the sibling book Yo Odin (https://nofuss.co.za/yo_odin/), that boundary is WebAssembly↔JavaScript, and it's a big deal. Here it's Odin↔C, and raylib has done most of the hard work, but cstring still demands respect.

3.3 The lifecycle, with defer

The shape from Chapter 1, now with intent. raylib has Init…/Close… pairs (the window, later the audio device) and Begin…/End… pairs (drawing, later the 2D camera mode). Every one of them wants to be balanced, and defer is how we guarantee it:

rl.InitWindow(960, 540, "Sköll")
defer rl.CloseWindow()          // balances InitWindow

rl.SetTargetFPS(60)

for !rl.WindowShouldClose() {
	rl.BeginDrawing()
	defer rl.EndDrawing()       // balances BeginDrawing, every frame
	// ... draw ...
}

Putting the End/Close immediately after its Begin/Init, as a defer, means you literally cannot forget it, and it survives any early return or continue you add later.

3.4 Colours and coordinates

rl.Color is four bytes: red, green, blue, alpha (opacity), each 0–255:

BG   :: rl.Color{18, 18, 28, 255}   // opaque dark blue-grey
GOLD :: rl.Color{236, 200, 92, 255}

raylib also predefines names like rl.WHITE, rl.BLACK, rl.RED. The game defines its own palette as named constants (all synthesised, no image anywhere), which you'll build in Chapter 8.

The coordinate system: (0, 0) is the top-left corner, x grows right, y grows down. A window 960 wide and 540 tall has its bottom-right at (960, 540). "Up" on screen is negative y, which is why a jump, in Chapter 4, sets an upward velocity that's negative.

3.5 Drawing primitives

A handful of procedures draw everything in Sköll. Meet them by drawing a tiny scene between BeginDrawing and EndDrawing:

rl.ClearBackground(BG)
rl.DrawRectangleRec({100, 100, 120, 60}, rl.Color{120, 122, 134, 255}) // x, y, w, h
rl.DrawCircleV({400, 200}, 40, GOLD)                                    // centre, radius
rl.DrawLineEx({100, 300}, {500, 300}, 4, rl.WHITE)                      // a, b, thickness
rl.DrawTriangle({600, 260}, {560, 320}, {640, 320}, rl.RED)            // three vertices
rl.DrawPoly({760, 200}, 4, 30, 45, GOLD)                                // centre, sides, r, rotation
  • DrawRectangleRec takes an rl.Rectangle{x, y, width, height}: the top-left corner and a size.
  • The …V procedures (DrawCircleV, DrawLineEx endpoints) take Vector2 positions: our Vec2.
  • DrawPoly draws a regular polygon; four sides rotated 45° is a diamond, which is exactly how the runes are drawn.

That's essentially the entire art toolkit. The wolf, the hills, the obstacles and the runes are all combinations of these.

3.6 cstring lifetimes: the trap

You'll want to draw text: a score, a title. raylib's text procedure is:

rl.DrawText(text: cstring, posX, posY, fontSize: c.int, color: rl.Color)

A string literal converts to cstring for free, so this is fine:

rl.DrawText("SKÖLL", 20, 20, 40, rl.WHITE)

The trap is dynamic text: a score that changes every frame. You cannot hand raylib an Odin string directly (different layout, no terminator), and if you build a cstring by allocating one you have to free it or you leak a little memory every single frame. The clean answer Odin gives you is the temporary allocator and fmt.ctprintf:

import "core:fmt"

rl.DrawText(fmt.ctprintf("SCORE %d", score), 20, 20, 30, rl.WHITE)

ctprintf formats into a cstring allocated from the per-frame temp allocator, memory that is wiped in bulk each frame, so you never free it yourself and never leak. This is exactly how the HUD is drawn in Chapter 9.

Gotcha: don't hold a cstring past the frame. A cstring from ctprintf (or from strings.clone_to_cstring with the temp allocator) is valid only for this frame. Never stash one in a struct to use next frame; the memory behind it will have been recycled and you'll draw garbage or crash. Format text at the moment you draw it, every frame. That's not wasteful; it's the immediate-mode way.

3.7 Time: dt and the spike

Movement must be measured in units per second, not per frame, or your game runs at different speeds on different machines. raylib gives you the length of the last frame, the delta time, in seconds:

dt := rl.GetFrameTime()   // e.g. ~0.0167 at 60fps
x += 200 * dt             // move 200 pixels per second, whatever the frame rate

Multiply every rate by dt and speeds become real-world speeds. Try it: a rectangle that crosses the screen at a steady pace and wraps:

BOX_SPEED :: 260.0

x: f32 = 0
for !rl.WindowShouldClose() {
	dt := rl.GetFrameTime()
	x += BOX_SPEED * dt
	if x > 960 do x = -60

	rl.BeginDrawing()
	defer rl.EndDrawing()
	rl.ClearBackground(BG)
	rl.DrawRectangleRec({x, 240, 60, 60}, GOLD)
}

Gotcha: clamp the spike. If the window is dragged, the machine stalls, or you tab away, GetFrameTime() can report a huge dt for one frame, a fifth of a second, say. Feed that into physics and the wolf teleports through a wall. Every frame in the real game clamps it: dt := min(rl.GetFrameTime(), 0.05). A hitch then just means one slightly-long-but-bounded frame, never a physics explosion. We'll rely on this from Chapter 4 onward.

Checkpoint

You understand raylib as a C library, the immediate-mode frame lifecycle balanced with defer, the top-left/y-down coordinate system, the primitive drawing calls, per-second movement with a clamped dt, and, most importantly, how to draw dynamic text safely with ctprintf without leaking. A rectangle slides across your window at a steady real-world speed. Next chapter, that rectangle becomes a wolf that jumps.

Exercises

  1. Draw your name centred-ish with DrawText, then a changing frame counter beneath it using fmt.ctprintf("frame %d", n).
  2. Make the sliding box bounce off both edges instead of wrapping (flip a velocity's sign).
  3. Print rl.GetFrameTime() each frame while dragging the window around; watch the spike, then add the min(…, 0.05) clamp and print again.
  4. Draw a diamond with DrawPoly(centre, 4, radius, 45, colour) and a second, smaller, lighter one on top: the rune look you'll build for real in Chapter 6.

Further reading