A starting point for making games in Odin with raylib, modeled on the classic raylib-game-template — plus hot code reloading (edit gameplay code and watch it change in a running window), an optional ECS, and a guide to growing your code from placeholder to real game, with a bridge for developers coming from Python or JavaScript.
- Odin on your
PATH(raylib ships with Odin asvendor:raylib— nothing else to install). Check with:odin version - Windows (the build scripts are
.bat). The game code is cross-platform; to port, translate the scripts to.shand change the hot-reload host's DLL name (GAME_DLL :: "game.dll"insource/main_hot_reload/main_hot_reload.odin) to the platform's shared-library extension (.soon Linux,.dylibon macOS).
build_hot_reload.bat
This builds the game as game.dll, builds and launches the development host,
and opens the window. Then:
- Leave the game running.
- Edit any
source/screen_*.odinfile (try changing text on the gameplay screen). - In a second terminal, run
build_hot_reload.batagain — it rebuilds only the DLL and exits. - Watch the running window pick up your change without restarting.
In the window: F5 forces a reload, F6 restarts with fresh state, ESC quits.
| Script | What it produces |
|---|---|
build_hot_reload.bat |
game.dll + game_hot_reload.exe (dev host). Your everyday command. |
build_debug.bat |
build/game_debug.exe — one file, unoptimized, symbols + console. |
build_release.bat |
build/game_release.exe — one file, optimized, no console window. |
source/
game.odin The exported hot-reload API + the frame loop + Game_Memory.
screen_manager.odin The Screen enum, fade transitions, and dispatch.
screen_logo.odin ┐
screen_title.odin │ One file per screen. Each has init_/update_/draw_ procs
screen_options.odin │ and a small *_State struct stored on Game_Memory.
screen_gameplay.odin │ ← build your game here.
screen_ending.odin ┘
main_hot_reload/ The dev host: loads game.dll, watches it, reloads live.
main_release/ The release host: statically links the game into one exe.
assets/ Textures, fonts, sounds (none required to run).
All mutable game state lives on Game_Memory, reached through the global
g: ^Game_Memory. Never make a mutable package-level variable.
Why: hot reload swaps the code, but your state has to survive the swap. The host
keeps the Game_Memory block alive and hands the pointer back to the new code.
A plain package global would be reset to zero on every reload; a field on
Game_Memory persists. (Constants declared with :: are fine — they're not
state.)
// NO — resets to 0 on every hot reload:
player_score: int
// YES — lives on Game_Memory, survives reloads:
Gameplay_State :: struct {
player_score: int,
}
// …reached as g.gameplay.player_scoreYou don't need any of this on day one. The template starts with placeholder screens on purpose. Reach for the next step only when your current structure starts to hurt.
A "character" is just data. Put it in the gameplay state:
Player :: struct {
pos: rl.Vector2,
health: int,
}
Gameplay_State :: struct {
player: Player,
}Update and draw it from update_gameplay / draw_gameplay.
When you have lots of similar objects (enemies, bullets, pickups), use a
[dynamic]T (a growable list — see the Python/JS bridge below):
Entity :: struct {
pos: rl.Vector2,
vel: rl.Vector2,
kind: Entity_Kind,
health: int,
alive: bool,
}
Gameplay_State :: struct {
player: Player,
entities: [dynamic]Entity,
}Create it in init_gameplay (g.gameplay.entities = make([dynamic]Entity)),
and free it when you leave the screen.
When the entity array starts to strain — many object kinds, components that come and go — that's the moment to reach for the optional ECS (see "Optional: Entity Component System" below).
When you have tiles, rooms, or maps, give them their own file — say
world.odin — that owns the level data:
// world.odin
package game
Level :: struct {
width, height: int,
tiles: [dynamic]Tile,
}
load_level :: proc(index: int) -> Level { /* … */ }
draw_level :: proc(level: ^Level) { /* … */ }Store the current Level on Gameplay_State. Screens ask the world what to draw
instead of knowing tile details themselves.
health, inventory, and score show up on many kinds of things. Model them as
small structs you compose into entities, rather than copy-pasting fields:
Health :: struct { current, max: int }
Inventory :: struct { items: [dynamic]Item }
Player :: struct {
pos: rl.Vector2,
health: Health,
inventory: Inventory,
}Introduce a component the moment two different kinds of entity need it.
Put decision-making in its own file — behaviors.odin — as functions that take
an entity plus the world and decide what it does. This keeps AI decoupled from
rendering, and is the natural home for the algorithms this workspace is about
(state machines, steering, pathfinding):
// behaviors.odin
package game
update_chaser :: proc(e: ^Entity, target: rl.Vector2) {
dir := rl.Vector2Normalize(target - e.pos)
e.vel = dir * CHASER_SPEED
}Once behaviors multiply, run them as passes over all entities each frame, and
keep the per-screen update_* thin:
update_gameplay :: proc() {
update_input(&g.gameplay.player)
update_behaviors(g.gameplay.entities[:], g.gameplay.player.pos)
update_movement(g.gameplay.entities[:])
update_combat(&g.gameplay)
}Centralize tuning constants (CHASER_SPEED ::), asset handles, and lookup
tables so screens and systems refer to them by name rather than hard-coding
values in the middle of logic. Load asset handles in game_init, store them on
Game_Memory, and unload them in game_shutdown.
The through-line: each file has one job, everything mutable flows through g,
and you add a module only when a concept has earned one.
The template ships an optional generic ECS in source/ecs/ (package ecs).
It's entirely opt-in: the default logo→title→gameplay→ending flow doesn't use it.
Press E on the title screen to see the live demo (source/screen_ecs_demo.odin)
— hold left-click to spawn particles; watch the entity count rise and fall as
they expire.
Illustrative — each dot is an entity with Position, Velocity, Tint, and Lifetime components. Press E from the title to run it for real.
An ECS stores your game objects as entities (just ids) with components (plain data structs) attached, and runs systems (functions) over everything that has a given set of components. It shines once you have many objects of varying makeup. Here's the whole API:
import "ecs"
// 1. Define components — any plain struct:
Position :: struct { x, y: f32 }
Velocity :: struct { x, y: f32 }
Health :: struct { hp: int }
// 2. Register them, in a fixed order (see the reload note below):
register_components :: proc(w: ^ecs.World) {
ecs.begin_register(w)
ecs.register(w, Position)
ecs.register(w, Velocity)
ecs.register(w, Health)
}
// 3. Make a world, initialize it once, and register your components:
world: ecs.World
ecs.world_init(&world) // allocates internal storage — call once
defer ecs.world_destroy(&world) // free when done (in a game: in game_shutdown)
w := &world // the ecs procs take a ^World
register_components(w)
// 4. Create entities and attach components:
e := ecs.create(w)
ecs.add(w, e, Position{100, 100})
ecs.add(w, e, Velocity{40, 0})
ecs.get(w, e, Position) // -> ^Position (nil if absent)
ecs.has(w, e, Velocity) // -> bool
ecs.remove(w, e, Health) // detach one component
ecs.destroy(w, e) // remove the entity; stale handles to `e` go invalid
// 5. Systems query for a component set and operate:
move :: proc(w: ^ecs.World, dt: f32) {
for e in ecs.query(w, Position, Velocity) {
p := ecs.get(w, e, Position)
v := ecs.get(w, e, Velocity)
p.x += v.x * dt
p.y += v.y * dt
}
}Adding a component to your game is just: define a struct, add one ecs.register
line, write a system that queries it. There's no central World struct to edit.
ecs.create returns an Entity (an index + a generation). When you destroy
an entity its slot is reused later, but the generation is bumped — so any old
handle to the destroyed entity fails ecs.is_alive and ecs.get returns nil.
No dangling references.
Keep your ecs.World on Game_Memory (the demo uses g.ecs_demo.world) so it
survives hot reloads, and free it in game_shutdown with ecs.world_destroy.
One detail makes generic ECS coexist with hot reload: Odin's runtime type ids
can change when the DLL recompiles, so the ECS rebuilds its type→pool mapping
each load by re-running your register calls (that's why registration is an
explicit, ordered step). Your entity data itself is preserved. The demo simply
calls ecs_demo_register at the top of its update every frame — cheap and
always reload-safe. In your own game you can do the same, or call it once at
startup and again from a reload hook. Note: changing a component's fields
(its size/layout) can't be applied by a hot reload — press F6 to restart when
you edit a component struct.
The ecs package has unit tests:
odin test source/ecs
Odin is a compiled, statically-typed systems language. If your background is Python or JS, the biggest adjustments are about the workflow — how code runs, how projects are packaged, how you get libraries — as much as the syntax. Here's the shift.
There's no python app.py / node app.js interpreter and no REPL. Source is
compiled to a native .exe ahead of time.
odin run source/main_release— compile and run once (closest thing topython app.py).odin build source/main_release— just produce the executable.
(You point Odin at a directory that has a main proc — here the release host.
The source/ directory itself is package game with no main, so it's built
as a library/DLL by the build scripts, not run directly.)
In this template you rarely call those directly — the .bat scripts do — and
the hot-reload loop is your interactive iteration: edit, rebuild the DLL,
watch the running game update. That's the moral equivalent of re-running a
script, but without losing your place.
A package is a directory of .odin files that share a namespace. Every
file in source/ starts with package game — so they see each other's types
and procs with no imports between them. You only import to reach another
directory:
import rl "vendor:raylib" // a bundled collection
import "core:fmt" // the standard library — always availablecore: (standard library) and vendor: (bundled third-party like raylib) come
with your Odin install, the way Python's stdlib is always there.
There is no central package manager, no requirements.txt / package.json, no
node_modules, no virtualenv. You add a third-party library by dropping its
source into your project and importing the directory (this is called
vendoring). Fewer moving parts, no dependency-resolution step; you control
versions by choosing which source you copy in.
No webpack/vite, no setup.py/pyproject.toml, no transpile or bundle step. A
one-line odin build is the build. The .bat files here are thin wrappers
around it.
Types are static, but usually inferred, so it still reads lightly:
x := 5 // x is an int, inferred — like Python
name := "Alice" // a stringThere are no classes, methods, or inheritance — just structs (data) and
procs (functions) you pass data to:
# Python
class Player:
def __init__(self, hp): self.hp = hp
def hurt(self, n): self.hp -= n// Odin
Player :: struct { hp: int }
hurt :: proc(p: ^Player, n: int) { // pass the data in explicitly
p.hp -= n
}You allocate and free. defer schedules cleanup to run when the current scope
exits — like a lightweight with/finally:
data := os.read_entire_file("save.dat", context.allocator) or_else nil
defer delete(data) // runs automatically at end of scopeThis template hides most memory management for you — state lives in one
Game_Memory block allocated once — but it's worth knowing where the GC went.
Assigning a struct copies it. In Python/JS "everything is a reference"; in
Odin it isn't. Use ^T for a pointer type and &x to take an address when you
want to share and mutate:
a := player // a COPY of player
b := &player // a pointer to the same player; b.hp = 0 changes the originalThis is exactly why the template threads state through g: ^Game_Memory — one
shared, mutable block.
Prefer an enum (or a tagged union) for things you might model with magic
strings or dicts elsewhere — like this template's Screen enum. Errors are
returned as extra values, not thrown:
data, err := os.read_entire_file("x", context.allocator)
if err != nil {
// handle it — no try/except
}| You know (Python / JS) | In Odin |
|---|---|
python app.py / node app.js |
odin run source/main_release (compile + run) |
| REPL / live reload | the hot-reload loop (edit → rebuild DLL → live) |
pip install / npm install |
vendor the library's source, import the dir |
requirements.txt / package.json |
(none — no package manager) |
| module = file | package = directory of .odin files |
class + methods |
struct (data) + proc (functions) |
| everything is a reference | values copy; use ^T / &x to share |
None / null |
the zero value, or a Maybe(T) / tagged union |
try / except / throw |
multiple return values: val, err := f() |
print(...) |
fmt.println(...) (import "core:fmt") |
list / [] |
[dynamic]T (growable) or [N]T (fixed) |
dict / {} / Map |
map[K]V |
New to raylib too? The raylib cheatsheet
lists every function; they're available in Odin as rl.FunctionName.
zlib — the same permissive license as raylib. Use it for anything, including commercial projects; attribution is appreciated but not required.