Choosing a tool: about Odin language
Backstory
At the previous studio where I worked, my team lead was a proponent of Data-Oriented Development and an opponent of many things tied to object-oriented programming. Pretty quickly the whole team (or at least a major part of it) picked up these ideas. Beyond the data-oriented vs. object-oriented debate, another recurring topic was development using custom engines and the problems with commercial solutions like Unreal or Unity. Maybe in some future post I'll lay out my own thoughts on these subjects, but for now this will just stay as an introduction to how I arrived at the tools I use to develop my game today :)
One day that same lead told me about a programming language called Odin, which by his account was built for data-oriented development and, on top of that, shipped with features aimed at gamedev out of the box. And he suggested (insistently suggested) that I give the language a try.
Intrigued, I soon went off to read the documentation, and I also came across Karl Zylinski's blog — a very active user of the language and member of the Odin community. He's also, in fact, published the only full-fledged book on the language to date, one that organizes and substantially expands on the information from the docs. On top of that — and this caught my attention separately — Karl released a game, Cat & Onion, written entirely from scratch in Odin and Raylib.
So, after writing my first simple programs, I took on a learning project from https://raytracing.github.io — a series of three books that teach software ray tracing in detail and with code examples, without being tied to any particular language, library, or API.
In this guide all the code is given in C++, whereas I tried to make the most of the language features and paradigms that Odin offers (and promotes). And I quickly noticed how simple the language makes certain things. One of the first things that jumps out, for instance, is how vector types are implemented — they're synonymous with arrays of 2–4 elements. These arrays can be used both as ordinary arrays, with indexing, sorting, and random access, and as vectors (or colors), accessing the x/y/z/w (or r/g/b/a) components, with vector algebra working out of the box, and even with swizzling of these components (say, to pull out a combination like .zyx or even .yxxy). This seemingly small feature turned writing the math library the project needed (and yes, I didn't want to use glm or anything like it) into essentially building a compact wrapper over the language's built-in features, one I needed more for personal convenience than anything else. I hardly need to spell out how handy that was when writing the 3D ray tracer.
Another feature that also fit this practice project beautifully is tagged unions. What's great about them is that they combine the roles of the familiar C union and an enum. Each member of a union in Odin is a full-fledged user-defined type (built-in types work too, of course, but they don't reveal the full potential). Let me illustrate with an example from the ray tracing project itself:
material :: union {
lambertian,
metal,
dielectric,
diffuse_light,
isotropic,
}
lambertian :: struct {
albedo: color,
tex: ^texture,
}
metal :: struct {
albedo: color,
fuzz: f32,
}
dielectric :: struct {
refraction_index: f32,
}
diffuse_light :: struct {
tex: ^texture,
}
isotropic :: struct {
tex: ^texture,
}
As we can see, the layout of each type differs from the others — every type in material contains its own fields, which don't have to overlap with the fields of the other types in any way. And now for the interesting part, we can do this:
material_scatter :: proc(ray: ray3, hit: hit_result) -> (bool, color, ray3) {
switch m in hit.mat {
case lambertian:
scatter_dir := hit.normal + vec3_random_unit()
if vec3_is_nearly_zero(scatter_dir) {
scatter_dir = hit.normal
}
scattered := ray3{hit.point, scatter_dir, ray.t}
attenuation := m.tex != nil ? texture_value(m.tex^, hit.u, hit.v, hit.point) : m.albedo
return true, attenuation, scattered
case metal:
reflected := unit(vec3_reflect(ray.dir, hit.normal) + m.fuzz * vec3_random_unit())
scattered := ray3{hit.point, reflected, ray.t}
attenuation := m.albedo
return dot(scattered.dir, hit.normal) > 0.0, attenuation, scattered
case dielectric:
if m.refraction_index <= 0.0 do break
<...>
}
This way we essentially get lightweight, readable polymorphism — no inheritance, no void* with the risk of botching a cast. This approach shines especially, in my view, when writing state machines, where each possible state is described by its own type in a union. Here I'll just recommend watching Karl's excellent (and short) video showing how he implemented a state machine like this in Cat & Onion.
And all of this is just the tip of the iceberg! A tip that quickly inspired me to keep going with the ray tracing tutorials, and I decided to get through at least the first two books. As a result I reached the final render first of the first book, and then of the second, and I was very happy with the result.
The one significant problem was performance — whereas the earlier test images rendered in anywhere from a couple of seconds to half an hour, the final boss took several days. Well, the first thing I did was add saving of intermediate results when the program closes and loading them back on the next launch, so I wouldn't have to keep the PC running non-stop. But that, of course, is no solution. Profiling and tinkering with individual bottlenecks also helped only marginally — the presence of smoke objects in the scene, along with volumetric lighting, demanded a large number of computations under the probabilistic ray tracing model in use.
So I decided to look into what Odin has to offer when it comes to multithreading. After reading the introductory material on threads, I set about implementing a simple system by hand — the main thread acted as a manager for worker threads, each processing its own row of the image, until all the workers were free (that is, when the manager thread couldn't assign a new row to any of them). It worked, but I understood I could do better. I took on implementing a ring buffer, along with experiments on how to partition the image. The optimal approach turned out to be handing each thread a block of H×N pixels, where H is the image height and N is some empirically tuned width in pixels that's less than or equal to the image width divided by the thread count. One catch — I implemented it all by hand, and only after finishing did I discover that Odin, yet again, offers out of the box not just a thread type and synchronization primitives but worker thread pools as well :D Rewriting it to use that feature wasn't hard, though, and soon much more compact code was handing me the coveted image in a few hours (instead of a few days). On top of that, I hooked Raylib up to the project and started displaying the intermediate result in a window, instead of constantly opening an image file, which is what I'd been doing before:
Back to the game
So, Odin won me over. Not just with its features, but with its simplicity. I started getting the same enjoyment out of development that I once felt writing code in C or Pascal back in university. Except the former always required a lot of not-very-necessary ceremony to get things working, while the latter remained stuck in the niche of simple teaching tools, unfit for real projects. Odin cleaned up and built on that simple approach — without excessive layers of abstraction, without bulky constructs — adding on top of it plenty of pleasant features useful for game developers and proponents of Data-Oriented Design. Many people also compare the language to Go, but here I don't have the experience to judge.
Before long I thought to myself that I'd been wanting to work on my own game for far too long, and putting it off for far too long. I'd found myself in a state where I was endlessly building learning projects, starting and abandoning engine development. That's not bad — it's useful, it's interesting. But it gets me no closer to releasing a game of my own. Building a full-blown engine of my own, I'd almost certainly never reach a finished product — it's impossible within any foreseeable, reasonable timeframe. At least, given that I want to involve as few other people as possible; I definitely don't want to share the code-writing side with anyone — there I want to be a one-man operation.
And, looking at Karl's game, as well as keeping an eye on Casey Muratori's Handmade Hero series, I arrived at the desire to start writing an actual game from scratch. And that lined up with my experience with Odin. It also lined up with my recent experience with Raylib — an excellent minimalist graphics library that takes a lot of the details of graphics APIs off your hands and gives me, as a developer, a clear and practical interface.
So I began development. Along the way I keep studying the language, trying to adapt my thinking to the paradigm it offers. And next I'd like to skim over some examples of the language features I currently use myself. Each of them probably deserves its own post; for now I just wanted to broadly highlight what I find useful for myself.
Unions
The unions described above are extremely convenient and useful for closed polymorphism. For example, I use them for the separate editor modes:
EditorMode :: union {
^EditorModeFree,
^EditorModeSurfacePlacement,
^EditorModePrefabPlacement,
}
EditorModeFree :: struct {
// fields for free flying
}
EditorModeSurfacePlacement :: struct {
// fields for surface creation
}
EditorModePrefabPlacement :: struct {
// fields for placing prefab instances in the level
}
for the different ways of storing animations:
AnimationSheet :: struct {
// one texture, frames tiled in a rows×cols grid.
}
AnimationFrames :: struct {
// one texture per frame (individual DDS/BC7 files)
}
AnimationTexArray :: struct {
// all frames as layers of a single ktx2 array texture
}
AnimationData :: union {
AnimationSheet,
AnimationFrames,
AnimationTexArray,
}
for the collision types:
EntityCollisionType_Point :: struct {}
EntityCollisionType_Circle :: struct {
radius: f32,
offset: rl.Vector2,
}
EntityCollisionType_AABB :: struct {
bounds: AABB,
offset: rl.Vector2,
}
EntityCollisionInfo :: union {
EntityCollisionType_Point,
EntityCollisionType_Circle,
EntityCollisionType_AABB,
}
This way I see all the possible type variants in one place, with convenient transitions between them. And combined with the compiler's default requirement to spell out every case in a switch, I don't risk missing the implementation of methods for any of the subtypes.
Using statement
using in Odin works very similarly to languages like Jai and Zig. In fact, Jonathan Blow, the author of Jai, put out a great video about using in Jai. With it you can pull off a trick where, without real inheritance, we can access the members of a type included by composition as if they were members of the top-level type itself. For example:
Transform :: struct {
x, y: f32,
rotation: f32,
}
Entity :: struct {
using transform: ^Transform,
<...>
}
<...>
t := Transform{x = 10, y = 5, rotation = 0}
e := Entity{transform = &t, name = "fox", health = 100}
// Without using we would need to write e.transform.x and e.transform.rotation
e.x += 1
e.rotation = 90
<...>
Right now I use using precisely for this kind of syntactic sugar. However, Jonathan in his video explains how this can later be applied in combination with SoA (Structure of Arrays), heavily optimizing performance. He's talking about Jai, but the exact same thing works in Odin, which also has a built-in tag for SoA. Right now I have no need for this optimization, but I don't rule out that it'll come up at some stage. And it's very nice to know that the language has already laid the necessary groundwork for me, and that such a seemingly sweeping change won't require significant effort.
Allocators
One of the pleasant surprises for me was the built-in support for various allocator types right out of the box. Arena, Temporary, Tracking — all of these types are already in the language. In Odin a hidden context parameter is passed into every procedure, and the allocator in use is set within it. You can override it both globally and for the body of a procedure, or at the point of an actual allocation request.
Right now I make active use of the tracking and temporary allocators. The first has already helped me uncover dozens of leaks, which didn't go unnoticed and got fixed as soon as they appeared. The second, obviously, is regularly used for short-lived objects — for example, when parsing asset manifests I don't worry about the memory allocated for the asset info; it frees itself at the end of the frame, once all the assets have already been created.
load_assets :: proc(filepath: string = "assets/assets.ini") {
for name, desc in G.g_assets {
delete(name)
asset_source_destroy(desc.source)
}
delete(G.g_assets)
// map[string]map[string]string is allocated with temp_allocator
cfg, err, ok := ini.load_map_from_path(filepath, context.temp_allocator)
defer delete(cfg) // frees the map itself (but not the temp allocated memory inside it)
if !ok {
log_errorf("Failed to load asset manifest: %v", err)
return
}
for asset_name, asset_desc_map in cfg {
<...>
G.g_assets[strings.clone(asset_name)] = { ... }
}
}
The built-in Arena allocator is a separate, wonderful bonus that I definitely intend to use. Right now my sandbox doesn't have multiple levels, there's no active dynamic creation and destruction of objects, so there's no use for an arena yet. But I'm looking forward to when I reach all those things and can take advantage of the built-in type.
Defer
I come across differint opinions on deferred execution of calls. Some people like it, some don't. I, for my part, regularly use it in the project for, say, guaranteed cleanup of memory at the end of procedures (temp_allocator won't help here — its target lifetime is a whole frame). This way, first, I manage to avoid leak problems, and second, the scope of just-allocated memory is immediately visible — a defer delete follows right after the allocation, and it's instantly clear that this is a local construct.
Other
I've listed the most basic and useful features of the language that I regularly use myself. But there are plenty of other handy capabilities that make life easier, and I'll just list them off: slices, which let you work with fragments of arrays; the bit_set type for various named flags instead of a bunch of bools; multiple return values, which let you avoid passing out-parameters into procedures; distinct types; directives like when or @(private), which echo the preprocessor directives from C but with less of a headache. And, of course, the built-in vector types I already mentioned, which I can't get enough of :)
I keep studying the language as the project develops, and I never stop getting genuine enjoyment out of it. For me it's become a simple, understandable tool that, above all, doesn't get in the way of working. Going forward, I anticipate and look forward to using the capabilities tied to Data-Oriented Design (I already use some elements of this approach — for example, I tightly pack same-typed "hot" data from entities into arrays for better cache hits, though for now that's done by hand, without support from the language). And I want to note how much the language's well-developed community helps along this path — Discord servers, instructional videos and articles. The people in this circle, in my experience, are very friendly and eager to help. And that too, one way or another, adds to the pleasant impressions of working with the tool.
Funny enough, after some time my lead dropped Odin and switched back to C, while I stayed with the new thing.