Learning Jai via Advent of Code

February 13th, 2023

Last year I got into the Jai beta. One of my favorite ways to learn a new language is via Advent of Code

The Jai beta rules are that I am free to talk about the language and share my code. However the compiler is not to be shared.

This is the type of topic where folks have, ahem, strong opinions. Be kind y'all.

Table of Contents

  1. What is Jai
  2. Advent of Code
  3. Memory Management
  4. Big Ideas
    1. Context
    2. Compiler Directives
    3. Runtime Reflection
    4. Compile Time Code
  5. Medium Ideas
  6. What do I think about Jai?
  7. Conclusion
  8. Bonus Content

What is Jai

Here is my attempt to define Jai. This is unofficial and reflects my own biases.

Jai is a systems language that exists in-between C and C++. It is compiled and statically typed. It strives to be "simple by default". It focuses on problems faced by games.

It's worth pointing out several that Jai does NOT have or do.

The Philosophy of Jai is not explicitly written down. However the tutorials are full of philosophy and perspective. Based on tutorials, presentations, Q&As, and community I think the Philosophy of Jai's looks very vaguely like this:

I think it's also critical to note that Jai:

Please take all that with a grain of salt. These are my early impressions.

Blog Target Audience

I always try to write with an explicit audience in mind. Sometimes it's a deeply technical article for professional programmers. Other times it's for the "dev curious" gamers who want a peak under the hood.

This post has been very difficult to frame. Who is this blog post for? What do I want them to take away? Should this be an objective description of the language or my subjective opinion? So many choices!

Here's what I settled on:

The list of things this post is NOT is much longer:

Who am I

This post is highly subjective and biased so I think it's important to understand where I'm coming from.

I have 15+ years professional experience working in games and VR. I've shipped games in Unity, Unreal, and multiple custom engines. Most of my career has been writing C++ or Unity C#. I've written some Python and Lua. I <3 Rust. In games I worked on projects with 15 to 40 people. I worked on systems such as gameplay, pathfinding, networking, etc. I currently work in BigTech Research.

I'm not a language programmer. I don't know anything about webdev. I think C++ is terrible, but it's shipped everything I've ever worked on. JavaScript is an abomination. I have thoughts on Python and global variables. I don't know anything about backends.

This post is just, like, my opinion, man.

The Dude from The Big Lebowski

Getting Started with Jai

Jai is very easy to run. The beta is distributed as a vanilla zip file. It contains:

That's basically it. Compiling and running a Jai program is as easy as:

  1. Run jai.exe foo.jai
  2. Run foo.exe

On Windows the compiler will also produce `foo.pdb` which allows for full breakpoint and step debugging in Visual Studio or RemedyBG. I'm a card carrying member of #TeamDebugger and hate when new languages only offer printf for debugging.

Jai in the Debugger

Advent of Code

I am pleased to say that this year I successfully solved all 25 days of Advent of Code exclusively with Jai. This ended up being 4821 lines of code. You can view full source on GitHub.

My solutions are a little sloppy, inefficient, and non-idiomatic. One cool thing about learning a language via Advent of Code is learning from other people's solutions. Unfortunately with Jai being a closed beta I wasn't able to do that this year!

Day 01

Here's my solution to day 01. It's an exceedingly simple program. However I'm assuming most readers have never seen Jai code before.

// [..]u32 is similar to std::vector<u32>
// [..][..] is similar to std::vector<std::vector<u32>>
day01_part1 :: (elves: [..][..]u32) -> s64 {
  // := is declare and initialize 
  // var_name : Type = Value;
  // Type can be deduced, but is statically known at compile-time
  max_weight := 0;

  // iterate elves
  for elf : elves {
    // sum weight of items carried by elf
    elf_weight := 0;
    for weight : elf {
        elf_weight += weight;
    }
  
    // find elf carrying the most weight
    max_weight = max(elf_weight, max_weight);
  }
  
  return max_weight;
}

This code should be easy to understand. The syntax may be different than you're used to. It's good, just roll with it for now.

Day 04

Here's most of my solution to day 4.

day04_solve :: () {
  // Read a file to a string
  input, success := read_entire_file("data/day04.txt");
  assert(success);
  
  // Solve puzzle and print results
  part1, part2 := day04_bothparts(input);
  print("day04 part1: %\n", part1);
  print("day04 part2: %\n", part2);
}
  
// Note multiple return values
day04_bothparts :: (input : string) -> u64, u64 {
  part1 : u64 = 0;
  part2 : u64 = 0;
  
  while input.count > 0 {
    // next_line is a helper function I wrote
    // this does NOT allocate. it "slices" input.
    line : string = next_line(*input);
      
    // split is part of the "standard library"
    // splits "###-###,###-###" in two "###-###" parts
    elves := split(line,",");
    assert(elves.count == 2);
  
    // declare a helper function inside my function
    // converts string "###-###" to two ints
    get_nums :: (s : string) -> int, int {
      range := split(s, "-");
      assert(range.count == 2);
      return string_to_int(range[0]), string_to_int(range[1]);
    }
  
    a,b := get_nums(elves[0]);
    x,y := get_nums(elves[1]);
  
    // more helpers
    contains :: (a:int, b:int, x:int, y: int) -> bool {
      return (a >= x && b <= y) || (x >= a && y <= b);
    }
  
    overlaps :: (a:int, b:int, x:int, y: int) -> bool {
      return a <= y && x <= b;
    }
      
    // Jai loves terse syntax
    // This style is encouraged
    if contains(a,b,x,y) part1 += 1;
    if overlaps(a,b,x,y) part2 += 1;
  }
  
  return part1, part2;
}

