My experience with JAI
This was supposed to be a chapter on evaluating the language in a larger game prototype post mortem, but it got long, so it became its own thing.
I used the language this summer for a videogame project that is around 15k lines of code at the time of writing this post, so not huge, but not exactly tiny anymore. The code constitutes a 2D puzzle game (running both on Windows, macOS and in the browser) and an automatic solver for the game's puzzles. If the game grows, there will likely also be a puzzle generator, more powerful asset system, better graphics, etc.
This post is written from the perspective of a programmer who values first principles thinking and is directed by a handmade ethos, but at the same time, because of historical circumstances, has been programming mainly in a C-like subset of Rust for the last years 8 years (with a little bit of C sprinkled here and there). I apologize in advance to the people with hardcode C background: some of the ideas presented here will likely be obvious to you, but they were not immediately obvious to me a few years ago. I had to grow into them.
The overall experience with JAI was one underlined by pragmaticism. I didn't really have to think about the language very much. It let me do what I wanted, leaving the responsibility of cleaning my own mess to me, but also giving me the tools to do the cleaning. This is a good fit for projects with complicated or unclear designs. These need a lot of iteration and experimentation, but shipping any project requires hardening: systematizing, fixing bugs, optimization. So, somewhere during development, you want to transition from being messy and unconstrained to being organized and precise. JAI allows you to turn this dial as needed, and gives you tools to gradually improve the quality: metaprogramming, profiling tools, instrumenting allocations, visualization tools, etc. To be fair, in my project, I utilized the "let me make a mess" aspect of the language more than the "help me clean it up" one, both because the game is still small enough for it to not require large-scale cleanup before shipping the current prototype version, and this also isn't my first game, so at this point I can avoid some programming and design mistakes ahead of time.
Codex view of the WASM build of the game.
Here's things that resonated with me, followed by a few reservations. Both lists are by no means complete, rather the items are mostly what I encountered while working on the game.
Things that spoke to me
- Pointers and indices are 64-bit and signed. No size_t/usize that have different sizes based on the target CPU. Being signed, you don't need to care about underflow when doing arithmetic. This is a breath of fresh air. Yes, being 64-bit always makes it hard to port JAI to non-64-bit platforms, so it is something to consider if you know you are going to need that. However, today all of consumer PCs, servers, Macs, consoles, phones and tablets, and now even WASM is 64-bit, so you are able to target quite a few platforms even with this design decision.
- Integer types widen automatically, but truncating or reinterpretting requires an explicit cast. This is a small thing, perhaps it is obvious that this is how it should be, but both C, C++ and Rust do get this wrong. C and C++ have implicit conversions that happily lose data, while Rust goes overboard and requires you to explicitly convert even when widening.
- Structs are plain old data and can contain any bytes you like. Again, this is obvious, and yet people get this wrong. In JAI, there is no such thing as compiler-exploited unused bits (e.g. struct padding or upper bits of booleans, pointers or enums) that would escalate your correctness bugs to UB. For contrast, in languages with heavy typesystems, there is a "correct by construction" philosophy: you can only create correct values for a type, e.g. NonZeroU32, but you can imagine creating your own types with various upheld invariants. This allegedly makes your program more correct. While it initially might seem enticing, I disagree with this philosophy, as the cost far outweighs any correctness benefits. The cost is that you can not treat these values as plain data anymore. Zero-copy deserialization is now not possible, because the compiler could have generated code that acted on the "unused" bits, and just the act of creating a value with an invalid bit pattern is now instant UB. Need to deserialize gigabytes of data from disk? If it has attached invariants that the compiler assumes, you can not just reinterpret the memory after you load the file (a common way of cheap deserialization of simpler binary formats), but also have to validate the raw bytes before you cast, otherwise you'll get UB instead of just a correctness bug. This might seem like a minor thing, but in my last game project this came up so often that I eventually had to rewrite multiple systems (entity storage, serialization, undo buffers), until I finally arrived at the conclusion that I should just have gone the plain old data route from the beginning. Fortunately, in JAI, you are not discouraged from plain old data.
- There is a standard allocator interface. There are not that many things that require the language to define a standard interface, but allocators are one. This is a language where it is easy for you to swap allocators per function, module or data-structure.
- "#as using base: Base_Struct;" lets you quickly implement shallow inheritance hierarchies. You can store each type of structure in separate storage, so you don't pay the cost for the largest variant everywhere, but by passing a pointer, "#as using" lets you treat data homogeneously when you only want to look at the common fields. Yes, you can do this in other languages (inheritance in C++, "the Deref trick" in Rust), but it is especially easy to do in JAI. One thing to note is that JAI has neither a massive type system nor inheritance, but selectively captures the useful bits as its own small set of features.
- enum_flags!
- Overall syntax decisions and terseness. I don't usually like talking about syntax, but JAI threads the needle between conciseness and simplicity really well. For instance, you can omit the type name in struct or enum literals, if they can be inferred locally, but there is no globally reasoning type inference that would harm understandability. Or you can declare polymorphic parameters simply by adding a dollar sign - there is no need for angle brackets or other unreadable nonsense. Also of note is the for loop syntax, which is really short for the common case.
- how_to files and modules that ship with the compiler are good documentation. The former is not just a tutorial, but spends a lot of time explaining the reasoning and philosophy behind the language. Being able to read (and modify) the latter gives you the sense of being in control over the project. If you find a bug, you can just fix it for yourself locally and not have to wait while the maintainers respond to your report or bugfix.
- Very fast compile times, unless you invoke the LLVM backend (for optimization or platform support), in which case they are still okay.
- There is a lot of fancy stuff that I somewhat intentionally relegated to a single bullet point: metaprograming (the Compiler module, #run, #insert, #no_reset, #caller_location), autobakes, macros, etc. This is because I didn't get to use most of it very extensively yet, as I was programming a small game that doesn't need all that power. I can imagine at least metaprogramming being tremendously useful for building larger things. In Thekla's Sokoban game, this is used to generate entity storage code, serialization, developer commands, profiling, and probably much more I am not aware of.
Things I have some reservations about
- This is on the roadmap, but currently it is only possible to do SIMD for x64 (with the built-in inline assembler for X64). There's multiple possible approaches for how Jon and the team can handle this - general SIMD abstraction (already has a prototype) vs. platform intrinsics vs. userspace inline assembler - so it might take some time before SIMD is possible to do without friction on all platforms.
- Context is convenient, but I don't think I got a lot of mileage out of it, and I had to pay for it (slightly). I only use the context allocator, temporary storage and context logger, and because I like to have things explicit, I can't think of anything else I'd like to put there. For the allocator, I could have lived with, or even preferred passing the memory arenas as explicit parameters instead. For logging, the way I use a logger is amenable for it being just a global variable. The downside I did experience is that when another language calls a JAI dynamic library, you have to create an empty context in almost all entrypoints, which is inconvenient. The major cited use-case for Context is being able to override the allocator for external code, and this is pretty much the only thing I can imagine using it for.
- The #poke_name thing. Modules by default do not see code from other modules. This is sometimes inconvenient, one example being when you make a struct and you want to use it as a parameter to the array_find function, or a key in a hash table. By itself, the callee module doesn't see the operator== for the struct. #poke_name is the temporary workaround that lets you make the module aware of your stuff, but it is not very easy to use or transparent. I am looking forward to having a better version of this.
- I personally would like a stricter separation of OS-specific from platform-independent code. These days, I default to having explicit, Casey-style platform layers, but the JAI modules go the (far more travelled) route of abstracting over the common features of multiple APIs in their internals. I can still do the Casey thing, but for that I have to ignore some modules shipped with the compiler (File, Thread, Window_Creation, Input, Simp, Sound_Player) and make my own. However, there's a few things I can't really change, like Temporary_Storage being partially defined in the compiler, or write_string being defined in Runtime_Support. I realize that these are probably just my platonic ideals, and in practice the more pragmatic approach that Jon took didn't cause me any problems.
- I get why unused variables are not warned against, and that it can be annoying, but sometimes an unused variable warning points to an underlying problem even when rapidly iterating on code (e.g. when incorrectly shadowing). Maybe it can be optional and disabled by default, or maybe a metaprogram plugin?
Since I am mentioning my reservations, now would be the time to also mention the bus factor. The team working on the compiler at Thekla is very small. I wouldn't start a large project with JAI just yet, unless my team gets source access to the compiler and there is budget in the project for maintaining JAI.
Otherwise, for small projects, JAI is already a great boon, and I am looking forward to having the polished and fully released version of the language that I can use for large projects.
Happy coding!
Live allocation profiling.