Yuzhe's Blog

yuzhes

RedScript Compiler Architecture Explained

The core problem for RedScript is not “how to parse a language.” It is “how to fit high-level semantics into a target platform with almost no control-flow abstraction.” Minecraft Datapack ends as a collection of .mcfunction files, while concepts like function calls, variables, enums, and pattern matching all have to be broken down at compile time into scoreboard commands, storage, and execute chains.

So RedScript’s compiler does not stop at AST and emit directly. It follows a fairly classic pipeline, trimmed for the MC target: Parser → HIR → MIR → LIR → .mcfunction.


Parser: get a syntactically correct, structured program first

The parser turns token streams into AST. This stage handles syntax concerns: precedence, block structure, function declarations, decorators, match, for-each, enum constructors, and so on. This stage does not decide where a variable should live in a scoreboard, and it does not decide whether a match ends up as an if-chain or a jump table.

Parser output should preserve source intent as much as possible. For example, match value { Some(x) => ..., None => ... } remains a pattern-branch construct in AST instead of being lowered early into temporary variables and conditional jumps. That early lowering would erase structural information needed by later analyses.


HIR: the layer where semantic checks are the most comfortable

You can think of HIR as “typed, pre-desugared IR.” By this stage, name resolution, scope binding, and type checking are largely complete, and nodes are no longer pure syntax; they carry explicit semantics.

For example, for item in players in HIR is no longer generic for; it becomes ForEach(binding=item, iterable=players, body=...). enum payloads also have a concrete shape here, e.g. Option<int> is represented as “discriminant + integer payload,” not scattered syntax nodes.

This matters because advanced language features can be proven valid at the semantic layer first, before subsequent lowering. Otherwise you might only discover an invalid pattern binding during .mcfunction generation, when the cost is already much higher.


MIR: break high-level constructs into executable control flow

MIR is where RedScript genuinely “starts compiling.” It still tracks variables and basic blocks, but control flow is explicit and many high-level constructs are expanded there.

match is the clearest example. Given:

match state {
    Idle => idle_tick()
    Walking => walk_tick()
    Combat(target) => attack(target)
}

MIR first reads the discriminant value, then emits a sequence of conditional jump blocks. Payload-less branches only compare the tag; payload branches compare the tag and additionally load payload from a temp slot into a local binding. In other words, match is effectively lowered to “discriminate + bind + jump.”

for-each also becomes concrete at this point. Array iteration becomes index variable, length read, and loop blocks; entity selector iteration gets marked as a special execution context for downstream emission as execute as ... run function .... This step is crucial because Minecraft has no multi-statement execute body; loops are usually extracted into sub-functions.


enum payload: why explicit layout is necessary

enum payload in RedScript is fundamentally an algebraic data type, but Minecraft has no native tagged union. The compiler typically splits it into two parts:

For example, Option<int> can be compiled to tag=0/1 plus payload0. Some(42) sets the tag to 1 and puts 42 in payload0; None sets tag=0. Later, match compares the tag first and when it hits Some(x) reads payload0 into local variable x.

This explicit layout may feel low-level, but it lets the compiler consistently move data between MIR and LIR without guessing what this enum means at codegen time.


LIR: the final layer close to mcfunction

At LIR, the goal is no longer “express semantics”; it is “emit commands.” Variables map to concrete scoreboard players, temporary values map to fixed slots, and control flow edges become function calls or conditional execute chains.

Common instruction shapes at this stage are already very close to final output:

The advantage of LIR is isolating platform constraints. Parser/HIR/MIR keep a language perspective, while LIR handles Minecraft-specific realities, such as no native goto, no stack, no registers, high call overhead, and conditional execution implemented through execute-chain composition.


.mcfunction generation: the file tree is the CFG

The final step is turning LIR into a datapack file tree. A key idea here is that in Minecraft, files themselves are control-flow nodes. A basic block usually maps to one .mcfunction file, and block jumps become function namespace:path/to/block_x.

So RedScript does not emit one big script. It splits the CFG into many small functions. This increases file count, but it aligns with Minecraft execution and makes block-level optimization, child-function reuse, and debugging easier.


Closing

The value of this pipeline is not pretending to be a classic compiler by name; it is that each layer has a clear responsibility: Parser keeps structure, HIR carries semantics, MIR expands control flow, LIR targets command constraints, and the final stage maps everything to .mcfunction. Features like match, for-each, and enum payload can be made stable only through this layering.

Directly lowering from AST to .mcfunction can produce a running prototype, but if you want language evolution, this IR layering is nearly unavoidable.