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:
cstringlifetimes.
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 arecstring. (More in §3.6: it has a sharp edge.)c.int/c.uint: raylib's integer parameters are Cints. Fromcore:c. You'll convert to and from Odin'sintoccasionally.- The value types are shared. raylib's
Vector2([2]f32),Rectangle(a struct of fourf32) andColor(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
cstringstill 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
DrawRectangleRectakes anrl.Rectangle{x, y, width, height}: the top-left corner and a size.- The
…Vprocedures (DrawCircleV,DrawLineExendpoints) takeVector2positions: ourVec2. DrawPolydraws 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
cstringpast the frame. Acstringfromctprintf(or fromstrings.clone_to_cstringwith 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 hugedtfor 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
- Draw your name centred-ish with
DrawText, then a changing frame counter beneath it usingfmt.ctprintf("frame %d", n). - Make the sliding box bounce off both edges instead of wrapping (flip a velocity's sign).
- Print
rl.GetFrameTime()each frame while dragging the window around; watch the spike, then add themin(…, 0.05)clamp and print again. - 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
- raylib cheatsheet (core, shapes, text): https://www.raylib.com/cheatsheet/cheatsheet.html
- Odin
core:fmt(ctprintf): https://pkg.odin-lang.org/core/fmt/ - Odin
core:c: https://pkg.odin-lang.org/core/c/ - MDN aside on the general idea of a game loop and delta time (concept, not API): search "fix your timestep".