AlloyLang

A statically-typed systems language, designed to replace C++ in my game engine, with an LLVM backend.

Period
Jan 2024 – Feb 2025
Role
With a collaborator
Status
archived
my commits / total
194 / 321
lexer, parser, modules
4,025 LOC · 99% mine
JIT-executing tests
20

My project and my design, built with my father. I wrote the language design, the tokenizer, the parser, the module system and the semantic analyzer; he wrote most of the code generator and the editor tooling.

Open source C++20LLVMC#TypeScript

Highlights

  • Designed the language and specified it before building it: an EBNF grammar, and a written type-system spec focused on reducing common bugs in game development.
  • Hand-written tokenizer and recursive-descent parser, ~3,700 lines, over a bump-allocated AST.
  • No build file and no manifest: every .alloy file under the entry point is a module named by its path, so the directory tree is the module graph, with pub/exp visibility and cross-module symbol resolution.
  • Editor tooling calls the real compiler rather than approximating it; a C++/CLI shim exposes the native parser to .NET, and identifiers are classified by asking the AST what they are.
  • Twenty tests, each compiling a real .alloy program in-process, JIT-executing it and asserting on the value main returns.
  • Compiles to LLVM IR through the ORC JIT; generics monomorphize on demand at the point of use; tagged-union enums carry typed payloads with capture in if and switch.

Abandoned mid-refactor: type checking lived inside the code generator, and the semantic-analysis phase meant to fix that was never finished. These lessons led me to the staged approach I've adopted for future versions of this compiler.

To run the project, use branch Test. IR is the unfinished refactor and does not compile; Test is the last working state, from December 2024.

The problem

This is a working statically typed language, not a toy: it compiles to LLVM IR, runs through a JIT, and is covered by tests that compile and execute real programs and assert on what they return. It also ships the tooling around it: the editor integration runs the actual compiler front end on every keystroke rather than a second approximate parser. It was designed on paper before any of it was built, and it was abandoned for a specific reason, which the last two sections set out.

Build times in my game engine were getting out of hand, and C++ was carrying a great deal of weight I never used. I wanted the parts of C++ I actually like, proper compile-time reflection, and none of the rest of it. Alloy was meant from the start to replace C++ in my engine projects, so it only has features I wished C++ had or that had worked well for me in C++.

The original scope was narrower than that. I wanted to solve one problem: the ECS in my Vulkan engine was several hundred lines of C++20 template machinery doing work that a language with real compile-time evaluation could have expressed in a few readable lines. I widened the scope once I realized that a small language with enough compile-time power would solve the ECS problem and let me write everything else in it as well.

There was a throwaway attempt before this one, AlloyScript, which existed for a few days in 2023. It was not really a language project; it was me working out how LLVM fits together before starting the real thing.

Who built what

My father joined this project and wrote a large part of the back end. It was my project and my design and the decisions were discussed between us, but the implementation split is worth stating plainly. Mine: the language design, the tokenizer, the parser, the module and symbol system, and the semantic analyzer I started at the end. His: most of the code generator, and the editor tooling, meaning the LSP server, the VS Code extension and the Visual Studio integration. 194 of 321 commits are mine, and at line level the front end is 99% mine while the code generator is about a quarter mine.

What the language does

Alloy is statically typed, compiles to LLVM IR and executes through the ORC JIT. It has structs, fixed arrays, references, heap allocation with new and explicit ownership transfer with move. Pointers auto-dereference, so there is no * or -> anywhere in user code. Enums are tagged unions carrying typed payloads, generic over the payload type, with capture in both if and switch. Generics work by monomorphization, instantiated on demand at the point of use rather than in a separate pass.

The module system has no build file and no manifest. Every .alloy file under the entry point is a module named by its path, so the directory tree is the module graph, with pub and exp visibility and proper "not found" versus "not accessible" errors on symbol resolution. Compilation starts at main and pulls in only what is reachable from there.

The type system I designed

Alloy is nominal by design. type Health = u32 and type Stamina = u32 are different types and are not interchangeable, and a generic function monomorphizes separately for each even though both lower to i32. That duplication is the price, and it is deliberate. The reason is the compile-time reflection the whole language was for: if the two are the same type by the time you can inspect them, static reflection cannot tell them apart either. I wrote the decay and conversion rules up as a spec in the repository before implementing anything.

The reflection itself came out of a specific frustration rather than an interest in language design for its own sake. My ECS in C++20 and the native scripting event system in the engine before it were both built out of template machinery whose only job was to ask questions about types at compile time. Which components does this system touch. Does this script define a handler for this event. Those are one-line questions, and answering them in C++ took me hundreds of lines of things that are hard to read and harder to change. Alloy exists because I wanted to ask them directly.

The front end

Hand-written tokenizer and recursive-descent parser, about 3,700 lines together, building AST nodes out of a bump allocator. Comments are kept as real tokens rather than discarded, because the editor tooling needs them.

The idea behind the tooling was to not write a second, approximate parser for the editor, and to expose the real one instead. A C++/CLI shim makes the native tokenizer and parser callable from .NET in-process, so the C# language server runs the actual compiler front end on every keystroke. I wrote the classification logic, which takes an identifier and asks the parsed AST what it is. Vector3 and EnumType are lexically identical and only the AST knows that one is a struct and the other an enum. My father built the transport and the editor integrations around it.

The honest limits: local variables and parameters are never classified, and it only ever looks at one file, so imported symbols get no color.

Testing by running the programs

There are 20 tests, and each one compiles a real .alloy program in-process, JIT-executes it and asserts on the value main returns. A test that passes is proof the language worked end to end that day. The largest is a 350-line multi-file program written in Alloy: a generic dynamic array with capacity doubling, vector and matrix types, a shared-pointer prototype and the skeleton of a sparse-set ECS. It is a sketch rather than a working ECS, and the two set methods are stubs. One test hands an Alloy function to the Win32 CreateThread as a raw function pointer and joins on it, so the operating system runs JIT-compiled Alloy on a real thread. My father wrote that one.

Why it stopped

Type checking was never a phase. It was a scatter of comparisons inside the IR builder, and the only check on a binary operator compared the two lowered LLVM types. Health and Stamina both lower to i32, so the check passes. The central rule of the type system I had designed could not be enforced at the only place the compiler ever looked at types. The conversion rules were eventually implemented properly, walking the AST types, and they were never called from the code generator; the commit that adds them says "TODO: test in codegen".

Everything else has the same root. Signedness is stored on the type and never read, so unsigned comparisons emit signed instructions. && and || are bitwise and evaluate both sides. Struct constructors do not require every field, and my own test library contains a struct with one field assigned twice and another never assigned, which nothing diagnoses.

In December 2024 I started the middle end that should have been there from the beginning: a separate resolved IR, its own symbol table, and mangling moved out of code generation into a phase that could finally tell Health from Stamina. It got about 15% of the way. The mangling and call resolution are written, and expression and definition resolution are stubs returning null. That work is all mine, and it is where the project stops.

Other defects in the same codebase

Assertions compile to nothing in release builds, so several unhandled cases silently produce wrong values rather than failing loudly. The AST allocator is a fixed-size bump allocator sized by a magic number that cannot grow. The parser stops at the first error with no recovery.