Skip to content

Helix - the core compiler

helix is a TypeScript compiler for Minecraft datapacks. You author a pack in fluent TS; it compiles AST → IR → .mcfunction files + tag JSON, targeting a specific Minecraft VersionProfile so the same source emits correct, different output across versions (folder names, pack format, command grammar, registry membership).

Its defining stance is that it is un-opinionated: it provides mechanism - typed values, commands, codegen for a version - never policy. Anything that picks a convention belongs in spool or twine instead.

This page walks the authoring surface - the handful of classes you use to write a pack. Every example below is compiled by the docs build itself: the Compiled output panels are the real files helix emits for the source shown, so they can't drift. For the full symbol list see the API reference (which leads with this same authoring surface, grouped by task).

The Datapack

Everything hangs off a Datapack instance, constructed with a name and a version profile. dp.createFunction(name) returns a FunctionRef; .build(ctx => …) fills it in, where ctx is a FunctionContext - every ctx.<command>() (vanilla commands and sugar like say, tellraw, if, give, score ops) is a typed method on it.

ts
import { Datapack, v26_2 } from "helix";

const dp = new Datapack("mypack", v26_2);

const load = dp.createFunction("load");
load.build((ctx) => {
  ctx.say("loaded");
});

▶ Open in playground

Compiled output - the real files helix emits for the code above:

txt
say loaded
json
{
  "values": [
    "mypack:load"
  ]
}

Naming the function load (or tick) auto-registers it in the matching minecraft:load / minecraft:tick function tag - which is why the tag JSON appears alongside the .mcfunction. There's no fs or path-building anywhere in feature code; dp.writeDatapack(dir) is the one call that turns the built pack into files on disk (buildDatapack(dp) is the in-memory form used above).

Targeting: Selector

Selector builds @a/@e/@p/@s/@r with a fluent, version-aware filter chain - .tag, .limit, .distance, .gamemode, .nbt, .score, and more. You never hand-write @a[tag=…,limit=1].

ts
import { Datapack, v26_2, Selector, text, Color } from "helix";

const dp = new Datapack("mypack", v26_2);

const greet = dp.createFunction("greet");
greet.build((ctx) => {
  const admins = Selector.allPlayers().tag("admin").limit(1);
  ctx.tellraw(admins, [
    text("Welcome back, ").color(Color.GRAY),
    text("operator").color(Color.AQUA),
  ]);
});

▶ Open in playground

Compiled output - the real files helix emits for the code above:

txt
tellraw @a[tag=admin,limit=1] [{"text":"Welcome back, ","color":"gray"},{"text":"operator","color":"aqua"}]

Typed values, not strings

Domain concepts - Pos, Block, Item, Nbt, Id - are built with their own classes and render version-aware at codegen. Handlers never hand-build command fragments; if a concept isn't expressible yet, the typed API gets extended first.

ts
import { Datapack, v26_2, Pos, Block } from "helix";

const dp = new Datapack("mypack", v26_2);

const platform = dp.createFunction("platform");
platform.build((ctx) => {
  ctx.fill(Pos.rel(-2, 0, -2), Pos.rel(2, 0, 2), Block.STONE);
  ctx.setblock(Pos.here().offset(0, 1, 0), Block.AIR);
});

▶ Open in playground

Compiled output - the real files helix emits for the code above:

txt
fill ~-2 ~ ~-2 ~2 ~ ~2 minecraft:stone
setblock ~ ~1 ~ minecraft:air

Pos.rel renders the ~ form, Pos.local renders ^, and Block.STONE is a typed member off the version's block registry rather than a bare string.

Scores & control flow

An Objective comes from dp.objective(name, kind?); .score(target) gives a Score cell you can .set, .add, .remove, .copy. Comparison methods (.equal, .greaterThan, .lessThan) return conditions you feed straight to ctx.if, whose body compiles to a child function.

ts
import { Datapack, v26_2, ScoreTarget } from "helix";

const dp = new Datapack("mypack", v26_2);
const score = dp.objective("score");

const reward = dp.createFunction("reward");
reward.build((ctx) => {
  const points = score.score(ScoreTarget("total"));
  ctx.if(points.greaterThan(9), (ctx) => {
    ctx.say("high score!");
    points.set(0, ctx);
  });
});

▶ Open in playground

Compiled output - the real files helix emits for the code above:

txt
say high score!
scoreboard players set total score 0
txt
execute if score total score matches 9.. run function mypack:zzz/reward/if_0

Notice the emitted reward.mcfunction calls into a generated child function - the if body - instead of inlining. That's the AST → IR lowering doing its job. For runtime math beyond integers, Fixed (scale-tracked fixed-point) and ScoreVec3 build on the same Score primitive.

Version profiles

A version profile carries everything version-specific: pack format, folder conventions, the command grammar, and registry membership. It reaches command handlers only through ctx.datapack.version at call time - handlers are stateless singletons that never bake in a version. Swap v26_2 for v1_20_4 or v1_20_1 and the same source emits different folder names (function/ vs functions/) and pack format.

In-memory codegen, cost & validation

buildDatapack(dp) runs the same codegen as dp.writeDatapack(path) but returns a Map<path, contents> instead of writing to disk. Two features build on it - both read the rendered output, not the AST:

  • Cost reports - dp.report() / dp.printReport() walk the call graph rooted at the tick tag for worst-case commands/tick and unbounded @e scans.
  • JSON validation - validateDatapack(dp) checks every emitted JSON resource against the vanilla schema for the pack's target version, via Spyglass's mcdoc runtime. Those packages are optional dependencies loaded lazily; importing validateDatapack doesn't pull them in, only calling it does.

Resource packs

Alongside the datapack, helix can emit an optional resource pack: dp.writeResourcePack(path) writes an assets/ tree from Model, BlockState, and item-definition builders - a separate output, with its own resource-format pack.mcmeta, not folded into writeDatapack. Attach a model to an item with Item.X.model(dp.model(…)) → the item_model component, never a magic custom_model_data number.

Where to go next

  • The API reference - the authoring surface grouped by task, plus the full generated listing of every export.

Released under the MIT License · Credits