When I wrote Why Go? in 2012, I was struggling to get a Ruby on Rails web app to handle more than 3 concurrent requests. š Go was a revelation! And it continues to be one of the top choices in that space.
Today Iām retired from web development, and pursuing a childhood dream to make video games. I want to get back to the low-level programming I did in my teens.
Odin is the only language that Iām not waiting around for. The language itself is effectively done, reinforced by the Odin 2027 announcement. It has allocator APIs today. The compiler has a parallel frontend, and build times are entirely reasonable on the hardware and platforms I use.
To top it off:
- The
#soadirective makes optimizing memory layouts ergonomic ā for when itās gotta go fast. - FFI to C libraries is a breeze, and it comes fully equipped with bindings to DirectX, Metal and more.
- Vectors, matrices and quaternions are baked into the language.
- Hot code reloading is almost trivial, thanks to a focus on plain old data and manual memory management.
All things I like. Well, except manual memory management! Weāll get into all that, and strategies to mitigate the downsides.
A familiar face
It looks like a duck, it quacks like a duck, but itās an entirely different breed. š¦
The Odin language feels familiar, borrowing liberally from Goās syntax and design.1
- Pascal-style declaration syntax with type inference:
x := 10 - Zero is initialization (ZII)
- Multiple return values, named return values, and even naked
returnš³ - Built-in dynamic arrays, slices, strings and runes
- Built-in maps with the familiar
make(map[string]int)andelem, ok := m[key]syntax - Struct field tags:
struct {name: string `json:"username"`} - The
new(int)builtin and nil pointers š° - Defer keyword ā but Odin defers to the end of a scope
- Packages are directories
- Conditional compilation with build tags, file suffixes ā but also a
whenstatement
The similarities are bound to draw comparison, but while Go strove for simplicity, Odin is even simpler. There are no language primitives for concurrency and no built-in green threads. Odin doesnāt have duck-typed š¦ interfaces or methods on any type. No OOP. No closures. Itās an imperative language through and through.
These are not things I need. There is, however, one thing Odin sorely lacks.
It doesnāt have a cute mascot!2 š
If itās any consolation, Odin does have a few features that gophers have wanted for a long time:
- Enums and tagged unions with exhaustive switch statements
- The
or_returnoperator for more ergonomic error handling
So far Iāve found Odin quite pleasant to read and write ā familiarity is sure to be playing a role.
Manual memory management
We better &address the elephant in the room. š
Memory safety ā it isnāt critical for my use case. Single-player entertainment, maybe multiplayer with friends. I do want fewer bugs, š but I also want fast iteration, painless hot code reloading, and a language well-suited for game development.
Iāll be the first to admit that garbage collection didnāt make Minecraft (Java), Stardew Valley (C#), CrossCode (JavaScript) or Megās Monster (Go) unplayable. And the borrow checker didnāt stop Tiny Glade or (the) Gnorp Apologue from compiling. āļø
Iāve been attempting to Learn Me Some Rust for years. In the right hands, all that expressive power has produced some truly inspiring work. But itās not for me. I value simplicity more than power.
The low-hanging fruit
Safety close at hand. š
Even a simple language can make moderate improvements to memory safety.
Spatial memory bugs (e.g. buffer overflows) can be avoided with bounds checking, which Odin provides by default. You can disable bounds checking in tight loops with #no_bounds_check. Odin also initializes memory to zero, but you can opt out where needed. Odin starts from the right defaults, but trusts you to know what youāre doing.
The sanitizers that C developers are all-too-familiar with are here. The -sanitize:address flag enables ASan, which can catch temporal memory bugs (e.g. use after free). Odin also has its own tracking allocator, which doesnāt catch everything ASan can, but it has much lower overhead. Both of these are runtime checks, and thus depend on test coverage or manual QA.
These are easy wins for a new language.3
Mutation xor sharing
In Odin, you are the borrow checker. š¦
I say that jokingly, š but itās true. Mutate the world under your feet at your own peril.
Letās write an update function that runs on every entity, updating its position and state. It can also inspect the state of the world, including all the entities contained within it.
update :: proc(entity: ^Entity, world: World) {
// do update
}
Rust imposes many decisions on every function parameter. Should it be a mutable borrow, a shared reference, or move ownership? What about aliasing? The compiler will outright reject some combinations. For some, feedback like this is an opportunity to take a step back and find a better design. Others value iteration over all else, quickly prototyping a rough implementation without fighting the compiler. Rustās borrow checker, for all itās worth, canāt distinguish between a multi-threaded production server susceptible to adversarial attacks, and a single-threaded throw-away prototype.
Leaving the borrow checker behind, weāre left to ourselves to consider ownership, aliasing and the like.
Procedure parameters in Odin are immutable, but only in a shallow sense. The update procedure could mutate any data that world holds a reference to, which may not be what we want. Rather than avoid mutation by convention, we could define World and Entity so that everything is inline.4 Then the world parameter can be completely immutable:
World :: struct {
entities: [10000]Entity,
// etc.
}
game_tick :: proc(world: ^World) {
for &e in world.entities {
// update can modify entity, but world is read-only
update(&e, world)
}
}
Odin automatically passes large arguments by reference, so passing world around doesnāt tank our performance. However, changes to entity will be reflected in the world immediately, since world isnāt a copy.
Which brings up another problem with this design. If the entities interact, those interactions are order dependent. For example, does a monster dodge the bullet by being the first to move, or does the bullet move first?
Maybe a better design would double buffer the world data (previous and current frame) or store up the writes to apply at the end. Or maybe entities should be split up (monsters, bullets) and resolved in a defined order. Maybe thereās an entirely different design thatās even better. But those design decisions are all language agnostic.
Even though Odin doesnāt check your borrows, it doesnāt mean your data is a mutable ball of mud with pointers flying everywhere. How you architect your data and code is still up to you.
Enter the arena
How barley men manage lifetimes. šļø
Why free one gladiator at a time when you can free_all at once? Games have some big obvious lifetimes:
- The beginning of the game until the very end
- When a level loads until it unloads
- From the beginning of a frame until the end of the frame
A simple arena allocator operates like the stack you already use every time you call a function, except the arena can have any lifetime you choose. It can use preallocated memory and then reuse that memory again and again. Freeing and allocating memory within an arena is a matter of moving a pointer ā making it very inexpensive to operate.
Instead of just a single global allocator (e.g. malloc), Odin has an implicit context system5 that provides a default heap allocator and a temporary arena allocator. These can be customized with other allocators, or you can pass allocators around explicitly when desired.
In a game loop, the temp_allocator can be used for any allocations that should live for a single frame. At the end of the frame, clear the arena for reuse:
free_all(context.temp_allocator)
Grouping together values with the same lifetime reduces the cognitive burden of keeping track of every independent value. āDid I remember to free everything?ā becomes a matter of allocating things in the arena with the appropriate lifetime.
If you want a deep dive, check out Ryan Fleuryās talk and article on arena allocators.
One idea Fleury describes is reserving a large contiguous block of virtual address space up front. The mem/virtual package in Odin can be used for that purpose.
Avoiding relocation
Please donāt go. š
When a dynamic array is full, appending to the end causes the data in the array to be copied to a new, larger location. This is fine, unless there are pointers to elements at the old location. Now those pointers are invalid (dangling pointer).
Odin includes some specialized data structures that donāt relocate their data, making it safer to hold pointers into them.
Fixed-capacity dynamic arrays ([dynamic; 100]int) are a recent addition. They behave much like a dynamic array, but they canāt grow beyond their initial capacity, and thus never reallocate.
The xar package is like a dynamic array, but it grows in chunks instead of relocating the data.
Generation
Exit the pointer jungle. š
Generational indexes provide an alternative to long-lived pointers. As an added benefit, they are easy to serialize to disk (save game) or the network.
Imagine a monster š§āāļø that is chasing another entity. Instead of an ^Entity pointer, store an index (often called an ID or handle) into the worldās entity array. If the entity being chased is destroyed, the slot in the array could be repurposed for a new entity. A generation is a simple counter used to verify that the referenced entity is still the same one.
Index :: distinct u32
Generation :: distinct u32
GenerationalIndex :: struct {
idx: Index,
gen: Generation,
}
Odin provides a generational index implementation in the core library.
So there are many techniques to reduce the risk of dangling pointers and the like. Maybe manual memory management doesnāt need to be a pointer jungle full of foot guns and spooky action at a distance.
All that pointer chasing isnāt just bad for developer sanity. It can also harm data locality and performance. But to understand why, we need a cursory understanding of how CPUs work.
Data-oriented design
Hardware is the platform. šŗ
Programs are made up of data and transformations to that data. If you know the data and how itās accessed, and you understand the target hardware, then itās possible to write software that runs better on that hardware. This is the thesis of data-oriented design.
When I was a teenager, using a lookup table to avoid mathematical operations (like trigonometry) was a good idea. Today we have a lot more RAM and itās somewhat faster. But CPUs are much, much faster. CPUs are so fast at math that reading from main memory could take an order of magnitude longer than just recalculating the math.
Since memory is relatively slow, CPUs have levels of progressively larger and slower caches (e.g. L1, L2, L3). When data isnāt in a cache (a cache miss), the CPU looks at the next one, and eventually main memory.
Today, data-oriented design centres on economical use of memory (the fastest caches are small), and organizing data based on how itās accessed. When iterating a data structure, an array places the next value nearby. Whereas a linked list uses pointers, potentially placing each node anywhere on the heap, and therefore increasing the likelihood of a cache miss for each iteration.
Iām not saying to never use pointers or even linked lists. Rather, itās worth thinking about how our data and code map to the underlying hardware.
There are several tricks that are worth knowing, but the technique thatās most relevant here is called structure of arrays.
Structure of arrays
Gotta go fast. šØ
Letās flesh out our array of entities.
Vector2 :: struct {x, y: int}
Location :: struct {
position: Vector2,
velocity: Vector2,
}
Entity :: struct {
location: Location,
health: int,
// etc. potentially a lot of data
}
entities: [10000]Entity
We know that our physics system only needs a subset of all the entity data, so we collect it into a Location struct. Maybe not the best name, but letās go with it. Now we can pass our physics subsystem the subset of data it needs:
for &e, i in entities {
do_physics(&e.location)
fmt.printfln("render %v: (%v, %v)", i, e.location.position.x, e.location.position.y)
}
But even though weāre only passing the data the physics system needs, the data is still laid out in memory one Entity after another. That means fewer Locations fit into the cache than if we had a separate [10000]Location array. If itās a large amount of data, it could result in more cache misses and slower performance.
We could reorganize our code by hand, but Odin provides a #soa directive, which can modify the memory layout for us.
entities: #soa[10000]Entity
Effectively, those four characters change our memory layout to a struct of arrays, like this:
EntitySoA :: struct {
location: [10000]Location,
health: [10000]int,
// etc.
}
All with no other code changes! By grouping together all the data that our physics system needs, we can take better advantage of the CPU cache.
Stability
A solid foundation. šŖØ
Speaking of no other code changes, we need to talk about stability. JangaFX and others rely on Odin to remain stable and well-maintained, and the recent Odin 2027 announcement is Odinās 1.0 moment.
As a smoke test, I upgraded the 2024 source code from Cat & Onion (available on Itch šø) to the latest Odin dev-2026-07a compiler and raylib 6.0. The most significant changes were to core:os, which were telegraphed well ahead of time. The other big changes were for raylib, particularly HiDPI.
Did I mention that raylib bindings are bundled with Odin?
Vendor
In Odin, you are the package manager. š¦
Odin ships with bindings for DirectX 12, DirectX 11, Metal, OpenGL and Vulkan. Windows COM and Objective-C APIs feel like part of the language. Defer helps with cleanup, as thereās no RAII like in C++ or Rust.
Having all these bindings baked in makes it quick to get up and running, say, to learn a graphics API from a book or tutorial. While porting a Metal by Tutorials demo from Swift to Odin, I hit a bug with passing SIMD types over FFI. š It was promptly resolved by an Odin community member. šš»
Other C bindings are included as well, such as Box2D. There is value in depending on mature libraries, with logic bugs fixed over many years.
If thereās a C library not provided in vendor, there are tools to generate bindings. Odin can link in foreign libraries, but those libraries may require CMake or similar to build. Odin doesnāt call out to C compilers. This is fine by me, as I have no desire to port and maintain third-party build scripts in Odin.
Thereās no official tool that pulls in dozens of transitive dependencies with a single line. Itās part of the ethos to vet external packages more carefully. Very deliberate dependency choices also keep compile times snappy. For my needs, this too is fine.
Compile times
Oh. Carry on. āļø
Compile times are one thing Go definitely got right from the start. Odin doesnāt have incremental compilation and its backend is LLVM, so it must be slow, right? We wonāt know for sure until we put it to the test!
Presenting the -show-timings data for the Odin compiler (dev-2026-07a). Default optimization level of -o:minimal and no debug info. Based on multiple runs with the initial warm-up discarded.
- Windows 11 with an AMD Ryzen 9700X (8c/16t) and RAD Linker (0.9.24 ALPHA).
- macOS Tahoe with an M1 Max (8P+2E) with the default linker.
| Platform | Project | LoC6 | Parsed7 | LLVM | Link | Total |
|---|---|---|---|---|---|---|
| Zen 5 8c | Karl2D Snake | 14K | 141K | ~90ms | ~32ms | ~230ms |
| Zen 5 8c | Cat & Onion | 21K | 141K | ~445ms | ~32ms | ~575ms |
| Zen 5 8c | Odin Core Library + Tests | 270K | 282K | ~1s | ~65ms | ~1.3s |
| M1 Max | Karl2D Snake | 14K | 132K | ~140ms | ~170ms | ~420ms |
| M1 Max | Cat & Onion | 21K | 134K | ~615ms | ~180ms | ~900ms |
| M1 Max | Odin Core Library + Tests | 270K | 278K | ~1.6s | ~200ms | ~2.1s |
Odin Core Library + Tests is a build ā weāre not evaluating test execution time.
odin build tests/core -all-packages -build-mode:test -show-timings -out:core-tests
For my hardware setup, Windows was noticeably faster.
- The default Windows linker (MSVC) was 20-50ms slower than the bundled version of RAD Linker.
- With debug info, builds were ~100ms to ~600ms slower on Windows and up to ~1.1s slower on macOS.
Odin build times are dominated by the LLVM backend (~80%) on larger projects.
- The Odin front-end scales near-linearly with lines parsed.
- For debug and development builds (
-o:noneor-o:minimal), Odin emits LLVM IR for each package in parallel. LLVM scales according to the largest package, measured in terms of IR emitted. - The largest package in Cat & Onion (game) emits 5x the LLVM IR of the largest package in Snake (karl2d). This explains the gap in compile times, despite parsing a similar number of lines.
- Core Library + Tests emits 69 MiB of LLVM IR spread across 213 modules. The package emitting the largest amount of LLVM IR (14.5 MiB) was the biggest determinant of build time, with the majority of packages emitting far less.
In addition to pure lines of code, parapoly is a factor. In Odin, monomorphized IR concentrates in the defining packageās module. This avoids duplication, šš» but it also increases the likelihood of a few modules dominating compile time. This seems more likely to impact test builds (e.g. the flags package tests 60 variations). Moderately slower test builds may be negligible vs. the time running the test suite.
As Jakub Tomsu writes, keeping procedure bodies small when using parapoly can help.
Keeping package sizes reasonable and not overusing generics are common recommendations in many languages. Heavy use of #load and reflection (e.g. fmt, json) may also be worth keeping an eye on. Odinās -show-timings and -build-diagnostics are helpful tools if builds ever start feeling slow.
Overall Iām happy with these results, especially on my Zen 5 system.
Hot code reloading
Instant gratification. ā»ļø
Once we have reasonably quick build times, hot code reloading provides the ultimate in fast iteration. The goal is to apply code changes without restarting the running app or game.
There are three approaches that Iām aware of. The best option for hot reloading Odin code is to split a game into two parts: an executable and a shared library (dll/dynlib/so). Launch the game, tweak gameplay logic or user interface code, then recompile the shared library. Have the main executable reload the shared library so code changes are reflected instantly.
For this to work, the memory that the gameplay code is using must be handed off to the new shared library. The game continues on from where it left off. Odin doesnāt provide hot code reloading out of the box, but its focus on plain old data and procedures, along with manual memory management makes Odin a natural fit. It keeps the entire solution relatively simple.
There are still some gotchas to look out for. To get started, take a look at Karl Zylinskiās article and templates for hot reloading.
Whatās not to love?
Baby, donāt hurt me. š
The reference documentation is incomplete and there are gaps in the core library (e.g. saving PNGs). These are things being worked on for Odin 2027, which isnāt that far off.
The tooling is good, but could always be better. Daniel Gavinās ols language server includes a code formatter, but it allows configuration and another formatter exists. Iām grateful that these tools exist, but a single standard would benefit humans and tooling alike.
There is odin test, but Iām not aware of a built-in test coverage or fuzzing tool. Fortunately itās feasible to use existing C/C++ debuggers and profilers.
Maybe the compiler could optimize a little better or be a little faster. Thatās always the case.
Conclusion
Most outstanding. āš»
Iāve enjoyed my time with Odin so far. The ergonomics and the compile times. The vendor libraries, built-in linear algebra, #soa and allocators are all nice to have. Manual memory management, plain old data and the bog-standard threading model make FFI and hot code reloading simpler. While not everyoneās šµ cup of tea, itās a good fit for my use case.
If youāre at all intrigued, take a gander at the Odin overview. After that, I highly recommend Karl Zylinskiās book, Understanding the Odin Programming Language. He has kept the content in step with the language over the years.
Until next time.