Yuzhe's Blog

yuzhes

RedScript: A Compiler Targeting Minecraft Java Edition

RedScript: A Compiler Targeting Minecraft Java Edition

GitHub: bkmashiro/redscript

Minecraft Java Edition has a surprisingly capable scripting layer: mcfunction datapacks, scoreboards, NBT storage, the execute command chain. People have built working CPUs, ray tracers, and sorting algorithms inside the game. But writing this code is painful — raw .mcfunction files with no variables, no proper loops, no abstraction.

So I built a compiler.

What RedScript Looks Like

// Kill zombies within 10 blocks, then reward the player
@tick(rate=20)
fn check_zombies() {
    foreach (z in @e[type=zombie, distance=..10]) {
        kill(z);
    }
}

@on_trigger("claim_reward")
fn handle_claim() {
    give(@s, "minecraft:diamond", 1);
    title(@s, "Zombie Slayer!");
}

This compiles to valid mcfunction files that you drop into a Minecraft datapack. The @tick(rate=20) runs the function once per second (20Hz ÷ 20 = 1Hz). The foreach loop becomes an execute as @e[...] run function call. The @on_trigger handler sets up a scoreboard trigger objective so players can activate it with /trigger claim_reward.

The Interesting Design Decisions

Entity Selectors as First-Class Types

In vanilla mcfunction, entity selectors are just strings embedded in commands:

execute as @e[type=zombie,distance=..5] run kill @s

In RedScript, they’re a proper type with a dedicated AST node:

interface EntitySelector {
  kind: '@a' | '@e' | '@s' | '@p' | '@r' | '@n'
  filters?: {
    type?: string
    distance?: RangeExpr   // ..5, 1.., 1..10
    tag?: string[]
    notTag?: string[]      // tag=!excluded
    scores?: Record<string, RangeExpr>
    limit?: number
    sort?: 'nearest' | 'furthest' | 'random' | 'arbitrary'
    nbt?: string
  }
}

Range literals (..5, 1.., 1..10) are their own token kind in the lexer. The parser understands @e[tag=boss, tag=!excluded, distance=..10] including the ! negation prefix. This means type errors, selector validation, and future IDE completion all become possible.

foreach — Extracting Bodies Into Sub-Functions

Minecraft’s execute command can only run a single command:

execute as @e[type=zombie] run <ONE_COMMAND>

For multi-line foreach bodies, you need to extract the body into a separate .mcfunction file and call it:

execute as @e[type=zombie] run function mypack:check_zombies/foreach_0

RedScript does this automatically. When the lowering pass encounters a foreach statement, it:

  1. Creates a new IRFunction named parent_fn/foreach_N
  2. Lowers the body into that function
  3. Emits a raw execute as <selector> run function ns:... instruction at the call site

The codegen then writes the sub-function as a separate .mcfunction file. The programmer just writes foreach and gets correct, multi-command loop bodies for free.

The IR (Three-Address Code)

I chose TAC (three-address code) instead of SSA. MC scoreboards have no register allocation problem — you can have unlimited “registers” as fake player scores in an objective. SSA’s main benefit doesn’t apply, so the simpler TAC is better.

Variables map to scoreboard fake players:

let x: int = 42;
→ scoreboard players set $x rs 42

x = x + y;
→ scoreboard players operation $t0 rs = $x rs
  scoreboard players operation $t0 rs += $y rs
  scoreboard players operation $x rs = $t0 rs

The IR has explicit basic blocks and jump instructions that the codegen turns into separate mcfunction files (MC has no goto, so each basic block becomes a function that calls the next).

@tick(rate=N) — Software Timer

@tick registers a function in the minecraft:tick function tag, running it every game tick (20Hz). @tick(rate=N) is more interesting — MC has no native timer, so the compiler generates a counter:

# __tick_check.mcfunction (runs every tick)
scoreboard players add $__tick_slow_fn rs 1
execute if score $__tick_slow_fn rs matches 20.. run function ns:slow_fn
execute if score $__tick_slow_fn rs matches 20.. run scoreboard players set $__tick_slow_fn rs 0

So @tick(rate=20) fires at 1Hz, @tick(rate=100) at 0.2Hz (every 5 seconds), etc.

Builtins — Compile or Pass Through?

There are two kinds of “function calls” in RedScript:

Compiled functions (user-defined): go through the full IR pipeline — lowered to basic blocks, optimized, then codegenned.

Builtin commands (say, kill, give, effect, summon, etc.): these are macros that directly emit a known MC command. They don’t go through IR optimization because there’s nothing to optimize — they’re already single commands.

const BUILTINS = {
  say:    ([msg]) => `say ${msg}`,
  kill:   ([sel]) => `kill ${sel ?? '@s'}`,
  give:   ([sel, item, count]) => `give ${sel} ${item} ${count ?? 1}`,
  effect: ([sel, eff, dur, amp]) => `effect give ${sel} ${eff} ${dur} ${amp}`,
  // ...
}

There’s also raw(cmd) — a raw escape hatch that passes a string directly to the output .mcfunction without any processing. For when you need execute as @e[nbt={SelectedItem:{id:"minecraft:stick"}}] and the compiler’s selector parser can’t quite handle it yet.

/trigger — Player Input Without Operator Permissions

This is the most interesting MC mechanic for interactive datapacks. Normally, players can’t modify their own scoreboard scores. But trigger-criterion objectives are special: the server can enable them for specific players, and those players can then run /trigger <objective> to increment their own score (then it auto-disables until re-enabled).

This is the only safe channel for player→datapack communication without granting operator permissions.

@on_trigger("open_shop")
fn handle_shop() {
    give(@s, "minecraft:bread", 3);
    tell(@s, "Enjoy your bread!");
}

The compiler generates:

Players just run /trigger open_shop and the handler fires. Clean.

The Pipeline

Source (.rs)
  ↓ Lexer
Token stream
  ↓ Parser  
AST (Program, FnDecl, Stmt, Expr)
  ↓ Lowering
IR (IRModule, IRFunction, IRBlock, IRInstr)
  ↓ Optimizer
Optimized IR (constant folding, DCE, copy propagation)
  ↓ Codegen
datapack/ directory
  ├── pack.mcmeta
  └── data/
      └── <namespace>/
          └── function/
              ├── main.mcfunction
              ├── main/foreach_0.mcfunction
              └── ...

164 tests, 6 suites. All green.

What’s Next

That last one is basically OOP inside Minecraft. Each armor stand is an instance; its scores are fields; foreach (@e[tag=turret]) iterates over all instances. It’s cursed and beautiful.