This code should also be easy to understand. Compared to C there are a few new features such as nested functions and multiple return values.

Advent of Code is pretty simple. The solutions are well defined. I didn't write a 5000 line Jai program. I wrote 25 Jai tiny programs that are at most a few hundred lines.

Advent Summary

I was going to share more AoC snippets but they're "more of the same". It's all on GitHub if you'd like to look.

Would I recommend AoC to learn Jai? Absolutely! It's a great and fun way to learn the basics of any language, imho.

Would I recommend Jai for competitive programming? Definitely not. Jai isn't designed for leet code. This year's AoC winner invented their own language noulith which is pure code golf sugar. I kinda love it. But that ain't Jai.

If you want to solve puzzles fast use Python. If you want puzzle solutions that run fast use Rust. If you want to learn a new language then use anything.

Memory Management

I want to discuss cool language features. I think it's necessary to go over memory management first.

I. No garbage collection

Jai does not have garbage collection. Memory is managed manually by the programmer.

II. No Destructors

Jai has no destructors. There is no RAII. Users must manually call free on memory or release on handles. Jai does have defer which makes this a little easier.

// Create a new Foo that we implicitly own and must free
foo : *Foo = generate_new_foo();

// free the Foo when this scope ends
defer free(foo); 

No destructors is very much like C and not at all like C++. If you're a C++ programmer you may be recoiling in horror. I beg you to keep an open mind.

III. Categories of Lifetimes

Jai documentation spends over 7000 words describing its philosophy on memory management. That's longer than my entire blog post! I can't possibly summarize it here.

One subsection resonated with me. Jai theorizes that there are roughly four categories of lifetimes.

  1. Extremely short lived. Can be thrown away by end of function.
  2. Short lived + well defined lifetime. Memory allocated "per frame".
  3. Long lived + well defined owner. Uniquely owned by a subsystem.
  4. Long lived + unclear owner. Heavily shared, unknown when it may be accessed or freed.

Jai thinks that most program allocations are category 1. That category 4 allocations should be rare in well written programs. And that C++ allocators and destructors are focused on supporting cat 4.

Categories 2 and 3 are best served by arena allocators. Consider a node based tree or graph. One implementation might be to manually `malloc` each node. Then `free` each node individually during shut down. A simpler, faster alternative would be to use an arena and `free` the whole thing in one simple call.

These categories are NOT hard baked into the Jai language. However the ideas are heavily reflected.

IV. Temporary Storage

Jai provides standard access to both a "regular" heap allocator and a super fast temporary allocator for category 1 allocations.

The temp allocator is a simple linear allocator / bump allocator. An allocation is a simple increment into a block of memory. Objects can not be freed individually. Instead the entire block is reset by simply resetting the offset to zero.

silly_temp_print :: (foo: Foo) -> string {
  // tprint allocates a new buffer
  // uses the temp allocator
  return tprint("hello world from %", foo)
}

// no destructor means we have to deal with memory
msg : string = silly_temp_print(my_foo);

// awhile later (maybe once per frame)
reset_temporary_storage();   

In this example we printed a string using the temp allocator via tprint. The function silly_temp_print doesn't own or maintain the string. The caller doesn't need to manually call free because it knows that reset_temporary_storage() will be called at some point.

The concept of a temporary allocator is baked into Jai. Which means both library authors and users know it exists and can rely on it.

Most allocations in most programs are very short lived. The goal of the temporary alloactor is to make these allocations super cheap in both performance and mental effort.

Big Ideas

At this point we've seen some simple Jai code and learned a little bit about its memory management rules. Now I want to share Jai features and ideas that I think are interesting.

This is explicitly NOT in tutorial order. Can Jai do all the basic things any language can do? Yes. Am I going to tell you how? No.

Instead I'm going to share more advanced ideas. These are the types of things you don't learn on day 1 but may provide the most value on day 1000.

1. Context

In Jai every procedure has an implicit context pointer. This is similar to how C++ member functions have an implicit this pointer.

The context contains a few things:

  1. default memory allocator
  2. temporary allocator
  3. logging functions and style
  4. assertion handler
  5. cross-platform stack trace
  6. thread index

This allows useful things like changing the memory allocator or logger when calling into a library. contexts can also be pushed.

do_stuff :: () {
  // copy the implicit context
  new_context := context;

  // change the default allocator to arena
  new_context.allocator = my_arena_allocator;
  
  // change the logger
  new_context.logger = my_cool_logger;
  new_context.logger_data = my_logger_data;

  // push the context
  // it pops when scope completes
  push_context new_context {
    // new_context is implicitly passed to subroutine
    // subroutine now uses our allocator and logger
    call_subroutine();
  }
}

