The Language That Never Was
Now, where do I even start... Ah, games, yes I guess that's a good
start. Some people like to play games, videogames even. But videogames don't
grow on trees, you know?
Look, mind's still fuzzy. I don't usually write in words I could go
all the way back to John Carmack being an alleged asshole to the other John
sitting in a garage making Doom or something, don't ask me. Then something
something fast forward and there's Jonathan Blow influencing the next generation
of wannabe C replacements while he sits comfortably in his private-boat-club
driven development scheme working on that sokoban game. And then there's an
influentiable 10-year-ago me, watching those streams and thinking: Surely what the
world needs is another programming language for making videogames. Except... I
thought so unironically, and I still kinda do?
Yes. That would sum up the context nicely. Let's get started.
Chapter 1
What Do I Need In A Gamedev Language
Language design is a very opinionated thing, and game development is also a very opinionated thing. Everyone has different answers here, but I'd like to think my requirements lie somewhere on a reasonable middle-ground and represent at least another person.
It is not unheard of that games need to run fast, despite the fact nobody can agree on
what that really means. Thankfully, as the old saying goes, computers are fast. But as
the other saying goes, picking Python the wrong tool for the job will make
computers go slow. It's not a surprise that most discussions around language design for
game development focus around performance. So to ease ourselves in on this journey,
let's start there.
Gotta Go Fast 
As far as I'm concerned, there is one key property in languages that separates languages that can do this (CAREFUL: turn the volume down!) from those that don't. And it's not discussed often enough. It's value types.
By value types, I mean, the language must have a notion of struct (object, potato, thing, record, whatever) where the language guarantees that:
-
What you see is what you get: If I declare a value type with 32-bit floats
x
,y
andz
, the in-memory representation for that struct contains exactly 12 bytes, arranged in the obvious way. Nothing more, nothing less. -
The thing goes on the stack, whatever stack means: Value types stay on the stack. Allocating them is as close to bumping the stack pointer as it can get. Passing them to functions should be as close to as a few move instructions as it can get. Returning them from functions should... well, that triggers painful memories from the SystemV ABI I'd rather not get into.
Point is, creating a new instance of a value-typed thing does not involve the heap in any way.
-
Arrays of structs are packed together: Games usually want to have a lot of thing. That thing can be particle, enemy, tile, or whatever your imagination allows. Having a lot of thing means having to process a lot of thing fast. Enter cache locality. If things are stored sequentially in memory and iterated sequentially, things are fast. Because we live in the bad timeline where RAM is slow and ALUs are fast and SSE is even faster and linked lists are slow because the Lisp machine never took off, or something. The thing is, if I put value types in an array, it better look like their bytes are stored inline and sequentially. You better not give me a list of pointers each pointing to data scattered throughout the heap, you hear me?
Most languages offer a limited form of this. Except for pathologically dynamic cases (not gonna take another stab at the snake, we all know who we're talking about), an int's an int, ints aren't boxed and thus follow most of these properties. But the point is to allow custom, user-defined value types. That's what allows us to climb up the ladder of abstraction without losing performance. Primitives being value types is not enough.
And note how I didn't mention a property of value types that some consider essential:
immutability. Actually, if anything, I'd prefer my value types to be very mutable. As
mutable as I can get away with. Games are big mutation machines. In that sense, I lost my
faith in Project Valhalla the moment I realized after all these years they were only
going for immutable value types
because they couldn't figure out how to make mutability work. That day was a
sad day... But let's not dawdle!
Lots of young fursuit wearers (Hey! I'm wearing cat ears as I type this, I'm with y'all
) are spending their bandwidth thonking intensely about some flavor of
automatic memory management, more often than not involving checks and borrows. To me,
that's an interesting and valid question to tackle but, ultimately, the wrong angle for
a game development language. I'm more interested in having the properties I discussed
above than anything to do with ownership, RAII or lifetimes. The way I see it, it's
not that hard to take whatever system your language offers and make it perform well: Be it
garbage collector, reference counting, generational references (hi Vale author! huge
fan!!11 I love the side notes thing in your blog), or the
infamous borrow
checker . If you want to be fast, you'll preallocate, reuse memory and avoid
allocations in your hot loop. And that's just as true for C and Rust as it is for any
flavor of GC'd language.
And speaking of things people often don't care enough about. There's another thing I wish language designers spent more thonking cycles on, one that is also essential to game development: It's metaprogramming.
I Never Finished Let Over Lambda 
Metaprogramming, what an interesting concept. Like the ancient saying goes: "Yo dawg, heard you like programming...". But silly remarks aside, it is one of the most important concepts for a game development language, and to be honest, for a language in general.
Games are huge beasts. They're a long way from the "do one thing and do it well" style of
programs other people may be used to. Games are user interfaces, world simulations, pieces
of art and gambling machine software all at once. Games do a lot of things,
most of them poorly. This is why the average game codebase is scattered with all sorts
of inter-related systems that all need to run: Sometimes caring about each other,
sometimes not. Games are a visual craft, and you cannot do everything in code. More often
than not you need to look at things, tweak things... You need an editor. Little gizmos
everywhere. Gizmos is where it's at tbh.
But I digress. What I mean to say by all this is that there are two paths to achieve all those things I'm mentioning: Power through it via large amounts of boilerplate or embrace metaprogramming. That is, automating that boilerplate, so we don't have to type it. I've seen people be successful in boilerplate-embracing scenarios, good for them! But I choose the latter out of necessity.
Metaprogramming can mean many things, however, so let me take a moment to clarify what kinds of metaprogramming I'm interested in:
- Reflection, at compile-time: Something not many languages do is letting me take a peek at the data structures they use for compilation. Code is data, not a bunch of lists. Don't keep that data to yourself, don't make excuses. Being able to look at the shape of things in code is the bread and butter of building game editors, serialization of game state and also to minimize boilerplate in all sorts of "gimme all the X that can do Y" situations in a codebase. For example, "give me all game systems in the codebase so I can call them in a loop", or "give me all classes that implement the AutoTiler interface so I can register them automatically in this widget". These are real examples game developers have been demanding your Respect for all this time. The alternative to this, is fragile boilerplate and a non-local codebase where adding one new "thing" requires registering the thing in five different files in non-obvious ways, with most related errors relegated to runtime (if at all). If your language has you spending more time writing guides to enroll new developers on "how to add a new X" instead of writing metaprograms that do it for you, maybe things could be done a bit better?
- Custom metadata: But compile-time reflection is not whole without a way for me to sprinkle my own little annotations on top of things. Things you, the language designer, may not care about. Following the previous example: scheduling constraints. For code locality reasons, it is important that I can define any scheduling constraints for a system at the location that system is defined. Not in a different file, and I sure hope not in some YAML file (we don't do YAML, go back to kubernetes *sprays you with water*).
- Code generation: The two previous points gave us eyes into the codebase. But what good is knowing about your code if you can't do anything about it. More often than not, you're going to want to look at code to generate more code. There's plenty of ways to do this, but to be honest, if you're an s-expression based language, give me lists. Otherwise just hand me good string interpolation and I'll be reasonably happy. Please don't try to get too clever and waste everyone's compile time. Compile time is precious, but more on that later.
- Reflection, at run-time: At the hot loops a game can be very static, but
in its many tentacles, games are, more often than not, very dynamic. Look, I'll
be brief and honest. If I can't put a
Type
in a variable, pass a type to a function, list the fields of a type (and methods, if you're into that) and serialize a reference to a type, I have no business with your language. Simple as that. For performance reasons, the compile-time variant of reflection is preferable, but there's beauty in the fact some languages let me write adraw_inspector(ty: Type)
function that lets me draw the user interface associated to that type as I would write my day to day code, no fuss. Doing all this at compile time involves a higher mental effort because you need to reason about different compilation stages your code will go through. Sometimes you just want to get the job done, and these things don't need to run that fast, your players will never see them. No compile-time system beats the ergonomics of runtime reflection. - Whatever the lispers are excited about, I guess: And last but not least, Lisp-style
macros. I've found this is not something I care about that much, but still feels worth
including because I low-key miss it when it's not there. I'm talking about extending the
syntax of your language, by writing a function that takes in the shape of the AST at
compile time, reshapes it and spits it back. This is the most overly hyped idea in
metaprogramming. You've surely read all about it so I'll save you the long-winded explanation. And if you need it, just trigger an infodump from your nearest lisper by asking why the parentheses look so ugly. Now, I only want lisp-style macros if they won't kill my ability to use an LSP. It can be done, just give macro authors the tools they need and they'll happily do it for you. If I have to pick between the two, I prefer a good LSP over macros. But if you can pull off both, you have my vote!
Compilers shouldn't be black boxes, the closer a compiler gets to a library, the happier I am. This is why most flavors of Lisp tickle my brain the right way. I just wish Lisps had a strong flavor of a non-copium static type system (no, I don't mean CLOS, go away *sprays you with water*). And, as chance would have it, that takes us to the next section.
Meet Me At The Top Of The Ivory Tower 
If there's one topic that causes more collective thonk CPU cycles to be spent in language
design than memory management, that can only be type systems. I'll start the section by
misquoting several smart people by saying that a good type system is like dancing with the
compiler. No words, only feelings. The compiler's warm embrace and also the
compiler occasionally stepping on your toe and vomiting a three-thousand-line trait solver
error on top of your dress.
In general I like types, and I like typing them out. As I often put it, if your compiler can't even tell its strings from its ints I have no business with it. I don't care about your "well, it's technically strongly dynamically typed" guido-pedantry. What I care about is about the language saving me from my own stupidity. But at the same time, I acknowledge that can take many shapes besides Haskell's: Because more often than not, it's less about the types, and more about the tooling.
Now, it pains me to say this as much as anyone else, but Micro$oft proved that you can take Javascript and Python and turn them into something I don't mind touching as long as the tooling around them is good. And yes, I hear you, why cast the shadow of the corporate monster over this fun and so-far lighthearted post? Well believe it or not I'm trying to make a bittersweet point here, just need a few thousand more words. Hang on. But for now let's get back on track by saying, that many people don't care about the compiler, they care about the tooling.
Now, if you're one of those people who spend way too much time looking at the terminal
output of gcc
you may be surprised by this, but most people have the compiler integrated
into their editor in a tight feedback loop: They type in code, and red squiggles appear.
It's magical. I like the
red squiggles, I like them a lot. In the old days, we got the squiggles by
programming in Java, using Eclipse.
But these days, we just use this thing
called the Language Server Protocol. Wait, what's that again? I got this eerie feeling. A
chill down my spine. This is getting ominous... But ah, it's gone. Must've been the wind,
let's continue.
My point, albeit long-winded, is that your type system is important, very much so, but the way you present that type system to your users is even more important. You need good error messages, you need a good (and responsive, and fast) LSP. And sometimes that means making compromises: Let's take a stab at scala now, and hereby declare that no language shall ever vomit the equivalent of "I couldn't solve this restriction system in time. Sorry!" upon their users. Never. If your type system is so complicated that it prevents your tooling for offering a pleasant user experience, your type system is too complicated.
While I don't mind the occasional trip to the ivory tower myself—believe me I know my way around type fuckery—as far as game development is concerned I believe our requirements are actually quite simple. In general, a language for gamedev should spend its innovation points elsewhere and leave dependent types for whoever needs them. I could make a list of things I want or don't want in a type system, but to be honest I don't feel qualified. After spending years chained up at Rich Hickey's dungeon inside the ivory tower (if you got any weird ideas, that's on you), I have learned to embrace whatever is there and try to make it work. But if I were to write such a list, it would most likely involve things like letting me define my own generic types and structures, as long as most sensible uses of higher order functions and lambdas.
I'd also have a neutral list for things like type inference: I enjoy it, but I also don't
need that much of it. Hindley-Milner is cool but sometimes things get Scala-y and the
user experience suffers. Sometimes a crappy auto
can get the job done just as well. I
feel languages are right in converging towards the idea that function parameters should be
typed out but inference for local variables inside of functions is fine.
And then there would be the big no-nos list with things like the typestate pattern, anything people refer to as "encoding invariants into the type system" (if it encodes an invariant but you're just casual about it, then it's all good) and especially any linear algebra API that tries to abstract over the storage and number of dimensions via use of templates / generics. But I acknowledge these no-nos are more of a cultural issue. I wouldn't disable these options at the language level. The fact those things are possible indicates a robust and bug-free experience for the normal uses of the type system, so they stay. Instead, I'd disable them at the developer level by using the power of the cult of personality invested in me as a language designer, instructing my minions to point and shame those who use them.
Back to Lisp, for some esoteric reason. One reason lispers give up all their clothes
and all their types when enrolling into the cult is because types hurt the "REPL"
experience. After all, if code is too static, how could we ever hook into the production
server and fix bugs live over the phone, right? That's where we're going next.
Hot And Bothered Reloading
I spend a lot of time thinking about that 57-page PhD-level dissertation of a blog post
on why the is not a good language for game
development, by Loglog Games. Having
read a lot of the comments around that post, one of the main points people took home was
that the thing that matters the most for game development is iteration time. And rightly
so, to be honest!
I'm glad people got the message, I wish my message was
as clear. To me, iteration speed is the single most important property a
game
development programming language should optimize for.
Now I'll preface all this by being brutally honest again and mention that if your only idea of "fast iteration" time involves a very fast compiler that can compile 100kloc codebase in under a few seconds so that you can close your game, and launch it again from the main menu every time you make a tiny change, you've already lost me. Because It is not about compilation speed, it is about keeping the flow going.
Don't get me wrong, compile times are important. But more often than not, what we need is something that keeps us in that flow state. Able to iterate and get the perfect game feel. Focus on that feature that will make your brain go brrr on the happy brain juice... And, well, for that you need something that will let us iterate on the code while the code is running, without having to close the game and launch it again.
It pains me to see how many developers have grown used to and are perfectly happy with a brutally sisyphean development cycle that involves:
- Starting the game.
- Navigating through menus to get into the gameplay state.
- Test the feature they're currently working on.
- Notice something's wrong. Close game, tweak code a bit.
- Go back to 1.
If I have something to thank Mr. Hickey for after all these years in the dungeon, it's having been exposed to the idea of a REPL. I do not conceive programming without something at least as half as fun as that anymore. Especially not when we're talking about a discipline as reliant on iteration speed as the development of silly videogames. If you haven't yet, go watch Tomorrow Corporation's Tech Demo and come back after your mind has been blown. Once you're done, let's tone things down a notch so that us mortals can still comprehend them, and let's enter the realm of hot reloading.
It's not that hard to understand really, you've probably been exposed to this
one way or another, but seeing how many people are stuck pushing that boulder
uphill, let me be didactical for once. This is what I expect from my
ideal gamedev language in terms of hot reloading:
-
Changing code affects the runtime state of the game, without restarting: It's... well, what it says on the tin. If I touch a line of code, I should have a mechanism to see the effects of that change in real time without having to go through the hassle of restarting the game and losing all that state it took me literal seconds to build. I find it doesn't really matter if the mechanism for reloading is me instructing an editor-integrated REPL to reload individual pieces of code by re-evaluating them with a keystroke (LISP style) or a file watcher re-scanning my codebase on file save and recompiling any new functions (rest-of-the-world style). Both have their pros and cons, I've worked with both and I can live with either.
-
That's it. I'm not really asking for much. I'm not asking for Tomorrow Corporation's tech demo, I feel their games are a very particular kind of games and this wouldn't work in the general case (or would it?). But I don't need this time traveling virtual machine from the future, I'm actually not ready for it. And on an unrelated note, if your language goes fast,
you may be worried about what happens with structs changing layout after a reload. Let me tell you: It's okay, we don't care! I've been doing this for a while, and can tell you most of us are happy with a restart when things go wrong. My only request here is that the compiler tells me: "Hey, you touched a struct. No more fun, time for a restart" instead of having to deal with the wacky consequences of UB. What matters is that we can iterate on functions, not types. Because this is where the lispers don't get the full picture: A sensible combination of a type system and good hot reloading is better than going fully to either of the extremes. If the shape of types changes, then we restart and have our tricks to keep the flow going. The same tricks the boulder-pushers have been employing for years: Cheat codes, menu skips and what-have-you.
So far we haven't asked for that much, and yet, hot reloading really throws a wrench at the whole thing. So far, my requirements of value types and type system were reasonably fulfilled by most C-replacement-wannabes out there, metaprogramming not so much. But you introduce hot reloading into the wishlist and then suddenly everything falls apart.
Most of the interpreted, JIT-ed or otherwise runtime-powered languages out there
(including the JVM that makes our little Duke friend here dance ) have no real
trouble with upholding the reloading requirements above. The problem comes when we try to
get some notion of low-levelness, you know, like value types. (Those chills again...?
let's proceed and ignore this ominous feeling).
But fear not, for in the land of the low level languages a younger and softer-skinned
10-year-ago version of Jon Blow's wholesome alter-ego, Casey Muratori,
proved all this is possible, McGyver style.
As long as you have a dynamic library, a pair of system calls, and a tiny bit of
patience.
Truly remarkable, and it really gets the job done.
So, problem solved? What's stopping all those C-wannabes from doing the same
thing? Well, glad you asked. Because at the end of the day, your favorite C
replacement may quack like a C 🦆, it may even look a bit like a C 🪿, but then
you blink and you realize you're stuck with the fucking C ABI. And with that,
let's smoothly slide onto to the next section so I can make a simulacrum of a
point.
The Interface Of The Binary Application, And Why I'm Forced To Care 
By this point you may have realized I've been typing words about lots of things I only have a vague understanding of. Let me tell you it's mostly the world's fault. I just wanted to make your screen go beep boop in fun little colors, I did not choose the life where I have to care about what an ABI is, that life chose me...
Let's misdefine the ABI, as far as a game developer like me is concerned, by saying it is how the computer arranges the bits of your things. Especially when those things need to be transported around various parts of your code, like when you pass a parameter to a function. The ABI is the set of rules that tell the compiler—or anyone who enjoys typing assembly by hand—how to do that. It's important to follow the ABI so that the receiving end knows how to unpack and interpret all these bits you arranged for it before jumping to the first instruction of another function. That is, if someone didn't manage to overflow your stack, do some nasty things, and suddenly you're jumping into the first instruction of a fun ransomware adventure.
The reality of software these days is that we're stuck with the C ABI. Lots has been written about this. Some are trying to change it, others are... well, promising they will change it. At the end of the day, while I still have respect for all these attempts, the reality of the situation is not changing anytime soon. And that reality is that the only way two different computer languages can talk to each other is if they talk in C. Or rather, the C ABI.
And let me point out "C ABI" is a bit of a misnomer, because there's not just one C ABI, there's one for every combination of cpu architecture, operating system, and then some. But what all of them have in common, is that they dictate how to arrange all the constructs of the C programming language into bits, and nothing else. Does C have structs? Yes. The C ABI will tell you what to do with them. Does C have strings? No. Well then the C ABI says you're on your own.
So, why do we care? I could tell you all the benefits of having good cross-language interop, which I actually care about, but that's not the main reason we're discussing ABIs. After all, we're happy with a single language as long as that language is good. And for those cases where we need to call out to the outside world, just use the C ABI and write some glue code once. Who cares, right? Well, this takes us back to the fond nostalgic memories of the previous section: It's hot reloading, all this is still about hot reloading.
Because, see, the problem with what I'll from now on refer to as Casey's DLL trick, is that it works for any language, but if your language isn't C, then you're forced to dumb-down to C at the point where your hot reloadable code and the non-reloadable code meet: The dynamic library barrier.
If you didn't watch the six hundred episodes of Handmade Hero, let's take a moment to
explain what Casey's DLL trick to hot reloading game code is. What you do is have a main
binary, holding your engine code, and your game code compiled as a dynamic library. The
game code calls into the engine, and the engine is watching the source files for the game
code. When the game code files are updated, the engine recompiles the dynamic library,
unloads leaks the old one, and then loads in a fresh new dynamic library with
the new code. The next time it calls into your game code, the new code runs, magic!
For Casey the trick works perfectly because he's old school. He writes in C. And maybe
there's a lesson to be learned there, but my mind is too contaminated by all those years
in the ivory tower to be touching C anymore. If I can be honest for a second, sometimes I
wish I could just write C, safety concerns aside.
When your language is not C, any communication across the dynamic library barrier has to happen in C. That means if your objects have vtable pointers... well, good luck with that! And what if your language allows higher order functions that hold state (aka lambdas, closures...)? No it doesn't! Good luck rebuilding civilization from scratch because in C land you only have a stone, a stick, and a pointer. And you're on your fucking own.
Unless... you realize there was a bit of a lie here, a sleight-of-hand if you will, because there's an alternative: Your language may not give a damn about that C ABI as long as it offers its own stable ABI. A language having a stable ABI means that the language can talk to itself across different binaries (in Casey's DLL trick, the dynamic library with game code, and the game engine) using all the constructs that are representable by the language!
So, let's take a quick moment to appreciate all the languages that sound like good candidates for gamedev on paper and have a stable ABI:
- C++ ... nope!
- Zig ... oops, not this one!
- Odin ... neither this!
- Rust ... ahahahahahahahahahaha, no.
- Your favorite C replacement ... probably not.
Oh shoot, it's only C, is it?
Ah, and Swift I've been told. Thanks wonderful fedi people. Apparently
Swift has a stable ABI too
, but I don't own an Apple device so I can't check for
myself.
Still, If we lower our expectations from: "two independently compiled binaries, created by two separate versions of the compiler running on different machines at different points in time must talk to each other", to the much softer requirement that: "two binaries compiled by the same compiler, on the same machine, on the same day, without rebooting the computer in-between must talk to each other", then some of the languages on that list above can somewhat hold their ground, but the issues is that there are no guarantees.
The main issue here is, without the explicit support and blessing from compiler developers, any trick that relies on reloading dynamic libraries for a compiler that doesn't offer that stable ABI could break at any point. This is not a made-up fear: For many languages, things are working fine in your little game jam demo but break in catastrophic ways once it's too late and your codebase has grown. And when you report it as a bug, you'll be very politely told by the GitHub™ stalebot to fuck off, because nobody will care about your little hack.
In my experience, compiler developers tend to get mad when you remind them their ABI is
not stable. Much like my mind, it's unstable for a reason, you know. In their case, it's
so they don't have to commit to any decisions because most people struggle with
commitment the language can keep evolving without being restrained by past choices.
A commendable goal, which has nothing to do with my requirements by the way. Evolve all
you want, I just want you to tell me I can compile two binaries with the same compiler
version and have them kiss talk to each other. I haven't seen enough people ask
for this, I want this. Discourse is polarized between the "stable ABI all the way" vs
"let's randomize struct
layout
on every compiler invocation for security reasons", with no nuance in between.
And it's convenient that I've run out of clever ways to tie the sections together with one another, because the remaining section is a bit of a mixed bag of features I would like to see in a language but did not qualify for a full-blown section.
... And Then Some
There's plenty of things in a programming language, so here's a list of more things I've come to appreciate in all my years of computer touching and I value in the context of videogame programming:
-
Compilation speed: Compilation speed is good, it makes everyone happy. Nobody likes it when a compiler is slow, though some people try to pretend it's not a big deal while suffering in silence. In a language with hot reloading, some sacrifices can be made to the time it takes to run a full build as long as the iterative reloads are snappy. Similarly, most people can live with full builds taking a long time as long as long as the compiler supports some form of incremental compilation. The metric you want to optimize for game developers is incremental recompiles. Nobody cares you managed to shave 10 seconds off a full build unless that makes incremental recompiles faster. And as time goes, I have less and less respect for people who argue GitHub™ Actions™ is a valid excuse as to why from-scratch compile times are a more important metric to focus on than incremental.
-
Debug Build Performance: A single global
-O3
flag is not enough. Unlike most applications, games can't be properly tested in a completely unoptimized build, because performance is an important aspect of the user experience. Performance is also binary: Either the game is playable, or it's unplayable. Though the thresholds may vary for people, we tend to put "playable" in the 30 to 60 FPS range. The thing is, what turns the debug build for a game from unplayable into playable is often a very small set of easy to isolate functions. A good game development language would let me define the optimization level at the module, or even at the individual function level, and preserve as much debug information as possible for these half-optimized builds. Some languages let you pick the optimization level for 3rd party dependencies, but in my experience that is not enough. In the games I write, most code is not third party, most code is here partying with me. -
Exhaustivity Checks: Discriminated unions are a feature from the ivory tower that is hard to shake off once it has contaminated your previously pristine C-shaped brain. They're good. But similarly to value types and people (mis-)focusing on memory management instead, I find it worth discussing this: With discriminated unions, the point is not the unions themselves but the exhaustivity checks that come with them. Different languages will present it in different ways, but it's often shaped in some sort of pattern matching that makes sure you have handled all cases. The only reason discriminated unions are powerful is because they're a todo-list generator: If you make something a discriminated union, every time you try to take a peek at it, the compiler will hand you a to-do list of all of the variants you have to handle. If you add a new variant, the compiler will make sure to give you a helpful to-do list of all the previous code locations you're now supposed to update. I don't care so much about whether this to-do list generation is haskell shaped. It could be Kotlin-shaped, sealed interfaces are the same idea but with a top hat and a moustache.
-
Everything is an expression: This is the only time you'll hear me talk about pure syntax, and I believe it's a very semantics-adjacent syntax which qualifies for my strict no-syntax-discussion rule. The historically accurate context is that Paul McCarthy mathematically proved around 200 years ago that having everything be an expression is beneficial to languages, though nobody paid attention because he used parentheses. Then something something Coffeescript and, finally, Graydon Hoare accidentally discovered this when he was working on the Ferris-mascoted language
and made the idea finally popular. What I'm talking about is when... *waves hands*, you know, like your if statement, it's a "statement" sure, but you can also go full
let x = if (a) { 1 } else { 2 };
on it and things work. (Add ternary too if you want, I don't give a shit, it's just an example. Please bring your syntax talk elsewhere *sprays you with water*). My point is, this is a good thing to have in a language, it's also uncontroversially agreed upon to be good and the only reason the old guard is not copying this one is because they literally can't. -
Operator overloading: I know this is technically syntax, twice in a row, sue me. It's almost semantics adjacent so I'll let it slide.
Game development requires lots of math, and nobody is going to type
v.add(v2)
when they could be typingv + v2
. Operator overloading is necessary so that people smarter than you can define mathematical operators for mathematical objects you didn't know or care existed, like quaternions or that new fancy alternative Freya Holmér talked about. It was never meant to be invented so Mr. Stroustrup could pretend you're pushing bits>>
and<<
of aniostream
or whatever. I don't think there's value in designing a language that prevents an act that stupid. People with that level of stupidity will find other ways to screw up things anyway no matter the tools you give them. Maybe the problem is with them, not the language. -
Good SIMD support: This was taken out of the 'gotta go fast' category, not because it's not important but because most languages already support this to some extent so it felt less important to point out. Single-Instruction-Multiple-Data is when, instead of going
on a float array, you go
on it, sometimes even more. Games do lots of vector and matrix math, sometimes it's all they do. So a language for gamedev should, at the very least, offer ways to easily implement SIMD accelerated data structures. Bonus points if you ship those SIMD-accelerated structures as part of the standard library because then you'll solve another common issue, which is the proliferation of competing, incompatible, vector math libraries.
-
Platform Support: I'll be honest and say this is not a big focus for me. Shipping to Steam is already enough of an adventure for me to worry about the consoles right now... But I'm trying to think of the bigger picture, and so I appreciate the option being there in case one of my games accidentally becomes a hit and suddenly the idea of a console port is not out of reach. A language should support mobile too of course. I know the mobile game market is rotten and I'm not shipping there, but if you're trying to change that, I support you. And as far as PC OS support goes, a gamedev language worth its salt should obviously support them all. Especially Linux. Quick aside: Here I'm talking both game developers and language developers. Get your shit together, don't think business, think basic human decency at this point. It's about accessibility: Some of us are unable to use computers with Candy Crush advertisements on the main menu, or whatever dystopian shit they've pulled off in the past 10 years while I wasn't looking. Plus it's not that hard. You have literally no excuse.
-
The Assembly Of The Web. But Only In Name, For It Is Neither: I split this off platform support because I am conflicted about this. On one hand, the best way for a new language for game development to gain traction is via low-risk no-commitment game jams, and everybody knows nobody will play your game jam games if they don't run on a web browser. On the other hand, the web standards are in such a sorry state, I wouldn't blame anyone who looks into this and then decides to turn around and never look back. WebGL is ancient, WebGPU is an experimental alpha-level thing that I'm not sure will ever ship before browser vendors finish their ongoing self-destruct routine, and the only WebAssembly that allows you to use tech that will let you ship non-broken games to the browser is emscripten. Even if it may not seem that way, my point here is less about criticizing existing technologies, and more about saying that if you're working on a programming language, or any other piece of technology to make videogames, be honest with your audience and the level of support they can expect from you. Don't go around making any big claims about browser support unless you can guarantee people won't have their audio crackle and their textures disappear. Try to dogfood things a bit and ship a few browser games of your own before you send your users into a wild goose chase. There's a reason browser gaming is still a third-class citizen after all these years. All that said, my original point stands: Game jams. So web, web it is. And as a final note, if wasm looks more daunting than compiling to Javascript, by all means just do the latter. As long as it works, nobody cares, and you'll save yourself so. much. trouble. Games on the browser are never going to run faster than V8 anyway, so focus on the user experience.
Whew! And that concludes our first chapter of the blog post. After all this
opiniondumping, if you haven't left to rage in the comments yet, I hope we're at
least somewhat aligned into what a game development language needs.
Now it's time to get a bit more personal. Because, you see, there was a time where I tried to take matters into my own hands. I tried to do something about all these ideas. This blog post is actually the story about...
Chapter 2
That Time I Set Out To Make A Programming Language
There's a time in every developer's life when they're exposed to the beautiful
world of compiler development, often via the gateway drug of parsing. Some of
them recover from their parsing-induced trips and go on to discover the world of
semantics... and this is how new babies are made programming
languages are born.
I was one of those developers. Young, influentiable, with a pure and bright gaze... And without repeating myself too much, having the pleasure of being there for that first Jai talk at the same time I was taking my first class on compilers probably ruined my brain in more ways than I care to admit. Compilers have interested me since forever, and my game development obsession began slightly earlier than that. As I kept growing as a developer, my ideas about languages became more refined and ever so slightly less stupid.
Annoyed by the mainstream, just like Shadow the Hedgehog, I grabbed my motorbike and a comically unfitting weapon and sought to discover the edgiest corners of programming. I started doing Haskell, then quickly moved to Clojure and spent more time than I'd care to admit idolatrizing Rich Hickey's ideas. Some people never recover from Lisp, I was lucky I made it out. Then, my gamedev obsession and yearning for static analysis brought me to the language everybody was hyped about: Rust.
Another Crab Bites The Rust 
The game development community in Rust has been described by some with SEO-juice-fueled newspeak-approved words like vibrant and thriving. Sometimes, when people are pointing out undeniable flaws, the words adapt and morph into others like incipient, or still in its infancy. The success of Rust is a success in storytelling first, language design second: We are the the diverse, the robust, the strong. We are responsible. Our fight is against them. The others.
And to some extent I actually buy all the rhetoric there. Look, if you can't write three lines of code without invoking UB, there's something seriously wrong and most likely unfixable with your language. No amount of public whining is going to change that, and no amount of hand-waving while screaming "safety profiles" can get you out of this mess. I appreciate the good work that went into Rust, and it was a necessity. It's also hard to feel more welcome and included than when you approach their community as an enthusiastic and open-minded newbie. Just don't fly too close to the leadership and your wings won't burn. You'll be happy.
There's plenty written on the topic already. But after spending almost five years trying to make game development in Rust work for me, I feel qualified to at least elaborate on the issues that ultimately brought me to parting ways with it. Even when just having mentioned that, I feel the need to reiterate that my points are about game development. I try to only talk about the things I'm qualified to talk about (and then also ABIs).
-
A staggering lack of metaprogramming: I'm convinced people praise Rust for their macros mainly for language demographic reasons. Half of the userbase comes from C++, the other half is coming from Javascript. They just haven't seen better. I'll put it lightly and say Rust macros are barely adequate to use, a nightmare to author, and ultimately solve none of my issues I have as a game developer while disproportionately blowing out in scope (and compile time!) at the same time. Just go back and read that section on metaprogramming from the previous chapter. In its most powerful form, the "proc macro", the Rust compiler hands you a list of tokens, gives you nothing and asks you to output a list of tokens back. All the work already done by the compiler is hidden away from you: No access to the AST, let alone the symbol table or anything that resembles type information. They also have this sad situation going on, with most of the ecosystem depending on a holy trinity of third party libraries built with questionable technical choices and nobody (or almost nobody?) being up to the task to improve the situation.
-
A Language To Keep You And Your Teammates In Check: Another thing that rubbed me the wrong way about Rust is how at every fork in the road they decided to take trust away from their users. You don't know better, let
take care of it for you. I understand this mentality: Rust is a language built for large teams, who want to ship robust, reliable software and they seek to empower everyone (yes, everyone, even you!) to do that. At no point did their core marketing team try to sell us on the idea that Rust is a good programming language for a small indie studio with one or, if you're lucky, two programmers. So why are so many people fascinated by the idea? Why was I fascinated by the idea? I could spend weeks listing all the ways in which the Rust compiler doesn't trust you, but let me tell you none of my reasons have anything to do with
unsafe
. I'll happily use the safe abstraction, I don't want to touch raw pointers, especially when you make it so painfully inconvenient. I'm talking about things like the dreaded orphan rule and its glaring lack of escape hatches. These things are in place for a reason, but when the reason doesn't matter it just feels they're there because Rust doesn't trust you to be a grown-up and make your own life choices. If you're a small team trying to ship stuff, the last thing you should be worried about is someone taking your code and implementing a trait in an incompatible way. And moreover, restrictions like this are preventing you from organizing your codebase in a way that improves the also terrible compile times. -
Iteration Times Are Bad And The Community Is Hostile Towards Improvements: The only improvement to iteration times the Rust community will celebrate is that monolithic black-box IR-spitting machine getting faster at spitting its IR. Given the community's obsession with safety, any attempts to make life better for programmers via tools like Casey's DLL trick—the only tool available, really—are met with the knee-jerk reaction of: That's unsafe, sounds cursed, and I don't like it. This reaction also extends beyond the userbase and into the compiler team. The one exception to this rule is perhaps WebAssembly. The average rustacean is very excited about WebAssembly without the web, and its vaporware component model is often brought up as a tool to improve iteration speed for games. Too bad my CPU runs by jumbling electrons around, not vapor.
-
Rust Makes People Focus On The Wrong Things: I already mentioned this in the previous chapter, but I believe the focus should be on value types, not memory allocation. It will come as no surprise that Rust, focusing its whole existence on the concept of the lifetime of variables and their owner would feel a bit misguided in my opinion. But this goes beyond that particular issue. It is a cultural issue reaching every corner of the language's design. It's a language made by and for the type puzzlers, those who'd rather tackle the bigger challenge, as long as that challenge is solved at compile time. I noticed a tendency in the community to be overly hyped by new developments that raised the bar in the amount of shit you can do with types: Const Generics, Generic Associated Types, and many more apparently nonsensical acronyms like RPITs, RPITITs and AFITs that get the average Rustacean very excited and leave me wondering how I could ever use that in my day to day game code.
-
Async Keeps Steering The Language In The Wrong Direction: A lot of these new developments for the type tetris enthusiasts became necessary after the Rust team collectively decided to open up the
async
can of worms. This is my very biased opinion, but I know I'm not alone in this. I think async brought unprecedented amounts of complexity into an otherwise still manageable language. Async will be the end of Rust if we let it. It's a big task they set out to do: Making a runtime-less asynchronous programming system that's fully safe and zero cost and lets you share references without shooting yourself in the foot is no easy feat. In the meantime, every other language and their cousin implemented the basic version of async, paid a little runtime cost and called it a day. Why is Rust paying such a high and still ongoing price? So that we can pretend our Arduino code looks like Node JS? Needless to mention that nothing async brings to the table is actually useful for me as a game developer. In the meantime, the much simpler and useful for gamedev coroutines are there, collecting dust in a corner of the unstable book. So, while ultimately I'm happy ignoring async, the idea that much more important things are not being worked on because of it annoys me.
But all these reasons pale in comparison to the star of the show. The one guiding principle, ingrained deep within the core premise of Rust, that you shall under no circumstance hold two references to the same data (aliasing) while one of those references is modified (mutability). It's either one or the other. It's an XOR. Aliasing xor mutability.
Aliasing Xor Mutable Cuts Your Wings
There is a bit of a journey every young goes through. First, you try to mutate
something while holding another reference to it, then you get scolded by the borrow
checker. Rinse and repeat, and your mind is slowly shaped by the idea. You start
developing
coping mechanisms to navigate the restriction and you get better at
it. But sometimes you need more, so you find out about interior
mutability. You learn to
tame it, you internalize it, and get more and more comfortable with controlled bending of
the rules. Then you feel invincible, you're a Rustacean, a soldier of the crab army,
through and through. You are finally a grown-up crab, and so you go on to spread this
useful knowledge. But then, for some of us, one day we realize that all these hoops we're
jumping through... there was a time when we didn't use to do any of that? And we start
looking at all those naysayers telling you some patterns, like you know the Observer
pattern, are not possible in Rust. Suddenly, a switch flips and we see them in a different
light. Maybe there's some truth to that? You know you can make anything in Rust, even a
linked list! But... at what price? A
brief moment of hesitation, the cognitive dissonance becomes too much, things start
crumbling, and it's all over. At least that was it for me.
I do not know if I'd be the same programmer today without having gone through this journey
myself though. Being subjected to the borrow checker for extended periods of time is truly
a mind-altering experience. I am now more conscious about the ownership and borrowing
state of my variables, and for the most part I instinctively avoid mutating things while
others are looking at them. I'm conscious of when I break that rule. All of that sticks
with you. But... now that I don't actually need to worry about those things anymore, let
me tell you it feels absolutely liberating in the best possible way
What I internalized (not realized, but internalized) here was that aliasing xor
mutability, the core restriction the borrow checker seeks to uphold, is not something you can
disable. People had told
me
before, but I had not internalized it. Deep down, I subconsciously thought I could turn
it off. What's interior mutability, if not that? But no, no amount of interior mutability
or unsafe
will ever let you disable it. If you manage to do it, congratulations: You have
now successfully invoked UB, that's the deal with Rust.
Well, I lie a bit. In fact, there is one way to disable aliasing xor mutability: Stick
strictly to raw pointers. But at that point you might as well be programming in C. Nobody
likes to dereference fields via a combination of ptr::addr_of_mut!
and ptr::read
when
they could type a single .
, and the moment you cast that pointer into a reference, you
better be well-behaved or else it blows up in your face. By making the only escape hatch,
pointers, unyieldingly unergonomic Rust makes sure that nobody ever escapes the one sacred
rule and comes out unscathed.
And you would say: Okay, but, isn't that a good thing? Well, for the most part, yes. But,
let me tell you a little secret: In C, you can mutate a variable while some other function
up in the call stack holds a "reference" to it. Imagine if all those duplicate &self
and
&mut self
methods were no longer necessary. Imagine a world where nobody cares about
partial borrows. And
no kittens are harmed in the process! Most of the time anyway. If those blasphemous ideas
are to be explored, there are two very different scenarios that are worth discussing here:
In the land of the many threads, very little discussion to be made. When there's multiple threads, if one thread something something mutates a variable while another thread reads it, that's called a data race. You don't want those, they're a big source of all sorts of bugs because you can never tell in which order things may happen. In a multithreaded scenario, enforcing aliasing xor mutability is a necessity and I don't think there's anything else that needs to be said on the topic. Rust does very well in that regard.
But in the land of the single thread, aliasing xor mutability is a conscious design decision. A tradeoff, and one that could've been avoided if Rust had taken a different set of forks along the road. Now, before you scream "something something iterator invalidation" at me, please assume some level of competence. I know what I'm talking about. We've got this far after all, and I thought we had something going. Don't ruin this.
There are indeed situations in which things can go wrong, even in a single threaded
scenario. A commonly cited example is modifying a collection while you are iterating it,
since the allocated buffer holding the data for the collection may reallocate or otherwise
morph and the memory you were iterating is suddenly freed. This is something our favorite
tiny friend, the little Duke, solved in the mid 90s by introducing this
advanced technique called a dead-simple runtime check (DSRC). In Java, collections check
whether they are being iterated before any mutation methods are called, and the collection
throws a runtime exception when that happens. In my experience, the situation is rare
enough that you'll see very few people claiming they need to catch these mistakes at
compile-time. And in modern CPUs (aka anything released in the 21st century), the branch
predictor ensures these runtime checks are as free as the array bounds checks you like to
boast about when you're dick-measuring against the Cniles, so no you don't get to play the
performance card here.
Having made a language that took this alternate set of tradeoffs (but more on that next
), I actually spent an awful lot of time thinking about this, and became
aware of the other situations in which breaking aliasing xor mutability makes things go south
beyond your favorite reddit counter-argument. In general, the problem occurs whenever you
are allowed to hold a reference to something where the proverbial rug can be pulled from
under you. A very good example is what happens with rust enums and pattern matching, say
we allowed breaking the one sacred rule, then you could write:
match (foo) {
Bar(ref x) => {
foo = Baz;
println!("{x}"); // oh no!
}
Baz => {
// ...
}
}
There's plenty of situations you need to watch out for, and I'm not saying that's where Rust should go. Rust is fine where it is. What I'm saying, is that I believe a world exists where you could take a different set of forks along the road, and end up with a language that doesn't feel so restrictive. A language that lets us have fun while keeping most of the performance benefits from Rust. At least, when it comes to my tiny bubble of opinionated pragmatic game development.
Just like in every breakup, it's important to be reassuring to the crabs. Give them a warm
hug and tell them that it's actually not them, it's me. Figuring out Rust
wasn't for me took a while. Don't take it too literally though: Many of my criticisms
above are shit that you could get together. People are not joking when they say the
"right tool for the job" thing. I think for most unyieldingly pragmatic game developers
out there, Rust simply isn't the right tool for the job. Rust is about the ideals above
all else. Those ideals are (i) safety at any cost, (ii) performance except when it
would conflict with safety and then if there's room for it (iii) maybe ergonomics. My
priorities are shifted. Safety is up there at the top, and I'm not saying let's all use
Python, but I value ergonomics over performance. I only want performance when it doesn't
shatter ergonomics. That is a fundamentally irreconcilable difference between me and Rust,
so I left.
I've always been a bit of a rebellious spirit. Rust is all about what you
can't do, and I just wanna carefreely do stuff. I'm with them, safety is a
must, I just don't appreciate all the unjustified dogmatism that comes with it.
And if Rust is known for something, that is for being a grumpy parent, yelling
at their kid because their room is a mess, they're being annoying with the
guitar and they're listening to this "twisted sister" noise... so after years of
bottling up frustration I decided to embrace my cringe angsty teenager spirit
and declare that we're not gonna take it
anymore!
Punk's Not Dead
If this were a movie, this is the part of the movie where a music montage starts with
scenes of me frantically typing code. The code of a compiler. All those tests slowly going
red-to-green. And a game, a game suddenly starting to emerge, written in the language.
Those were good times. Imagine thinking you can change the world, make history. The
megalomaniac tendencies in me were kicking in hard, I tell you. I'm getting nostalgia just
thinking about it.
I set out to build a programming language of my own. One with a set of guiding principles
that amounted to: (i) I want to make games, (ii) have fun, and (iii) can't tell
me what to do. What could go wrong?
Despite the roast in the previous section, I actually liked many things in Rust, still do. It ticks many of the small boxes, although not as many as the big ones. The goal here was to not fully abandon an ecosystem I still held dear. To contribute back, and give the community what I considered at the time would be the solution. Rust's perfect companion. We're talking about a language that:
-
Is Powered By LLVM: No compromises, we want performance. The language compiles to the same glorious LLVM IR Rust compiles to. This means things will be fast, most of the time comparably fast to its cousin language.
-
No Stable ABI, But A Predictable One: Structs don't shift layout randomly according to the whims of the compiler, they follow the C ABI, always. Things are documented, enum layouts too: Discriminants are 64 bits (yes, deal with it) and the variant layouts are those of a C union. I believe the performance gain from any of the code golfing Rust is playing with memory layouts is minimal. I have no data to back this up, but I'm convinced it wouldn't make a difference for your typical gamedev workflow. It does make an impact on hot reloading though, a huge one. Because this stable layout is what you need so that the previous version of the code and the next version of the code can share the same runtime state.
-
Fully Interoperates With Rust: Bidirectional bindings between Rust and this language let you call functions across the language barrier. A combination of proc macro magic and... well, the compiler itself, allow you to call functions across both languages. No need to dumb things down to the C ABI: The language uses the same allocator as Rust, and all of its core data structures are compatible with Rust. A
Vec
in this language can be marshalled as a Rust Vec, or a slice, in constant time (and very little constant time at that). -
Rust-Inspired Syntax: I don't care about syntax. I knew people would keep yelling at the parentheses if this were a lisp, and people seem to like Rust's, so I blatantly copied its syntax where it made sense, and made tiny adjustments where it didn't. It also made sense, given the intended audience.
-
No Borrow Checker: And instead, nice and ergonomic automatic reference counting. I believe automatic reference counting is a great fit for games, probably the best. It is one of the easiest to implement (though not as easy as you may think!) and, although typically slower, it is also more predictable than a GC. If your game is allocating and freeing too much, it will start running slow with nobody to hide that fact from you, and that's a good thing to have in games. Regarding the usual argument about reference loops leaking memory, to me, it's a non-issue. Just use
weak
references. And a good memory profiler, which the language should have anyway, to help you catch any oopsies. -
Value Types Come First: Things go on the stack. An array of things has the things nicely packed in memory. Things are passed around by (shallow) copy, sometimes optimized away, and there are no move semantics. Sometimes you'll need the heap, and that's where the refcounted pointers, called
Ref<T>
, come in. They're are also copied around, with the refcount operations being built into the language and optimized by it. -
Duck-Type Generics: No traits, generics, nor any of the complexity clusterfuck that comes with those. Just plain old boring C++-style templates. I had some vague notions of maybe something looking like concepts being useful in the future, as a tool to improve error message quality. Though when it comes to whatever error messages the trait solver vomits onto its users, the bar is on the fucking floor, and yet people still manage! So templates it is, and anything else is low-priority.
-
Proudly Single Threaded: Instead of dealing with all the complexity that comes from allowing multiple threads to run concurrently on the same shared state, the language is single threaded. Because it interops with Rust, and the runtime is lightweight, it still supports multiple computations to run in parallel. You just need to use whatever machinery you want in Rust to spin off multiple runtimes and make sure access is coordinated. However, the whole thing is made to encourage you to keep your concurrent and parallel shenanigans on the crab side because that's where the crab shines.
-
Modern Tooling: LSP and syntax highlighting, right there from the start. Eventually, a code formatter, good-quality debugger, time profiler, memory profiler. Give. Me. All. The. Tooling. I'm not the best at programming compilers, and I only got around to implementing the first two things in this list, but I wouldn't say no to any of those things and I'd consider them all a priority. I believe there's something to the idea of making the LSP and the compiler part of the same codebase and reusing as much of the compiler's code for the LSP as possible. I always cry a bit when a whole team, completely detached from the team writing the compiler, is tasked with writing a second compiler frontend just so you can get the red squiggles. I get extra sad when the tooling teams lag behind the compiler team, so new language features are always half-supported, sometimes for months.
And I saved the most important bit for last, because it deserves a bit more talking.
Hot Reloading As A First Class Citizen
You heard it, and I hope you're not surprised. The main goal of the language is to have Rust, except with hot reloading. Lots of design space overlap with Mun in that regard. Except my language has strings and arrays and lacks a marketing team and a production grade GitHub™ Actions™ CI pipeline (sorry for the stab, but also: come on! how can you host your first game jam before the language has arrays?).
The way this is achieved is by leveraging the nice, but still very unknown LLVM JIT APIs.
It's almost as if by me having shared this, you too are now sitting in this proverbial
pile of gold: Turns out that the folks behind the Low Level Virtual Machine that isn't a
virtual machine are bad at naming things. Their "JIT" thing is a bit of an accidental
misnomer. When people hear JIT they more often than not think of interpreter plus heavily
dynamic language plus JIT. That's not what this is about. What you get is LLVM
IR compilation at runtime, and your code will run
as fast as the equivalent clang
-O3
'd C if you let it. I measured. And what's the
catch? None that I'm aware of. This is the same strategy jank is
following. Which by the way look at them! We started pretty much at the same time and I
got this silly blog post to show up for it while they are funded! I'm rooting for jank,
jank's cool.
The big idea is that the full game is compiled as a single LLVM IR module, the same thing Rust does with crates. That takes a while to compile (same as rust crates). But then, on subsequent reloads, only the new functions are compiled, and some monkey-patching function pointer fuckery machinery makes sure the new versions are called. When not inlined, the machinery has a cost, but the cost disappears on release builds. In fact, the whole JIT compilation model disappears on release builds, because this whole thing is built to support AoT compilation, and it actually did, I even had a working proof of concept for it.
It was fun, it was glorious, and I'm not exaggerating when I say most of the things I'm
talking about here were actually implemented in that working proof of concept of a
compiler. Except for generics, I never reached the point where I needed actual generics,
so I had to settle with gonerics. You know, where only a few blessed types in the standard
library (including Vec<T>
, Ref<T>
and Option<T>
) were baked into the compiler.
And I mentioned a game! This became the language I used to build the first few versions of my latest game, Carrot Survivors! Here's a gif showcasing the game while I was using this language:
Even with all the friction of having to develop a compiler at the same time as the game, the hot reloading made the development experience delightful. This, I believe, should be a lesson for any aspiring compiler developer out there: Have a use case, and dogfood towards it. I can't stress how valuable this experience was. If your language is fueled by toy examples, you will make a toy language. If your language is fueled by the desire of self-hosting itself, you will make a language for making compilers. You have to be your first and most avid user, or else why even do this at all? And, needless to say, if your aspiration is to make the next big language for game development: Make. A. Game. With. It. No excuses. I'm looking at you. This is not a metaphor. Yes you. You know who you are.
So, just so you can get an idea of all the cool little things I built into this language
and we didn't cover yet, the next section is going to be all about this dogfooding: Let's
break tradition and, instead of hello world, look at some non-toy real world usage
examples (as real-world as my silly game is anyway ).
A Primer In Rebelry
Much like in real life, more than names, I like to think that entities accumulate a
collection of monikers. Words that other beings use to describe them. After all, if other
entities were to know my real name (not the one in human "legal records", mind you)
they'd hold power over me. Programming languages are a lot like that. My language was
called Rebel
. But it being too (intentionally) cringe of a name—sometimes even for
me—most people referred to it as 🥕. For accessibility reasons, even though I'm told most
screen readers would speak out a normie-moji just fine these days, this is a carrot emoji.
So the monikers this language accumulated during its life included 🥕, 🥕lang and
carrotlang. Oh, and of course, avoiding to name one's creation: "the language" which is
one of the guiding principles of the Jonathan Blow school of language design.
Anyway, I hope you're not here to discuss names. Let's discuss code snippets!
struct Sprite {
tex_name: Option<String>,
tex: TextureHandle,
frame_idx: i64,
next_frame_time: f32,
}
struct SpriteDrawParams {
// The position in the world to draw the sprite.
position: Vec2,
// The alignment of the sprite origin
align: SpriteAlign = SpriteAlign::Center,
// Defined as an offset from the origin. The point in which the sprite
// rotates around and gets scaled from.
pivot: Vec2 = Vec2_zero(),
// The color tint to apply to the sprite.
color: Color = WHITE,
// The size of the sprite.
size: Vec2,
// The rotation of the sprite in degrees.
rotation_degrees: f32 = 0,
// Whether to flip the sprite horizontally.
h_flip: bool = false,
// Whether to flip the sprite vertically.
v_flip: bool = false,
/// The z_index to draw this sprite on
layer: Layer,
}
Ah, sprites, the heart of every game. I wanted to start with this snippet because it showcases a few things I really like about the language. Let's get the boring out of the way first: Yes, this looks like Rust syntax. You'd be forgiven for thinking this is Rust if you've never written Rust. That's the point: syntax is uninteresting to me. If you start discussing syntax with me, I'm going to pretend I'm listening because I'm polite, but I'm actually singing this obscure sonic green hill lyrics youtube video inside my head. Now onto more interesting bits.
One thing I really like from Rust is the idea that structs have a set of fields, and the only canonical way to construct those structs is by invoking the only one constructor that requires initializing. every. single. field, no exceptions. This is why you don't see any "constructor" definitions here or things that look like it. As you may expect, the way to initialize these structs is using syntax like this:
let s = Sprite {
tex_name: <something>,
tex: <some_other_thing>,
frame_idx: 0,
next_frame_time: 0
}
Unlike Rust, though, you can see I relaxed the rules a bit: Look at all those default
values for fields! v_flip: bool = false
, because 99% of the time you won't want to flip
things vertically. 🙃 Constructing types in Rust sometimes is painful. There is a
catastrophical combination of lack of function overloading plus lack of default arguments,
both in functions and types declarations themselves. All of this leads to a proliferation
of functions called new
, new_with_this
, new_from_that
. Or perhaps even worse, some
avid redditors dusting off the gang of four book and summoning the Builder pattern to
justify their life choices. None of that nonsense here. If a field has a default value,
then you can omit it when constructing the type. Does that mean my language has incredibly
advanced const
-evaluation machinery that lets me put anything in there? Ahahaha nope
. I just do the obvious thing nobody seems to point out in these
arguments. If I have a field in a struct initialized like this:
struct Foo {
thing: String = call_a_fucking_template_engine_over_http_for_all_i_care(),
}
Then, the expression on the right hand side gets automatically inserted at every construction site and deterministically evaluated when the struct is constructed. It's actually not that hard.
Other minor things to point out are the types, you have the usual primitive suspects like
f32
and bool
, you'll notice custom user types too, and there's even our favorite
burrito-shaped type, the Option<T>
in there!
People who have not suffered in prison won't bat an eye to that
String
in
there, they'll probably bundle it up logically to all those other primitives. But for
those who have seen the horrors, we know strings get messy. So let's focus on strings.
There, have a string snippet.
impl StatUpgrade {
fn apply(self, stats: Stats) -> Stats {
let old_val = stats.get(self.kind);
stats.set(self.kind, self.op.apply(old_val, self.amount));
stats
}
fn describe(self) -> String {
let increase = match self.op {
Add => $"+{format_f32(self.amount, 1)}",
Multiply => $"+{format_f32(self.amount, 1)}x",
};
$"{increase} {self.kind.describe()}"
}
}
Stat upgrades, now that's some real world example. Most gamedev examples don't show
"stats", because people who write about gamedev focus on having a character you can move
around in an empty field, not in designing a level progression system that's actually
engaging. We're not doing that either here, but let me tell you: That is the real
nightmare. Designing programming languages for a bunch of nerds is easy next to designing
an arbitrary set of rules that is fun, but also easy, but also challenging enough,
but also rewarding, but also satisfying... I'd like to think I've gotten
better at this, but the more you get into game design, the more you realize it is by far
the hardest part of developing a game.
Anyway, let's get back to the cozy world of language design... Strings! In the Rebel world
of the 🥕, there is only one opinionated String
type. If your use case needs to
differentiate between a dozen string types, you probably need Rust and not Rebel, and I
fully support you. I admit sometimes the subtle distinctions make sense, but those never
mattered to me when writing games. And while the can of stringy worms is open, I'll
mention Rebel String
s are immutable, literals are interned, other strings are
intern-able and there is proper string interpolation.
By proper string interpolation, I mean like the one in Python's f-strings or Javasript's
backtick-strings. You know, not like the one Rust has where you can only type the name of
a variable, but any "complex expressions" were deemed too hard to implement
dangerous because Rust doesn't trust you to not abuse this power and write conway's game
of life inside a string literal. I say you $"{watch_me() + do_just_that()} :)"
.
But I know what you're here for. You wanna take a peek at the horrors
internals, let's shift focus into the standard library for a bit!
struct RawString {
len: u64,
ptr: RawPtr<u8>,
}
/// Builds a string from a pointer and a length. This includes allocating a new
/// Ref to hold the inner RawString
extern "stdlib" fn String_from_ptr_and_len(ptr: RawPtr<u8>, len: u64) -> String {
let r : Ref<RawString> = Ref(RawString { len: len, ptr: ptr });
// NOTE: This below is "poor man's transmute". This works, but is highly
// unsafe. We are allocating a new Ref, and then converting it to a String,
// which has the same layout, but changes the deallocation strategy.
//
// We then dereference that string and put them into a variable. This
// creates a copy of the ref, increasing the refcount, wich we return.
//
// Before returning, `r` is dropped and the refcount of the returned string
// goes back to 1.
let s = &r as RawPtr<String>;
let s2 = *s;
return s2;
}
Wow, look at that wall of text of a comment. Ominous. But let us not concern with whatever
scary thoughts past me was thinking back then, this was working, it was just a bit
wacky. As you can see, these strings consist of a RawString
struct: pointer plus length.
No capacity, because String
s are immutable. The string is then wrapped in a
reference-counted pointer, aka Ref
because otherwise you'd have to deallocate it
manually. So, except in name, and despite the ominous comment, a String
is a
Ref<RawString>
.
You can't see that here, but Rebel has a runtime, and that runtime is written in Rust, so
Strings are allocated by the same allocator as Rust. That means you can cheaply throw
strings over the fence to the Rust side and have them behave as &str
through some
RebelString
wrapper type. You couldn't easily morph them into a Rust String
though,
because of the immutability, but other than that, our strings play nice with the crab!
Did I also mention our strings play nice with the C crowd? Strings have a length,
they're civilized strings that enjoy drinking caramel chai latte and go shopping for cute
outfits. But they're also nul-terminated. That nul terminator wastes a single byte per
string: "Preposterous!" I hear you cry, but this makes it so not only Rust interop is
nice, but C interop is nice as well. If a C API, say Raylib's—to
name one C API I'd like to use—takes in a
const char*
, chances are its Rebel wrapper can
take a String
at no extra cost. Now this is a decision I made, but I'm also not super
attached to it. You may feel strongly against nul terminators in strings, and you can
anticipate my reply as: "sure, whatever, just don't have a nul terminator then".
I'll indulge into a bit of syntax discussion now, and note how you may have noticed the
RawPtr<T>
construct there. I believe if there's one thing I care about in syntax, it's
consistency. We don't need special syntax for pointers, especially not if it means they
look like *T
and *mut T
, we also don't need arcane syntax for things like slices
&[T]
or whatever else you can cook up. There's sigils and there's types, and you
don't mix the two so that "thing plus sigil" is also a type. Everything is better and more
consistent—especially when it comes to making things easy for metaprogramming—if
constructs in your language are consistently named. A raw pointer is a raw pointer to
something, right? We know how to spell that out, it's RawPtr<T>
, no special syntax.
Just like when we mean a vector of something we type Vec<T>
.
But let's leave the low level lands and move onto more interesting things, this is not a low level language after all. Did you notice that match statement two snippets ago? Does that mean...
enum ControllerId {
Keyboard,
Gamepad: u64
}
impl ControllerId {
@operator(==)
fn eq(self, other: ControllerId) -> bool {
match self {
Keyboard => match other {
Keyboard => true,
_ => false,
},
Gamepad(id) => match other {
Gamepad(id2) => id == id2,
_ => false,
},
}
}
}
Enums! Or discriminated unions! Or whatever the folks up at the ivory tower like to call
these!
No surprises there, since I mentioned ten thousand words ago that I like discriminated
unions when they're paired with exhaustiveness matching. You can infer the existence of
those todo-list-generator exhaustiveness checks at play here from the wildcard _
cases.
In another manic burst of simplification, I decided to do away with the old cruft, and
remove the useless inline struct and tuple variants of rust enums. Instead, enums here
look more like a discriminated union: Each variant has a tag (Keyboard
, Gamepad
), and
variants can optionally have a payload (in Keyboard
's case, a single 64-bit unsigned
integer). This payload can be as complex as you want, it could be its own struct, or a
tuple! The difference here is that of orthogonality. Most people dislike this aspect
about Rust enums but often focus on the wrong words to describe the feeling. The problem
is that Rust lets you define enums like this:
pub enum SomeEnum {
// Field-less, just the tag
Variant1,
// One-element tuple, the variant has type `A` as its payload
Variant2(A),
// Multi-element tuple, the variant has multiple types as its payload
Variant3(A, B, C),
// Inline anonymous struct payload, the variant has a sort of "named tuple" as a
// payload, where fields have names and each name has an associated value, like
// a struct, but it's not a struct.
Variant4 { foo: A, bar: B }
}
To be clear, what I removed is variants 3 and 4, and kept 1 and 2 as the only options.
Then I shifted syntax a bit so enum definitions are consistent with structs using :
to
separate variant and payload. The benefits of this, beyond mere consistency, is
orthogonality (that word again!). Type definitions are type definitions, and an enum is
a single thing where each variant has either zero or one type definitions. No special
rules, no magic anonymous structs you can't name the type of. You'll often hear rustaceans
ask why you can't declare that a function takes in a specific variant of an enum. Even
though this is one of the least uncontroversial wishes in the Rust community wishlist, it
hasn't happened yet because the rules around that are so complex it would take a huge
effort to get it right. You'll often hear people recommending you stay clear of variants 3
and 4 and stick to variant 2, declaring a struct for each payload. That is exactly what I
did here. That way, if you want a function that deals only with gamepads, what you do is
pass in the gamepad index (the payload), and not a ControllerId
. Wrap that u64
in a
GamepadIndex
newtype if that makes you feel less uncomfortable, because if you're
worried about that then you just missed my point.
Now I'm already hearing you cry: but what about the convenience? Look, I've spent 10 years writing clojure for fun and profit, so I have grown to appreciate deeply nested un-flattened type definitions too. Sometimes traveling outside your little bubble gives you ideas, and when you look at things from a different angle, you realize what Rust needed here was not five more different ways to define a type, but merely a bit of syntax, just a trick of the light: A fuckery. Let's make our enum a bit more contrived than what was actually needed so I can make my point:
// This is the flattened version you see in Rust
struct KeyboardId {
RawPtr<u8> some_sdl_shit;
}
struct GamepadId {
index: u64;
}
enum ControllerId {
Keyboard: KeyboardId,
Gamepad: GamepadId,
}
// But you could write it like this as well in Rebel
enum ControllerId {
Keyboard: struct KeyboardId {
RawPtr<u8> some_sdl_shit,
},
Gamepad: struct GamepadId {
index: u64,
}
}
See? Orthogonality, that's what I'm talking about! The nice thing about this, is that inline type definitions are no longer an ad-hoc thing that is restricted to some corner inside of enums. Anywhere a struct expects a type, you can just type in the type definition, right there and then. If your compiler is any good, then order of definitions does not matter, so you can nest definitions like this, and it would be as if you had typed them out one after the other, with the added clarity introduced by nesting.
However, because this is a syntax thing, I must confess I never got around to implementing it. Because typing out the flattened version never bothered me too much. But the idea is sound and implementation is quite trivial actually, I just never had a pressing need for it... This was actually one of my guiding principles throughout this adventure. My brain may feel very proud of this idea, but if I never bothered implementing it, maybe that should tell me something. One thing is true: I sure as hell never needed the inline anonymous structs and tuple variants Rust has either.
You may be pleased to hear I also had plans for yet another orthogonal feature for the language, anonymous structs. It's pretty much what it says on the tin. Just the idea that anywhere where you could write a type name, you could write a little struct definition without name, and that would be an anonymous struct, or named tuple if you must. And because of, say it with me again, orthogonality that same thing would be possible in enum definitions. So you can avoid one of the two hardest problems in computing. For the same reasons as I just mentioned, I never got around to implementing it, but I believe it's cool and would've eventually made its way into the language.
Anyway... Enough with features I never got around to implementing. I hope you didn't miss
that @operator
thing up there, because that's coming up next!
impl Vec2 {
@operator(+)
fn add(self, other: Vec2) -> Vec2 {
Vec2 {
x: self.x + other.x,
y: self.y + other.y,
}
}
@operator(-)
fn sub(self, other: Vec2) -> Vec2 {
Vec2 {
x: self.x - other.x,
y: self.y - other.y,
}
}
@operator(*)
fn mul(self, scalar: f32) -> Vec2 {
Vec2 {
x: self.x * scalar,
y: self.y * scalar,
}
}
@operator(*)
fn mul2(self, other: Vec2) -> Vec2 {
Vec2 {
x: self.x * other.x,
y: self.y * other.y,
}
}
}
You know, I put my money where my mouth is. If something was up there in that first
chapter, I sure as hell focused on it. Did you hear me mention a trait system as an
essential language feature? No you didn't. Did you, on the other hand, hear
me mention operator overloading? Yes, yes you did! And Rebel has those.
I don't dislike Rust's idea of operator overloading: Tying each operator to a trait so things implementing the trait support the operator is actually great. I just didn't feel a trait system was the right fit for a gamedev language. Traits are there so you focus on the generic and the abstract, and when making games you want to avoid all the associated rabbit holes that come with thinking abstractly. Games are very concrete, and so are their typing needs. Any generics you'll ever need look more like template replacement, and so it's better to just take the simpler approach and embrace templates. Templates are great when you keep them simple, and if I ever hear you mention the word SFINAE I'm muting you for three weeks, because I didn't mean that.
So, what I did was simple, but I believe quite elegant. Stick @operator
on top of a
method, and that method becomes an operator overload. The method will still exist as a
regular method, so when you're getting all meta, you can refer to the operator by using
its name. But what happens if you want to have a way to multiply vector with scalar, and
you're some sort of degenerate that believes think it also makes sense to
multiply a vector with a vector? Well, you annotate two separate functions with
@operator(*)
and they'll become two separate overloads for the *
operator. The
language will make sure things are consistent, and there is no orphan rule of any kind to
stop you from bolting operators on a foreign type... That is, if the language had any
notion of "foreign" in the first place. I never added a module system.
Just to hammer that previous point even further: There is no orphan rule. None, whatsoever. The language got its name for a reason. It is not called WellBehavedLang, it is not called ResponsibleLang. It is not called "I am the maintainer of a popular open source library and I spend my free time reviewing pull requests"lang. If you can't sleep at night thinking someone might implement an operator, or a method, for your pristine type you carefully designed and break your invariants 👻, you're in the wrong mindset. This is a language for small tight-knit teams of competent people that respect and trust each other. If coherence is accidentally broken, you'll see an error, and you can yell (/affectionate) at whoever screwed up because they're right there beside you: Either physically, digitally or through some sort of plural system interaction.
Now, you know what's something a rustacean hates more than breaking coherence rules?
Global variables. So let's look at that next.
#def_game_data(Character)
global CHR_DRIFT_CAR: CharacterKey = add_character(
"chr-drift-car",
CharacterData {
name: "Drift Car",
name_lines: [
"Drift Car",
],
short_desc: "I have a feeling I have been in this place before.",
tex_name: "drift-car",
death_text: "I'm out of gas, gas, gas...",
stats: Stats_new([
StatValue_new(StatKind::HitPoints, 100.0),
StatValue_new(StatKind::HealthRegen, 0.1), // hp per second
StatValue_new(StatKind::MovementSpeed, 4.0),
StatValue_new(StatKind::PickupRadius, 3.0),
]),
starting_weapons: [WPN_CIRCULAR_SAW],
}
);
Woah, so much to unpack here... but let me start at the less obvious and direct your eyes
at those string literals: Notice the lack of .into()
or String::from
surrounding
those? Remember: There's just one string type. You may have almost missed
it, but look at
stats:
, is that a list? Surely such a convenient syntax would be
reserved for initializing an almost useless stack-allocated arr... what? That []
is
initializing a heap-allocated Vec<T>
? Yup. Let's not turn this into a syntax argument,
but I like it when the language is lean enough that I can encode my game data into a set
of language literals. Sometimes it stays that way all the way until release, sometimes
it's moved into external data files as needs arise.
But with that minor thing out of the way... look at it. I'm sure you've seen it. Equally
glorious and disgusting. You know what I'm talking about, that global
keyword. It is
piercing into your mind, sending chills down your spine, in a way you never thought was
possible. Very few languages have the boldness to introduce a global
keyword, because
everyone universally agrees that globals are bad, they just don't know how or why, but
somebody told them so it must be true. All of those languages also support globals,
because globals are actually useful and necessary, they just hide them under a euphemism,
like static
or the "singleton pattern". Some of them are more unhinged than I am and
make all variables global by default, I'm looking at you Lua, huge respect there. But
also: What the fuck?
Globals are fun. There are problems with globals of course. Everyone knows you should use
dependency injection instead so that you can stub your main systems to run in unit tests.
But we're not here to climb up the ranks in the corporate ladder, we're here to have fun,
and we're here to get shit done. In that sense, a global is like a stick: The ultimate
tool, endless possibilities, and it's been there since the dawn of humanity, but that
doesn't mean we've stopped using it. We use sticks all the time (at least I do
).
In Rebel, globals are named for what they are, and because it's a language built with hot
reloading in mind, hot reloading doesn't break with globals, but gives you the tools to
work with them. For instance, you can tell the runtime whether a global must be
reinitialized on each game reload or not. Why is that useful, I hear you ask? Well, the
global has two parts, the type definition and the initializer. As long as the type
definition doesn't change, you can keep safely reloading the game. Sometimes you'll have a
global that holds your list of enemies on screen: You don't want that to go away across a
reload and reinitialize back to the empty list! On the other hand, for the snippet above,
it'd be quite rad if you could edit your character's stats right there and see the changes
in real time. Well, that's what Rebel did. A @persistent
annotation in globals lets you
define whether the contents of globals survive a reload or whether their initializer
function should be invoked again.
There were some rough edges I never fully ironed out, but it was convenient and it was
fun to work with. Rust makes working with globals almost universally a painful task. If
you dare so much as to utter the words static mut
, you have a security advisory at your
doorstep. The other way to do globals is via the world of fun introduced by having to type
things like OnceCell<Arc<RwLock<T>>>
, and all the associated pain that comes with it.
It's not that I don't see the value in these things, mind you, but my globals are just as
safe as Rust's and they don't make my life miserable.
About those rough edges, well: When globals can be initialized with whatever (remember,
there is no const
here, any expression is a valid global initializer), you run into the
problem of a global being initialized by dereferencing another global. I never solved this
in the general case (as in, if a global accesses another global through a function call),
but at least I did in the basic case. For the remaining cases, I added a hacky @priority
annotation so I could control initialization order. Not the best if you ask me, but it did
the job. I had a to-do item on that kanban board to rethink all this global initialization
logic.
There is one thing in that snippet I didn't comment on. That #def_game_data
at the top,
what's up with that? Well, for all my talk about metaprogramming, when you're in charge of
writing the compiler, you have so much power and there is so much to do that I didn't have
time to add a lot of metaprogramming features. Still, what you're seeing there is Rebel's
rudimentary macro system at play! Let's take a look at that:
macro #def_game_data($name: Ident) -> ItemList {
#let($Data, #concat($name, Data))
#let($Key, #concat($name, Key))
#let($GLOBAL, #concat($Data, _GLOBAL))
// Define the AMap for this type of game data
#def_amap(String, $Data)
// This global will store all instances
@priority(100) global $GLOBAL: #concat(AMap_String_, $Data) =
#concat(AMap_String_, $Data, _new)();
// The key can be used to refer to a piece of game data.
struct $Key {
key: String
}
// add_game_data: Registers a new instance of the game data type
fn #snake_case(#concat(add_, $name))(name: String, data: $Data) -> $Key {
$GLOBAL.insert(name, data);
$Key { key: name }
}
// all_game_datas: Returns all instances of the game data type
fn #snake_case(#concat(get_all_, $name, s))() -> Vec<$Key> {
let keys = $GLOBAL.keys();
let out : Vec<$Key> = [];
for i in 0..veclen(keys) {
push(out, $Key { key: keys[i] });
}
out
}
impl $Key {
// Returns the game data instance behind this key
fn get(self) -> $Data {
$GLOBAL.get(self.key)
}
@operator(==)
fn eq(self, other: $Key) -> bool {
self.key == other.key
}
}
}
What have we got here? I hadn't looked at these in a while, and I must confess I feel very
proud of myself for coming up with this. The idea here was to have
something that sits in between Rust's unwieldy
macro_rules
and the pile of hacks that
the C preprocessor is, with a sprinkle of Lisp on top.
Now, it's weird that, after all that talk about metaprogramming in the previous chapter I
go on and implement the one single thing I didn't care about. This is the most plain and
boring kind of metaprogramming: Token substitution. But I did it out of necessity. This
was easier to implement for me than generics, and it proved just as useful, if you can
excuse typing ThingSet_new()
instead of Set<Thing>::new()
.
Let me start by quoting myself and saying when it comes to hygiene, you should focus on
taking a shower from time to time and leave my macros alone. This is why my
macro system was anything but hygienic. I ask you to behold the gloriousness of
#snake_case(#concat(get_all_, $name, s))
and then take a minute to ponder about the fact
concat_idents!
in Rust being both unstable and not even as half as useful as this. Look
at what these folks need to mimick a fraction of our
power. In case it's not clear, what this is doing is
taking $name
, e.g. Character
from the first argument passed to
#def_game_data(Character)
, and constructing the get_all_characters
identifier, used to
declare a new function. What happens if the user had already declared a function with the
same name? See my point above about the orphan rule, languages that respect the competence
of their users, small tight-knit teams that respect each other as intelligent human
beings, and so on.
But there is a lot more to unpack here: Look at that #def_amap
, macros calling out to
other macros! I mean, it's not that surprising, but this sort of modularity is useful, and
I made sure it works fine. Macros were their own little language, like compile-time
functions, taking AST blobs and returning AST blobs, composing well with one another. Then
the compiler provided some intrinsic ones like #snake_case
or #concat
for your
delight. And don't forget about those #let
! I find it very useful to be able to define
reusable snippets of ast blob inside my macro so I can avoid repeating myself. All in all,
this ended up being a lot more ergonomic to use than Rust's macro_rules
, while still
being a lot more safe (integrated with the compiler, works at the token level and not with
string substitution) than the C preprocessor.
On a very unrelated tangent, what's that AMap
thing the #def_amap
macro emits, you may
ask? Simple! It stands for array-map
and it's the most dumb way you can implement an
associative data structure: List of key-value pairs, linear scan on every lookup. Somebody
told you years ago this was a bad idea, but I just love the simplicity of it and I wish
more languages offered it as a standard data structure. Nothing against hash maps, mind
you, I just like to have the choice sometimes, and these little guys come in surprisingly
handy time and time again.
Back to the topic, if there even was one to begin with, I find the biggest asset of this
macro system was its plain and boring procedurality. I never needed loops or if
statements, but the interpreter was super simple and it would've been trivial to implement
#if
or #for
. There is no puzzle here. It's not the sort of thing that would get your
average Prolog enthusiast excited. It won't give you a chance to write an entire book on
the topic with the most obscure cursed programming
patterns. It is
just yet another uninteresting stick you can use so you can scratch your own itch. I hope
other people can join me in being excited about stick-like tech that gets the job done,
because it feels lonely in here sometimes.
The fact macros were its own little language, separate from the real language is actually a bit of a regret of mine. I'd rather have a lisp where macros are written in the same language you use to food your dog. I just couldn't figure out how to make that work without the parentheses so I did this instead.
I guess the only thing left I want to show you is the Ref
. I've passingly mentioned it a
few times, but it was truly the star of the show, and a great excuse to discuss some
deeper semantics. Let's take a peek at the darkest corners of Rebel's standard library.
struct RefFatPtr {
ptr: RawPtr<u8>,
meta: RawPtr<RefMetadata>,
}
struct RefMetadata {
ref_count: u64,
is_static: bool,
}
fn Ref_get_meta_ptr(ref: RawPtr<RefFatPtr>) -> RawPtr<RefMetadata> {
ref.meta
}
fn Ref_get_ptr(ref: RawPtr<RefFatPtr>) -> RawPtr<u8> {
ref.ptr
}
/// Allocates a new reference counted object, with an initial refcount of 1 and
/// with a data layout of the given `size` and `align`ment.
extern "runtime" fn __rebel_alloc_ref(size: u64, align: u64) -> RefFatPtr;
/// Free a previously allocated reference counted object with the given layout.
extern "runtime" fn __rebel_free_ref(ref: RefFatPtr, size: u64, align: u64);
/// Increments the reference count of the given reference counted object.
/// Returns the new reference count.
///
/// NOTE: Only called when the debug option of use_ffi_for_ref_inc_dec is enabled.
extern "runtime" fn __rebel_inc_ref(ref: RefFatPtr) -> u64;
/// Decrements the reference count of the given reference counted object.
///
/// NOTE: Only called when the debug option of use_ffi_for_ref_inc_dec is enabled.
extern "runtime" fn __rebel_dec_ref(ref: RefFatPtr) -> u64;
Now that's a mouthful . To give you some much needed context, Rebel
has two pointers types:
RawPtr<T>
is a raw pointer: No safety guarantees, be careful,
you're on your own kinda deal. I never added unsafe
to the language because I didn't
need to, but if I ever did, messing around with RawPtr
would've probably required it. So
pointers aside, in Rebel, types are value types, and all types are passed around by
value. So how do we get around that fact whenever we need to safely pass things around by
reference? We use a Ref
, that's how.
The Ref
is Rebel's refcounted "smart" pointer type. The closest thing you could utter in
Rust would be Rc<RefCell<T>>
(remember, we're proudly single threaded), except here we
(i) are not telling LLVM these references are exclusive via noalias
and (ii) take proper
care to guard against nasty situations like iterator invalidation in our safety model. So
the RefCell
is not really there, you just get to mutate aliased Refs
however you want.
And it's liberating.
Now we're ready to take a peek at the internals: What's in a Ref? A Ref is a "fat
pointer", shorthand for "struct that holds two pointers". The first pointer points to some
piece of allocated data, the second pointer points to the ref metadata heap, a location
inside Rebel's runtime where the refcount is stored, as well as some additional metadata.
The only metadata you can see here is that is_static
thing, which is used to prevent
things like interned strings from actually deallocating (remember, one string type, not
twenty, so compromises have to be made).
The piece of magic Ref
s have, is that when copied, they're not copied like the other
types (aka, blitted). Instead, the compiler makes sure to insert refcount increments and
decrements whenever Ref
s are passed around: Either by themselves, or by being a field in
a struct. The whole thing was reasonably performant too. Some optimizations were in place
to ensure you didn't redundantly increment or decrement the refcount too much. LLVM added
some more on top (I checked!), eliminating some bounds checks, because the refcount wasn't
atomic (again, proudly single threaded ) and it could merge
x += 1
with x -= 1
into a no-op. And the worst performance bit was to worry about the branching
that occurred when a ref fell out of scope and we had to check whether refcount was zero
so we could deallocate it. I slept at night thinking that branch was somewhat
well-predicted, though not as well as
others. I may be exaggerating here,
but that branch instruction is probably the reason garbage collectors exist at all.
Let me tell you, figuring out when to increment and decrement those refcounts was hellish for someone like me who had barely scratched the surface of the dragon book. This required bringing out the heavy artillery. But I made it work. It was robust, I wrote a lot of code with it. By the end of it, I'm reasonably convinced there were no more bugs after months had passed with zero Ref-related incidents at Carrot Corp.
Implementing proper dataflow analysis algorithms on top of my own IR was by far the most technologically complex bit of this project. I didn't start this project to build something "impressive" or to satisfy my own ego. This whole thing was started to scratch a very itchy itch, one that was consuming me from within. If I could've handed this over to someone else and told them: There you go, please implement this complicated bit for me because I'm dumb. I would've done so gladly. I just wanted to make games.
And how fittingly! Because things are about to get a bit darker from here on.
In case my grammar choices regarding verb tense and the name of the
blog post didn't give you any clues yet...
Rebel is the language that never was.
Wait, don't leave! I'm no impostor, despite the syndrome. All this code exists, and it is sitting there in my hard drive. I didn't make this up. All the snippets you've seen are real, they compiled, and they ran. It's just that... it is very unlikely that this little language sees the light of day.
Chapter 3
So What Happened Next?
Mental health. What a beautiful concept. Things are about to get personal next and I'm not used to communicating personal thoughts, but if you got this far, you'll figure it out.
Motivation And Lack Thereof
Every side project goes through phases. My employer at the time wouldn't be happy to learn
how much time I put into this when motivation was at its peak. Or maybe they would, I've
always been very lucky with my employers. This was about a year, and maybe
a few months, of intense side-project work. I poured my soul into it. But motivation
doesn't last forever. It was sudden, one day everything crumbled, and since that day, I
haven't touched a line of code for this project.
I'd say focusing too much on the straw that broke the proverbial camel's back would be unfair. That's why I've taken my time before writing this blog post: It has now been a bit over a year since the whole Rebel project was put to a halt. I've taken my time to reflect, consider things, get my thoughts (and my shit) together, before putting any of this into words. But if you insist, I'll dig up the message I wrote at the time, in the heat of the moment, the straw that broke the camel's back, in all its glory. I do not stand by these words today, but these were my words back then:
You can thank the absolutely disgraceful state of wasm and anything that is related to it for killing my last bit of motivation of contributing to the Rust ecosystem. I hope they sort out their ABI issues some day, but it won't be me fighting to make it happen. I'm going to be inflammatory for once and say that the cryptowebshit bureaucrats behind the bytecode alliance can go
redacted, things you really don't wanna read.(sic. as in, I never typed those things out, they were redacted in the original)
I don't even fully remember what this was about, nor did I take the time to dig out the relevant issue thread for the blog post. I had just finished adding the AoT mode to the compiler and I was trying to add webassembly support to the language so people could play my game on the browser. I was well on my way there, actually! Then I hit an issue with the Rust compiler not following the webassembly ABI when passing large structs as function arguments. The issue was a known bug, one that had a solution, but nobody could agree upon it. Things were stuck in RFC PR limbo. One thing led to another and there I was, trying to build my own fork of rustc, but failing, and then the fuse blew up and I was out of replacements. Rebel was dead, right there and then.
The real reasons that made me stop, which in retrospective had not that much to do with web assembly, come in next.
I No Longer Want To Contribute To Rust. The once vibrant, once thriving Rust community had shown some of its dark sides to me. A community, fueled by the perfect combination of newbie enthusiasm and veteran burnout. So many thankless volunteers holding it all together. Then a small few, who shall remain unnamed, reaping the benefits of the hard work. All held together by a layer of fanatics that systematically shun any criticism, valid, thoughtful or otherwise.
I'm being purposefully vague here since this is not about attacking any particular people, forums or projects. Chances are if you think this is about a notable Rust thing, it indeed is about it. But it is not just about that one thing you have in mind, it's about them all really. I was tired of it.
Just the fact the average Rustacean heavily advocates for an Apache2+MIT dual licensing model (sometimes they throw Zlib at it for good measure) and bullies projects who dare to go in another direction, especially if that involves any amount of GPL, should tell you something. If it doesn't... well, it did for me.
As a "contributor" of some sort, I had some notable open source projects I maintained after all. It felt like all the work I was putting into this was mainly for the benefit of people who, as time went, I disagreed more and more with. Disagreements on very fundamental aspects about how software needs to be produced and consumed.
You'd be forgiven for thinking this is a very average case of open source maintainer burnout because it was. My contributions were never that notorious, you probably haven't heard of them, or perhaps passingly, but they were there.
Rust Leadership Is In A Very Sorry State. Whether you are part of the Rust community or not, you've surely heard of all the drama. That juicy drama.
To some, especially anyone watching from the C++ side, all that must've been delightful. I
can honestly see the appeal. No hard feelings. If anything, I'm taking a
lesson here to never grow too attached to a programming language. Your choice of
programming language should not be your only defining personality trait and I really
hope it's not the thing that keeps you going in life. Get a hobby, start with gundam model
kits if you're out of ideas.
Back on the drama. There were several incidents. There was the mod team resigning en bloc, the trademark policy lawyer fiasco... But the one I still resent was the whole RustConf thing. If you're not up to date, you can read Amos' mostly unbiased report of the events.
My relationship with JeanHeyd can be described as "omggg big fan!!1 " vs
"who the fuck are you?". By that I mean, I think they're a really cool individual, one to
whom I have no ties whatsoever. We've never even spoken. And yet, call it empathy if you
must, whatever happened to them was painful enough for me to feel completely depressed
about Rust's future. Enough to want me to cut all my ties with the language.
If you remember, back on the bright and beautiful pastures of chapter 1, I explained how metaprogramming is something I care a lot about. Not surprisingly either, compile-time reflection was at the very top of my wishlist for what the ideal gamedev language must have. They had the competent person, that person was going to make it happen. I was excited, very. Then egos were hurt (on both sides, but only rightfully so on one), and everyone lost because of it.
Now Rust will never have compile-time metaprogramming, and we're stuck in the wrong timeline. The one with the proc macros.
Even if Rebel was not Rust, it was meant to be "Rust's perfect companion" of a language, so whatever happened to the big cousin language affected me. Both my work and my motivation to continue it. And in all fairness, if you look at the dates, this particular event didn't kill my motivation, it fueled it. But looking back, it also planted that seed of dread that ended up destroying what little hope I had left for whoever sits behind the wheel at Rust corp.
Making A Compiler Is Tough. Much More Than I Imagined. But you'd be wrong to think it was all about Rust, their corporate bootlicking and their dramas. Making a compiler is really tough work. I've already touched on some of it, but let me tell you: It was brutal.
Perhaps, if my goal was to make a language, things would've been different. But my goal was not to make a language, it was to release games using the language. And I'm a very pragmatic person at that. I enjoy a good challenge, but if my goal is to ship a game, fixing compiler bugs in the middle of development is not fun but a huge distraction.
As you can imagine, there were many bugs. The language was complex. Targeting LLVM was no easy feat, though I did it, and I learned so much by doing it. If I could go back and do things different, I probably wouldn't. I think my compiler-making fuel is simply exhausted.
Because perhaps, the most important reason, one that deserves one of these big and impactful headers, perhaps, is that...
All I wanted is to make tiny silly games
That's it. I never set out to make a programming language, and I still don't know what got into me. It sure was fun, it made me feel amazing. The high from seeing that string of characters you typed into a file come alive on the screen. The feeling of being the creator, every decision up to you. No RFCs to type, no people to convince... That was quite something.
But at the end of the day, every day spent working on the gamedev language compiler was a day not spent on the game. That's how I saw it, and that's how I still see it.
Making something others want to play is a skill on its own, and it's a very different one to programming. I even dare say, the better of a programmer you are, the worse of a game developer you will become. You need to unlearn a lot of things. In solo game development, the inner game designer leads. The inner artist suggests. And the inner programmer shuts the fuck up.
In that sense, working on a programming language to develop videogames was very incompatible with my philosophy. The amount of cognitive dissonance this was causing over the year and a half or so I spent of this project took a toll on me. I just have this urge to craft experiences others are going to play, I don't know why. I'm also not that good at it, but I sure as hell won't get better at it by messing around with parsers and LLVM.
So, there I was. The language I spent so much time on, dead in the water. No chance of taking up the project again, burnout is not something you can recover from by doing more of what caused it after all. And a half-done game... wait, a game! I was working on a game. Remember the carrots? At first, it was this fun little side-side project, something meant to dogfood the language. And I sure ate lot of dog food. But I was having fun with it: If I had to finish one thing, I wanted it to be the game, not the language. But how do I finish a game without a language?
Bill Gates Enters The Room And Everybody Leaves
Remember that eerie ominous feeling, that chill down your spine? Back in chapter 1? Well this is it it: it's Microsoft. How the fuck did Microsoft end up in this story?
They keep saying, and nobody but them believes it (rightfully so), that Microsoft ❤️ Open
Source. Well, they did one thing. You may not have gotten the memo, because who even cares
about fucking .NET, but they open sourced .NET well over 10 years
ago. Before that,
corporate used to be all about the little Duke, but now there was a new kid in
town.
.NET is a bit of a weird technology, in that it is as technologically impressive as it is widely ignored. I think the bad press its parent company earns it is the main reason for it. I'm not blaming anyone for that. Originally, it was tied to Windows and its ecosystem. That hasn't been true for a while, but nobody cares. It's that Windows thing, made for Windows and Windows only, and who on their right mind would want to wear a suit and tie to work, to work using what's essentially Microsoft Java?
While I was working on Rebel, I heard about the news of dotnet hot reload being a thing. I chose to ignore them, because if true, my whole world was in danger. It's something the brain does to you, you ignore the things that challenge your world views because thinking about them causes pain. It causes what fancy people call cognitive dissonance (that word again!). Back then I was fueled by the motivation that no low level language (low level enough to support the value types and raw pointer fuckery I needed, anyway) cared about hot reloading. Because if I had looked, even just for a bit, there I had it: Microsoft, the bad guys, telling the world that hot reloading is okay to want and possible to achieve. In the meantime, the rustaceans, mocking me for even suggesting the idea and deeming it useless, unsafe and nonsensical.
I spent the previous section enumerating all reasons why I stopped working on Rebel, my
compiler, but the truth is the very same day I wrote that last line of Rebel code and
furiously typed that quote, I asked a friend to share his proof of concept of C# hot
reloading on raylib. I downloaded it, typed dotnet watch
on my Linux terminal, some
circles and lines popped into the screen. I modified the code, saved my file, and right
there and then, the rebellion ended.
So what did I do? I don't like reading documentation without a purpose. I started porting Carrot Survivors to C#, just to evaluate if it'd be a good fit. I am not exaggerating when I say that the full game (and there was quite a bit of it!) took me exactly one week to port. After that week, I was productive with the language, had a working build and was adding new features to the game.
I had never seriously looked into C#, other than an occasional run-in with Unity back when
I was a teenager still convinced that "all programming languages are the same". So I was
both surprised, delighted and terrified to learn about how many of my boxes this thing
ticked. I called C# depressingly good. I still do.
-
Value types: It's all about the
. C# is the only language I am aware of that has a runtime, a garbage collector and at the same time has value types, safe zero-cost references to said value types and all of that powered by a (albeit, simpler than Rust's) borrow checker. It also hands you full control over the memory representation of your structs too, if you need it. I find it baffling people are not writing about that. I think, just like with LLVM's JIT, people don't know? I find it hard to believe they don't care. While people were there, hyping Rust, some engineers at Microsoft were quietly sitting in a corner, making a compiler for a strong contender to hype queen of the low level language prom dance, and instead of focusing on showcasing that they've been marketing it to the Java crowd!? Anyway, my requirements for what I need to go fast? Check. All of them and then some.
-
Type system: C# met every fancy language at the top of the ivory tower,
fought them in a sword duel, and stole their powers. At the same time, being a language marketed to the average suit and tie Java enjoyer, they did it tastefully without taking things too far, and with pragmatism in mind. I have my reservations when it comes to some of the choices they made, especially that half-baked
record
feature, but the language has stood the test of time a lot better than the little Duke. C# doesn't need a Kotlin, C# is its own Kotlin. They added a very tasteful and solid nullability check system, fully eliminating the billion dollar mistake for me. They have bothasync
andcoroutines
: Pick your poison! I've tried both, both worked fine but for gamedev, I prefer the latter. An iterator API that makes Rich Hickey envious, except they market it as an SQL thing and that confuses everybody (even them). And the list goes on. I think it sits at a very good abstraction level when it comes to get shit done, without being complex enough to invite serious type fuckery. The only thing I dislike is the lack of discriminated unions. They're working on it, but slowly. -
It has them squiggles: The C# LSP is good. They have very good tooling. Good debuggers, good profilers... There is an opinionated code formatter I had to fork because it had the wrong opinions that works very well. The nasty side is some of these tools, like the profiler GUIs and some of its advanced features, are only available on Windows. The LSP is also kind of new and they kind of killed the open source one that was already there, only to replace it by their almost-open-source one. The guys at Emacs have not gotten the memo about the new LSP yet, those at Vim have gotten it but are still reacting to it, and the IDE ecosystem is centered around Visual Studio, VSCode and Jetbrains Rider. For my adventures, I have been using VSCode(ium) and while the most avid sharpies claim it's the worst of the bunch, being used to whatever Rust gave me, I have zero complaints. The tooling works. Just thinking of having to build all of this by myself is stressful.
-
Metaprogramming: Oh gosh, these guys have finished Let Over Lambda.
You get reflection at compile time and obviously at run-time too. Custom attributes are there, readable from both of the reflection systems. The compile-time reflection (source generators) allows for code generation, even though it's really boilerplate-y. The missing thing is Lisp-style macros. I won't lie, I miss them... But they were last on my list. These people have done their homework: Roslyn is the vision of "compiler as a library" only the most enlightened lispers could ever dream of. And it was made by Microsoft, and it is being marketed to a bunch of suit and tie CRUD Java-adjacent programmers. I just can't, even.
Hey, let me just break the bullet list for a moment to hammer in this point, because it is important. This quote right here: Read it. Read it fully:
Compilers build a detailed model of application code as they validate the syntax and semantics of that code. They use this model to build the executable output from the source code. The .NET Compiler Platform SDK provides access to this model. Increasingly, we rely on integrated development environment (IDE) features such as IntelliSense, refactoring, intelligent rename, "Find all references," and "Go to definition" to increase our productivity. We rely on code analysis tools to improve our code quality, and code generators to aid in application construction. As these tools get smarter, they need access to more and more of the model that only compilers create as they process application code. This is the core mission of the Roslyn APIs: opening up the opaque boxes and allowing tools and end users to share in the wealth of information compilers have about our code. Instead of being opaque source-code-in and object-code-out translators, through Roslyn, compilers become platforms: APIs that you can use for code-related tasks in your tools and applications.
Holy crap. Oh how I fucking wish this had been written by someone at the Rust core team. But no, this is a verbatim quote from the Microsoft documentation. In the meantime, the people behind Rust can't even conceive the idea of providing a stable API to access the AST so that people don't have to parse the syntax tree twice and kill compile times. And I'm just taking a stab at the crab because that's the basket where I used to put my eggs. Pick any hip and trendy language you're excited about and read this quote again. It will make you feel as bad as it made me feel. Anyway, back to the bullet points...
-
Hot reloading: The hot reloading system they have is baked into the runtime and requires no configuration. It is also everything I was asking for. Simply (and note I don't use this adjective lightly, hit that Ctrl-F) launch your project with
dotnet watch
, and your code will hot reload. Across libraries, across functions, across everything. There is no extra setup to be done. You don't have to care about ABIs or Casey's DLL trick. You don't have to build a clean separation between engine code that doesn't reload and game code that does. Everything is the same language and everything reloads. It's hot reloading turbo-equipped turtles all the way down. Things could be better of course: When the hot reloading system can't apply a patch, it will tell you why and then stop reloading until you restart the application. This happens when altering a struct's layout (but not a class!), but also more annoyingly when doing anything that involves changing the set of captured variables by a closure. I feel things on that last bit could be improved, but overall I am completely satisfied and fully productive with their hot reloading system. -
And then some, but not all: Compilation speed is quite good, no complaints especially during hot reloading: Takes about half a second even with a large codebase. Platform support is there, with all major desktop and mobile OSs supported, though I haven't looked into consoles. Operator overloading is well supported. SIMD is very much part of the standard library, with accelerated vector and matrix types.
The rest of the things in that first chapter though... well, they're missing. There is
still hope, C# isn't the perfect gamedev language! Let's see, for it to be our ideal
language we're missing *checks notes*
-
Not everything is an expression: C# is an old language and they can't do it. This is the one idea they can't steal from other languages because at this point they can't change their grammar. So ifs are statements, not expressions, and it makes me a bit sad. It's far from a deal breaker, but I wish it could be improved.
-
Web is not quite there yet: C# compiles to webassembly, via Blazor, so web is possible. If you're in Unity, but not in Godot (come on!!!!), it works seamlessly, but I hate Unity's guts. I ride alone for the most part, and making my custom engine work on the web feels a bit daunting. There's no good documentation for this, and while it seems possible, I haven't seen many people do it, so as things stand web is very much on the cons list.
-
Performant debug builds: Now, debug builds are quite performant by default. But it would be nice if I had some control over optimization levels at a granular level. Especially when it comes to hot reloading. Right now it seems that when you enable optimizations, you can run
dotnet watch
but hot reloading silently stops working. Not cool. -
No exhaustivity checks: No discriminated unions, no sealed interfaces (well, they have them, they just don't check exhaustivity). They're working on it, but there also hasn't been any noticeable progress in the last year. If keeping up with Rust developments taught me anything is I should not expect this to land anytime soon nor am I counting on it. It seems the complexity is about adding this to the runtime and code generator in a way that is performant and plays nice with value types. I appreciate the commitment though: If it doesn't work well with value types, I might as well not have it. If you don't care about performance, you can easily roll your own thanks to the excellent metaprogramming.
And... that concludes the list. It's fucking depressing if you ask me, that's what it is. Microsoft, of all companies, made the almost-perfect gamedev language while nobody was looking, and other than the Unity crowd we like to make fun of, nobody noticed... The whole C-replacement-wannabe tribe, with their heads up their butts focused on all the wrong problems, and Microsoft there, just catering to my interests like they had built the thing purposefully for me.
You could point out many minor papercuts you find annoying about C#. I could too. Having to write docstring comments in XML in 2025 is laughable, some operator precedences are wacky... I hear you. But I try to see the bigger picture: For me, C# ticked all the big boxes, Rebel did at the cost of my own sanity, and Rust ticked almost none. You shouldn't care so much about the small things, it's slowing you down. After all this, I have learned to excuse a little jank in my language choices as long as the important things I care about are there.
We should take one last tangent and discuss Unity, since I brought it up: Many of the
things I've come to appreciate from C#, such as their excellent SIMD accelerated types in
the standard library, most of the cool parts of their ref
borrow checker and the juicy
nullability checks that protect my foot from the null gun... They don't have any of that.
Unity is stuck with an ancient version of dotnet, and dotnet (and C# with it) have come
a long way in the past five years. Instead, what the Unity folks did was craft their own
parallel
universe, for
better or worse. At this point, what Unity does, might as well be a hard fork, and the C#
I'm talking about and what Unity users have are not the same language. Plus Unity sucks.
Anyway, I hope you now understand when I said it's depressingly good I meant it.
If I Knew Anything About Marketing, The CTA Would Go Here
So... where does that leave us? Well, it leaves me with a released game on steam. Yes!
I finally finished Carrot Survivors. It is now written in C#, with Monogame. Though I've
decided to part ways with Monogame since (different story, this post was long enough). Go
play it! There's a free demo, and people have told me it's fun.
But please don't think of this post as an advertisement for my game, because it would be a
very bad one. I enjoy making games and sharing them with others. This was my first release
and I'm hoping to do so much more now that I'm finally focusing on what matters to me. I
just think after reading all these words, you may want to learn a bit about who is behind
them and what truly motivates me, you know. People don't wonder that often enough with
blog posts.
Though I must admit all this leaves us with a bit of a bittersweet ending. The rebellion is dead, the bad Microsoft empire won, and our little language will never see the light of day. :/
I'd be genuinely happy if someone took all these ideas I put out there and made something out of them. I'd like to think my set of requirements and design ideas are interesting to at least maybe one other human out there? But I also don't have high hopes. Plus, the situation is dreadful. Unless you take your principles very, very seriously, you're probably using at least one of Microsoft lang (or this one, or this one), Microsoft squiggles, Microsoft Git, Microsoft Slop or Microsoft Emacs. I didn't even have to bring Office into the list, you should at the very least consider LibreOffice, I'm begging.
I truly hope someone does something before all the little rebellions out there are embraced, extended and eventually extinguished.
After all this journey, I don't know what to tell you. I started typing this post thinking I'd open source the language, the community would pick it up and it would live on: Further than I could ever take it. A grand finale. But after typing all this, I've realized things don't work that way. I'm tired of it, and I mean it when I say I no longer want to contribute to open source. Especially when it comes to anything Rust. As sad as it may be, Rebel stays on my hard drive, but the ideas live on. And the contradiction I now have to live with is hating open source because the people who made the language I love killed it. There is some burnout in all of this that is still healing.
What drove me to write all these words was looking for closure for this little project, and I found it. I'm thankfully in a better spot now. For what it's worth, I never was in a bad spot throughout any of this, though some of these feelings can get worse if untreated. To me, saying goodbye to Rebel and finally shipping my first game on Steam was therapeutic. 😌
And you, you've reached this far. Have some music, it soothes the soul. I promise it's not the broken Green Hill Zone theme this time. This one's actually beautiful.
Now, if you'll excuse me, I have more game to dev.