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:
- Creates a new
IRFunctionnamedparent_fn/foreach_N - Lowers the body into that function
- 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:
load.mcfunction:scoreboard objectives add open_shop trigger+scoreboard players enable @a open_shop- A tick-check:
execute as @a[scores={open_shop=1..}] run function ns:__trigger_open_shop_dispatch - A dispatch function: calls handler, resets score to 0, re-enables for that player
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
- CLI:
redscript compile src/main.rs -o dist/mypack/ - entity.tag/untag/has_tag: entity state machines via
/tag - random():
execute store result score $x rs run random value 1 6(1.21+) - Command block output (
--target cmdblock): generate.nbtstructure files with Impulse/Chain/Repeat blocks physically laid out in 3D space - World objects: invisible armor stands as first-class “instances” —
let turret = spawn_object(x, y, z); turret.health = 100;
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.