The context struct can be extended with additional user data. However this doesn't play nice with .dlls so there are still design problems to solve.

I hate globals with a fiery passion. I wish all languages had a context struct. It's quite elegant.

2. Directives

One of the most powerful ideas I've seen in Jai is the rampant use of compiler directives.

Here's an example of #complete which forces an enum switch to be exhausive at compile-time.

// Declare enum 
Stuff :: enum { 
  Foo; 
  Bar; 
  Baz; 
};
    
// Create variable
stuff := Stuff.Baz;

// Compile Error: This 'if' was marked #complete...
// ... but the following enum value was missing: Baz
if #complete stuff == {
  case .Foo;  print("found Foo");
  case .Bar;  print("found Bar");
}

This is a simple but genuinely useful example. Languages spend a lot of time bikeshedding syntax, keywords, etc. Meanwhile Jai has dozens of compiler directives and they're seemingly added willy nilly.

Here are some of the directives currently available.

#add_context       inject data into context
#as                struct can cast to member
#asm               inline assembly
#bake_arguments    bake argument into function
#bytes             inline binary data
#caller_location   location of calling code
#c_call            c calling convention
#code              statement is code
#complete          exhaustive enum check
#compiler          interfaces with compiler internals
#compile_time      compile-time true/false
#cpp_method        C++ calling convention
#deprecated        induces compiler warning
#dump              dumps bytecode
#expand            function is a macro
#filepath          current filepath as a string
#foreign           foreign procedure
#library           file for foreign functions
#system_library    system file for foreign functions
#import            import module
#insert            inject code
#intrinsic         function handled by compiler
#load              includes target file
#module_parameters declare module "argument"
#must              return value must be used
#no_abc            disable array bounds checking
#no_context        function does not take context
#no_padding        specify no struct padding
#no_reset          global data persists from compile-time
#place             specify struct member location
#placeholder       symbol will be generated at compile-time
#procedure_name    acquire comp-time procedure name
#run               execute at compile-time
#scope_export      function accessible to whole program
#scope_file        function only accessible to file
#specified         enum values must be explicit
#string            multi-line string
#symmetric         2-arg function can be called either way
#this              return proc, struct, or data type
#through           if-case fall through
#type              next statement is a type

They do a lot of things. Some simple things. Some big things we'll learn more about.

What I think is rad is how game changing they are given how easy they are to add. They don't require bikeshed committees. They can be sprinkled in without declaring a new keyword that breaks existing programs. They appear to make it radically easier to elegantly add new capabilities.

3. Runtime Reflection

Jai has robust support for run-time reflection. Here is a very simple example.

Foo :: struct {
  a: s64;
  b: float;
  c: string;
}

Runtime_Walk :: (ti: Type_Info_Struct) {

  print("Type: %\n", ti.name);
  for *ti.members {
    member : *Type_Info_Struct_Member = it;
    member_ti : *Type_Info = member.type;
    print("  %", member.name);
    print("  % bytes", member_ti.runtime_size);

    mem_type := "unknown";
      if member_ti.type == {
        case .INTEGER;  mem_type = "int";
        case .FLOAT;    mem_type = "float";
        case .STRING;   mem_type = "string";
        case .STRUCT;   mem_type = (cast(*Type_Info_Struct)member_ti).name;
    }

    print("  %\n", mem_type);
  }
}

Runtime_Walk(type_info(Foo));

At run-time this will print:

Type: Foo
  a   8 bytes  int
  b   4 bytes  float
  c  16 bytes  string

This is an exceedingly powerful tool. It's built right into the language with full support for all types - primitives, enums, structs, procedures, etc.

4. Compile Time Code

Jai has extremely powerful compile time capabilities. Like, frighteningly powerful.

First, here is a small syntax tutorial.

// this is compile-time constant because ::
// you may have noticed that structs, enums,
// and procedures have all used ::
foo :: 5; 

// this is variable because :=
bar := 5;

#run

Here's a super basic example of compile-time capabilities.

factorial :: (x: int) -> int {
  if x <= 1 return 1;
  return x * factorial(x-1);
}

// :: means compile-time constant
// note the use of #run
x :: #run factorial(5);
print("%\n", x);

// compile error because factorial(5) is not constant
// y :: factorial(5);

// executes at runtime
z := factorial(5);

Any code that has #run will be performed at compile-time. It can call any code in your program. Including code that allocates, reads files from disk, etc. 🤯

#insert

The #insert directive lets you insert code. Here's a toy example.

// runtime variable
x := 3;

// insert string as code
#insert "x *= 3;";

// runtime value of x is 9
print("%\n", x);

This inserts the string "x *=3;" as code. This is silly because we could have just written that code like a normal person. However we can combine #insert with #run and do things that are starting to become interesting.

// runtime variable
x := 3;

// helper to generate a string that represents code
gen_code :: (v: int) -> string {
  // compile-time string alloc and format!
  return tprint("x *= %;", v);
}

// generate and insert x *= 3;
#insert #run gen_code(3);
print("%\n", x); // prints 9

// compile-time run factorial(3) to produce 6
// insert code x *= 6
#insert #run gen_code(factorial(3));
print("%\n", x); // print 54

We can insert arbitrary strings as code. At compile-time we can execute arbitrary Jai code that generates and inserts strings. 🤯🤯

#code

Thus far we've been operating on strings. Jai can also operate with type safety.

// runtime variable
x := 3;

// #expand makes this a "macro" so it can
// access variables in its surrounding scope
do_stuff :: (c: Code) #expand {
  // splat the code four times
  #insert c;
  #insert c;
  #insert c;
  #insert c;
}

// generate a snippet of code
c : Code : #code { x *= 3; };

// at compile-time: expand do_stuff macro
// at run-time: execute code four times
do_stuff(c);

// prints 243
print("%\n", x);

Here we wrote x *= 3; and stored it in a variable of type Code. Then we wrote the macro do_stuff which copy pastes our snippet four times. And we did this with "proper" data types rather than strings. 🤯🤯🤯

Abstract Syntax Tree

At compile-time the Code type can be converted to Abstract Syntax Tree nodes, manipulated, and converted back.

// our old friend
factorial :: (x: int) -> int {
  if x <= 1 return 1;
  return x * factorial(x-1);
}
    
// function we're going to #run at compile-time
comptime_modify :: (code: Code) -> Code {
  // covert Code to AST nodes
  root, expressions := compiler_get_nodes(code);
  
  // walk AST
  // multiply number literals by their factorial
  // 3 -> 3*factorial(3) -> 3*6 -> 18
  for expr : expressions {
    if expr.kind == .LITERAL {
      literal := cast(*Code_Literal) expr;
      if literal.value_type == .NUMBER {
        // Compute factorial
        fac := factorial(literal._s64);
        
        // Modify node in place
        literal._s64 *= fac;
      }
    }
  }
  
  // convert modified nodes back to Code
  modified : Code = compiler_get_code(root);
  return modified;
}

// modify and duplicate code
do_stuff :: (code: Code) #expand {
  // modify the code at compile-time
  new_code :: #run comptime_modify(code);
  
  #insert new_code;
  #insert new_code;
  #insert new_code;
  #insert new_code;
}

// same as before
x := 3;

c :: #code { x *= 3; };
do_stuff(c);

// prints 3*18*18*18*18 = 314,928
print("%\n", x);

In this example we:

  1. Declare the code x *= 3;
  2. Compile-time modify the compiler parsed AST to x *= 18;
  3. Insert x *= 18; four times

We did all of this with code that looks and runs like regular vanilla Jai code. It isn't a new macro language using #define string manipulation or complex macro_rules! syntax. 🤯🤯🤯🤯

The possibilities for this are endless. Frighteningly endless even. Excessive compile-time code is more complex and harder to understand.

With great power comes great responsibility

Spark of Joy: assert_eq

I want to make a small detour share a small moment where Jai really sparked my joy.

For Advent of Code I wrote a simple assert_eq macro I was proud of. It prints both the values that failed to match and also the expression that produced the value.

assert_eq :: (a: Code, b: Code) #expand {
  sa := #run code_to_string(a);
  va := #insert a;
  
  sb := #run code_to_string(b);
  vb := #insert b;
  assert(va == vb, 
    "(left == right)\n  left: %  expr: %\n  right: %  expr: %\n  loc: %\n",
    va, sa, vb, sb,
    #location(a));
}

assert_eq(42, factorial(3));
// stderr:
// C:/aoc2022/main.jai:154,5: Assertion failed: (left == right)
//   left: 42  expr: 42
//   right: 6  expr: factorial(3)
//   loc: {"C:/aoc2022/main.jai", 85, 15}

It prints the value and also the code that produced the value. That's neat. It made me happy. It was a fun moment.

Rust has convenient built-ins like #[derive(Hash)]. The community has built ultra powerful libraries like serde. Jai doesn't an ecosystem of similar libraries yet. I believe the powerful compile-time capabilities should make them possible. I'm very curious to see what folks come up with.

Medium Ideas

Now we're at the phase of what I'll call "medium impact ideas". These ideas are super important, but perhaps a little less unique.

Polymorphic Procedures

Jai does not have object oriented polymorphism ala virtual functions. It does have "polymorphic procedures" which are Jai's version of templates, generics, etc. Naming things is hard. This name may change.

Here's a basic example:

// Jai
square :: (x: $T) -> T {
  return x * x;
}

// C++ equivalent
template<typename T>
T square(T x) {
  return x * x;
}

The $T means the type will be deduced at compile-time. Like most compiled languages this function is compiled for all necessary types and there is no dynamic dispatch or run-time overhead.

Polymorphic procedure syntax has some subtle niceties to improve compiler errors.

// compile error. $T can only be defined once.
foo :: (a: $T, b: $T);

// deduce from array type
array_add1 :: (arr: [..]$T, value: T);

// deduce from value type (gross)
array_add2 :: (arr: [..]T, value: $T);

// dynamic array of ints
nums: [..] int;

// Error: Type mismatch. Type wanted: int; type given: string.
array_add1(*nums, "not an int");

// Error: Type mismatch. Type wanted: *[..] string; type given: *[..] int.
array_add2(*nums, "not an int");

Explicitly specifying which argument defines the type T is intuitive. It also enables much better error messages for compiler errors.

Polymorphic Structs

structs can also be declared polymorphically.

Vector :: struct($T: Type, $N: s64) {
  values : [N]T;
}

// a simple vector of 3 floats
Vec3 :: Vector(float, 3);
v1 : Vec3 = .{.[1,2,3]};

// a big vector of 1024 ints (for some reason)
BigVec :: Vector(int, 1024);
v2 : BigVec = .{.[/*omitted*/]};

You can use this for simple types, vectors, hash_maps, etc. It's an important example of how Jai is C+ and not C.

Build System

Build systems are a pain in the ass. Some languages require a configuration language just to define their build. Make, MSBuild, Ninja, etc. This build language may or may not be cross-platform.

Jai provides a built-in build system that uses vanilla Jai code. Here is a very simplified look.

build :: () {
  // workspace is a "build mode" like debug, release, shipping, etc
  w := compiler_create_workspace();
  
  // get CLI args
  args := options.compile_time_command_line;
  
  // set some flags and stuff
  option := get_build_options();
  for arg : args {
    if arg == "-release" {
      options.optimization_level = .RELEASE:
    }
  }
  set_build_options(options, w);
  
  // specifiy target files
  // compiler auto-starts in background
  add_build_string(TARGET_PROGRAM_TEXT, w);
  add_build_file("extra_file.jai", w);
}

// invoke at compile-time via #run
#run build();

Jai ships default_metaprogram.jai and minimal_metaprogram.jai so users do not have to manually write this every time. Larger programs will inevitably have their own build systems to perform more complex operations.

Jai's approach to a build systems is interesting. Having a real programming language is great. It's better than cmake hell.

My professional life is a polyglot of languages (C, C++, C#, Rust, Python, Matlab, Javascript) and operating systems (Windows, macOS, Linux, Android, embedded) and devices (PC, Quest, Hololens, embedded) and environments (Unity, Unreal, Matlab, Jupytr, web). Kill me. ☠

C++ is under-defined and doesn't define any build system. Modern languages have rectified this and it's much better.

Unfortunately I don't think anyone has solved "the build system problem" yet. I'm not sure it can be "solved". :(

Small Ideas

This post is getting too long. I'm going to punt "Small Ideas" to the end of the post as a "bonus section"

What do I think about Jai?

So, what do I think about Jai? Numerous folks have asked me this. It's incredibly hard to answer.


Q: Do I enjoy writing Jai?
A:
Mostly. The language is good. Learning it is not difficult. The community is very helpful. The minimalistic standard library forces you to write lots of little helpers which is an annoying short-term hurdle.


Q:Would I use Jai in production?
A:
No, of course not! It's an unfinished language. The compiler is not for distribution. It's not ready for production.


Q: Would I rather use Jai than C?
A:
Yes, I think so. Jai being "C plus" is effectively a super set. I prefer C+ to C.


Q: Would I rather use Jai than C++?
A:
That's a harder question. Sometimes maybe but not yet? There are numerous Jai features that I desperately want in C++. A sane build system, usable modules, reflection, advanced compile-time code, type-safe printf, and more.

I'm intrigued by Jai's style of memory management. I'm not fully sold just yet. Jai is chipping away at the ergonomics but hasn't cracked it yet.

Ask me again in 2 years.


Q: Would I rather use Jai than Rust?
A:
I think Rust is really good at a lot of things. For those things I would probably not prefer Jai.

I also think Rust still sucks for some things, like UI and games. There's cool projects trying to fix that. They're not there yet. Since Rust is not a good fit I would prefer Jai for such projects.


Q: Would I rather use Jai than Carbon, Cppfront, etc?
A:
Yes. Lots of people are trying to replace C++. I understand the appeal of "replace C++ but maintain compatibility with C++ code". It's very practical. That path doesn't appeal to me. I don't want a shiny new thing to be tightly coupled to and held down by the old poopy thing. I want the new thing to be awesome!


Q: Would I rather use Jai than JavaScript?
A:
I'd rather serve coffee than write JavaScript.


Q: How is the Jai community?
A:
Small but very friendly and helpful. Every Discord question I ever asked got answered. They really hate Rust to a kinda weird degree. There will be growing pains.


Q: When will Jai be publicly available?
A:
I have no idea. I feel like the language went dark for awhile and it's starting to wake up. I think it'll be awhile.


Q: Do I think Jai's closed development is wise?
A:
No idea. I support anyone willing to do something a different way.


Q: Will Jai take off?
A:
Who knows! Most languages don't. Betting against Jai is a safe bet. It's also a boring bet. Jai is ambitious. If every ambitious project succeeded then they weren't actually ambitious.

Jai will be popular with the Handmade community. I expect folks will produce cool things. Jai will get off the ground. It may or may not hit escape velocity and reach the stars. I'm rooting for it!


Q: Will Jai take over the games industry?
A:
Nah. Unreal Engine, Unity, and custom game engines are too entrenched. Call of Duty and Fortnite aren't going to be re-written in Jai. I don't expect "Rewrite it in Jai" to be a big thing.

Building a game engine is hard. Not many studios are capable of it. Maybe Jai will allow some mid sized indies to more effectively build custom engines to make games that Unity/Unreal are a bad fit for? That'd be a very successful outcome I think.


Q: Will Jai develop a healthy niche?
A:
Strong maybe! It could be cool for tools. If enough useful tools get built then it could be cool for more things.

Designers and artists don't care what language their tools were made in. Being 15% better isn't enough to overcome inertia. It needs to be at least 50% better. Maybe even 100% better. That's tough.


Q: Can you re-write parts of your game in Jai?
A:
Ehhh I dunno. C ABI is the lingua franca of computer science. Jai can call C code and vice versa. You could write a Jai plugin for Unity or a custom engine. I'm not sure that provides enough value.


Q: Does Jai have papercuts, bugs, and missing features?
A:
Absolutely. What are they? I don't think it's appropriate to publicly blast an unfinished language that invited me to a closed beta. I don't want to pretend it's all roses and sunshine. But I don't want to publicly air my grievences either. It's a tough balance.


Q:Will Jai be useful for non-games?
A:
Good question. Probably yes? It doesn't exclude other use cases. But Jai is definitely optimizing for games and game problems.


Q: Will Jai make everyone happy?
A:
No, of course not. Will it make some people happy? Yeah definitely.


Q: What should readers think about Jai?
A:
That's up to you. I don't want to tell you what to think. I tried to paint a picture of Jai based on my experiences. Your interpretation of that is up to you.

Conclusion

Let's wrap this up.

Coming into all this I knew little to nothing about Jai. I got into the beta. Participated in Advent of Code. Read all the tutorials. And watched some videos.

I feel like I have decent grasp of Jai's basics. I can see some of the things it's trying to do. It would take working on a big project to see if its ideas payoff.

The Jai beta is still very small. If you've got a project you'd like to write or port to Jai you can probably get yourself in. If you just want to poke around for 30 minutes you'd be better off waiting.

I love the solution space that Jai is exploring. I'm onboard with its philosophy. It has some genuinely good ideas and features. It's a very ambitious project. I hope it achieves its lofty goals.

Thanks for reading.

(keep scrolling for more bonus content)

Bonus Content

Welcome to the bonus section! There's so many things I want to talk about. For the sake of semi-brevity and narrative I can't say them all. Here's some unsorted and less polished thoughts from the cutting room.

Language vs Standard Library vs Ecosystem

Sometimes I think about languages in 3 parts:

  1. Language. Syntax, keywords, "built-in" features, etc.
  2. Standard library. "User mode" code that you could have written yourself but ships with the compiler for convenience.
  3. Ecosystem. Libraries written by the community.

This post focused almost entirely on language. Jai is still in a small beta. I'll worry about the standard library and ecosystem later.

Jai ships with a very small standard library. It is NOT a "batteries included" language. It wants to avoid compiling tens of thousands of lines of code because you imported a single file.

Ecosystem can make or break language adoption. Python kinda sucks as a language, but the ecosystem is unrivaled. A key selling point of Rust is the vibrant and robust crate ecosystem. I don't know what approach Jai will ultimately take.

Compile Times

One of Jai's selling points is fast compile times. They're relatively fast?

Here's a comparison between my 2022 Jai code and my 2021 Rust code. It's not quite apples to apples. But it's pretty similar.

Advent of Code 2022 (Jai) 
Jai debug full              0.56 seconds
Jai release full            1.36 seconds

Jai debug incremental       0.56 seconds
Jai release incremental     1.36 seconds

Advent of Code 2021 (Rust)
Rust debug full             13 seconds
Rust release full           14 seconds

(incremental)
Rust debug incremental      0.74 seconds
Rust release incremental    0.74 seconds

Jai doesn't do incremental builds. It always compiles everything from scratch. The long term goal is a million lines per second. The compiler is currently not fully multi-threaded.

My impression is that Jai doesn't have any magic secret sauce to fast compile times. How do you make compiling fast? Compile fewer lines of code! Makes sense. But maybe a little disappointing. I wanted magic!

Run-time Performance

I did not do extensive performance testing with Jai. It seems fast?

I ported one solution that was running quite slow to C++ to compare. My C++ version ran slower. This was because C++ std::unordered_map was slower than Jai Hash_Table.

I am NOT claiming that Jai is faster than your favorite language. However I am not concerned about Jai's performance long-term. It should be just as fast as other compiled languages. It's idioms may or may not be faster than another language's idioms.

Struct of Arrays

Once upon a time Jai talked about automagic struct-of-arrays compiler features. Those features have been cut. I believe the use cases were so specific that no generalized solution presented itself.

Assembly

I'll be honest, the only assembly I've ever written was in school. It's not my jam.

Jai might have some cool assembly features? I dunno!

result : s64 = 0;

#asm {
  // compiler picks a general purpose register for you
  // that seems cool?
  foo : gpr;
  bar : gpr;
  
  // assign some 64-bit qwords
  mov.q foo, 42;
  mov.q bar, 13;
  
  // foo += bar
  add foo, bar;
  
  // result = foo
  mov result, foo;
}

print("%\n", result);
// prints: 15

Jai will allocate registers for you. If it runs out of registers it's a compiler error. Seems nice?

Safety

Jai is NOT a memory safe language. Debug mode checks array bounds which is nice. But that's about it?

There's no compile-time borrow checker. There's no checks for memory leaks or use after free. It's the C/C++ wild west for better or worse.

There's also no special safety for multi-threading either. You're on your own just like in C/C++.

There's also no mechanisms for async code.

Modules

Jai imports modules. I think it follows typical module rules? It has some assorted macros.

#import "Foo"   // import module
#load "bar.jai" // similar-ish to #include
#scope_file     // code is NOT exposed via import/load
#scope_export   // code is exposed via import/load

Jai does not have namespaces. It solves the C name collision issue by letting module imports be assigned to a name.

// foo.jai
do_stuff :: () { /* omitted */ }

// bar.jai
do_stuff :: () { /* omitted */ }

// main.jai
#import "Foo"
Bar :: #import "Bar";

do_stuff();     // calls foo.jai do_stuff
Bar.do_stuff(); // calls bar.jai do_stuff

Problem solved?

Small Ideas

As previously discussed, here are smaller language features I think are neat, valuable, or uncommon.

Initialized by Default

By default all values in structs are initialized. You can have uninitialized memory but it's strictly opt-in via ---.

foo : float; // zero

Vec3 :: struct { x, y, z: float };
bar : Vec3; // 0,0,0

baz : Vec3 = ---; // uninitialized

There are mechanics for specifying non-zero initial values. However there are no constructors.

No Printf Formatters

Printing in Jai is safe and simple. It doesn't use error prone printf specifier.

Foo :: struct {
  a : int;
  b : bool;
  c : string;
  d : Bar;
}

Bar :: struct {
  x : float;
  y : string;
  z : [..]int;
}

foo := Foo.{
  42, true, "hello",
  Bar.{ 13.37, "world", .[1,2,3]}
};

print("foo: %\n", foo);
// prints: {42, true, "hello", {13.37, "world", [1, 2, 3]}}

There are mechanics for fancy formatting. The important thing is that the simple case "just works".

Distinct

Jai has an amazing feature I want so badly in other languages. It's what I would call a "type safe typedef".

// cpp: using HandleA = u32;
// rust: type HandleA = u32;
HandleA :: u32;

// cpp: no equivalent
// rust: no equivalent
HandleB :: #type,distinct u32;

// Functions
do_stuff_u :: (h: u32) { /* ... */ }
do_stuff_a :: (h: HandleA) { /* ... */ }
do_stuff_b :: (h: HandleB) { /* ... */ }

// Variables
u : u32 = 7;
a : HandleA = 42;
b : HandleB = 1776;

// HandleA converts to u32
// HandleB does not

// Assignment
u = a;
a = u;
// a = b; // compile error
// b = a; // compile error
// u = b; // compile error
// b = u; // compile error

// procedure takes u32
do_stuff_u(u);
do_stuff_u(a);
//do_stuff_u(b); // compile error

// procedure takes HandleA
do_stuff_a(u);
do_stuff_a(a);
// do_stuff_a(b); // compile error

// procedure takes HandleB
// do_stuff_b(u); // compile error
// do_stuff_b(a); // compile error
do_stuff_b(b);

This is exceedingly valuable for handles. I've lost track of how many wrapper structs I've written.

Universal Declaration Syntax

Something you may have noticed is everything is declared the same way.

// compile-time constants use ::
my_func :: (v: int) -> int { return v + 5; }
my_enum :: enum { Foo; Bar; Baz; }
my_struct :: struct { v: int; }
my_const_float :: 13.37;
my_const_string :: "hello world";
my_func_type :: #type (int) -> int;

// variables use  :=
// the type can be explicit or deduced
a_func_ptr : my_func_type = my_func;
a_float : float = my_const_float;
an_int : int = 42;
another_int := a_func_ptr(an_int);
a_type : Type = string;
another_type := type_of(an_int);

This standard syntax is surprisingly nice. It's simple and consistent.

Some languages are exceedingly difficult and ambigious to parse. Jai strives to be easy to parse and to provide access to the AST to users. This will eventually enable very robust and reliable IDE support.

Everyone has their own opinion on syntax. I work in enough languages I don't really care. Any C-ish syntax is easy to get used to. I'll gladly adapt to any rational syntax if it enables better tooling.

Relative Pointers

Jai has first-class relative pointers. They store an offset relative to the pointer's storage address.

Node :: struct {
  // relative pointer stored in s16
  next: *~s16 Node;
  value: float;
}

// Declare two nodes
a := Node.{null, 1337};
b := Node.{null, 42};
a.next = *b;

// can directly dereference relative pointer
value := a.next.value;
print("rel: %  value: %\n", a.next, value);

// can convert to absolute pointer
abs_ptr := cast(*Node)rel_ptr;
print("abs: %  value: %\n", abs_ptr, abs_ptr.value);

// example output:
// rel: r60 (5c_5d6f_ecc8)  value: 42
// abs: 5c_5d6f_ecc8  value: 42

Yes you could store an integer offset. What makes it nice is the syntax for using a relative pointer is the same as a regular pointer. The language handles applying the offset for you.

Obvious use cases for this are packing data and deserializing binary blobs without having to run a fixup pass.

No References

Jai doesn't have references. Just value and pointers. It belives that references provide minimal value but significantly increase the complexity of the type system.

No Const

Jai doesn't have the concept of a const pointer or variable. Yes there are compile time constants. But there's no way to declare a run-time variable and say you won't change it.

Maybe by Reference

Arguments are passed to procedure "maybe by reference". This is kinda wild.

Types <= 8 bytes are passed by value. Types >8 bytes are passed maybe by reference. The compiler might pass it by a pointer. Or maybe not! Who knows.

However the code must be written as if it were passed by, in C++ parlance, const &.

do_stuff :: (s: string) {
  // compiler error
  // Error: Can't assign to an immutable argument.
  s.count = 0;
  
  // you can make a copy and change that
  s2 := s;
  s2.count = 0;
}

In Jai the compiler is free to decide whether to pass a pointer or by value. This allows Jai to actually treat args as const & restrict and assume that no pointer arguments alias. If you want to modify the arg then simply pass an actual pointer.

Treating args as const & restrict could be problematic. For example the data might be aliased by an evil global or a pointer. The Jai philosophy is don't do that and maybe try to detect it in debug mode by making a copy and comparing bytes at the end.

Data-only Pseudo-inheritance

Jai does not have virtual functions. However it does have some form what I shall call data-only pseudo-inheritance.
// Declare a base class
Entity :: struct {
  id: int = 42;
  name: string = "bob";
}

// Declare a "derived" class"
BadGuy :: struct {
  // base: Entity is a data member
  #as using base: Entity;
  
  health: int = 9000;
}

// Helper to print an entity's name
print_name :: (e: *Entity) {
  print("Entity: %\n", e.name);
}

// Declare a bad hombre
baddie : BadGuy;

// note: not baddie.base.id thanks to `using`
print("Id: %\n", baddie.id);

// *BadGuy casts to *Entity thanks to #as
baddie_ptr : *BadGuy = *baddie;
print_name(baddie_ptr);


// Can also cast the other way
// Don't get this wrong!
entity_ptr := baddie_ptr;
baddie_ptr2 := cast(*BadGuy)entity_ptr;

The #as casting is maybe useful. The using sugar is sometimes nice. It can be used to do some real wacky things that are probably a bad idea.

Custom Iterators

Imagine you have a custom data container and you want to write for value : myContainer. Jai has a slick mechanism to make this super easy.

// Super dumb Vector with fixed size
FixedVector :: struct(ValueType: Type, Size: int) {
  values : [Size]ValueType;
  count : int;
}

// Push a value!
push :: (vec: *FixedVector, value: $T) {
  if vec.count < vec.Size {
    vec.values[vec.count] = value;
    vec.count += 1;
  }
}

// iterate all values
for_expansion :: (vec: FixedVector, body: Code, flags: For_Flags) #expand {
  // Loop over inner array by count
  for i : 0..vec.count - 1 {
    // Must declare `it and `it_index
    `it := vec.values[i];
    `it_index := i;

    // insert user code
    #insert body;
  }
}

// Declare a vector and push some values
myVec : FixedVector(int, 10);
push(*myVec, 5);
push(*myVec, 1);
push(*myVec, 5);
push(*myVec, 2);
push(*myVec, 5);
push(*myVec, 3);

// Loop and print
for value : myVec print("% ", value);
// prints: 5 1 5 2 5 3

It took roughly 5 lines of code to add write a custom for_expansion for our custom container. We didn't have to declare a helper struct with a million little helper functions and operators.

Let's write a second version that skips values equal to 5.

// declare a custom iterator
skip_fives :: (vec: FixedVector, body: Code, flags: For_Flags) #expand {
  // perform normal iteration
  for value, index : vec {
    // we don't like 5, booo!
    if value == 5 continue;
    
    // declare required `it and `it_index
    `it := value;
    `it_index := index;
  
    // insert user code
    #insert body;
  }
}

// iterate using skip_fives
for :skip_fives v: myVec {
    print("% ", v);
}
// prints: 1 2 3

Here we've made a second iteration function called skip_fives. It iterates across the array but ignores any value equal to 5. We called it by writing for :skip_fives.

The End

Congrats for making it to the end. As a reward here is a picture of my dogs, Tank and Karma. They're adorable.

Two Adorable Puppers