Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Mods with j2 (.j2)

A .j3s script is compiled by j3c, so a shipped game can’t take new ones. j2 is for people without the source: players, modders, designers. A .j2 file is plain text the game reads, compiles, and runs while it plays.

j2 looks like J3 (let, fn, { }, backtick strings, match, use, pub) and behaves like Lua: values carry their types, and there are tables, closures, and a garbage collector. The whole thing (lexer, parser, compiler, VM, heap) is J3 code in j3.j2, with no window or C library, so it works in a console program and on every target.

I wantGo to
The smallest hostj3c doc recipes j2
The host API and its rulesj3c doc j2, j3c doc j3.j2 --short
The language page to give moddersj3c doc j2 lang
A whole game made of scriptsexamples/j2_script (reload on save, one script’s error disables only that script, SCRIPTING.md for modders)

The language

// a comment
let hp = 10;                       // a variable
const MAX = 3;                     // can't change
hp += 1;

let name = "slime";
print(`{name} has {hp} hp`);       // backtick strings fill in {expressions}
print("plain " + name);            // + joins strings
let art = r"
 |\ /  o
";                                 // raw string: no escapes, may span lines

let p = { x: 1, y: 2 };            // a table: names -> values
p.z = 3;                           // add a field
p.z = nil;                         // remove it
print(p.w);                        // a missing field is nil, not an error

let xs = [10, 20, 30];             // an array, first element xs[0]
xs.push(40);                       // dot call: push(xs, 40)

for i in 0..3 { }                  // 0, 1, 2
for i, v in xs { }                 // index and value
for k, v in p { }                  // fields, in the order they were added

match n {
    1 { }
    "two" { }
    _ { }
}

fn add(a, b) { return a + b; }     // without `return` it gives nil
let counter = fn() {
    let n = 0;
    return fn() { n += 1; return n; };   // closures capture
};

pub fn onTick(dt) { }              // `pub` names are what the game sees
use base;                          // another script's pub names: base.chase(e, dt)

Things that differ from J3:

  • Truthiness: only nil and false are false. 0 and "" are true.
  • Numbers: ints and floats. 7 / 2 is 3; 7.0 / 2 is 3.5. int(x) and float(x) convert.
  • this: obj.f(a) passes obj as this only when f’s first parameter is named this. That’s how methods are written.
  • Dot calls: when x has no field f, x.f(a) is f(x, a), like UFCS in J3.
  • Typos are caught at load: an unknown name (spwan(...)) is an error with its line as soon as the script loads. A missing field is just nil.
  • Errors: error("msg") stops the script. A loop that never ends stops too, after a step limit, so a mod can’t freeze the game.

Built-ins: print len type str int float error, arrays push pop insert remove pick map filter find sort join keys has, text upper lower trim split contains slice, rand(lo, hi), and a math table (pi sqrt sin cos tan atan2 abs floor ceil min max clamp lerp random). The host chooses which of these exist and can replace any of them. j3c doc j2 lang has the full table.

Hosting scripts

From j3c doc recipes j2: make a VM, bind natives, load a file, call its functions.

use j3.j2;
use j3.j2.{ Args, Limits, Value, Vm };   // J3 can't write `j2.Vm` as a type
use j3.memory.Arena;
use j3.result.Result;

// `spawn(kind, x, y)`. The getters raise a script error on a wrong type,
// so the native checks nothing itself.
fn spawn(vm: &Vm, args: Args): Value {
    let e = vm.newTable();
    e.set("kind", j2.str(vm, args.str(0)));
    e.set("x", j2.num(args.num(1)));
    e.set("y", j2.num(args.num(2)));
    return e.value();
}

fn main() {
    let mem = Arena.create(64 << 10);
    let vm = Vm.new(&mem, Limits { heapBytes: 8 << 20 });  // also stepsPerCall, maxDepth
    vm.openLibs(j2.LIB_ALL);            // or LIB_BASE | LIB_MATH | LIB_STRING | LIB_ARRAY
    vm.bind("spawn", spawn);            // binds come before the first load

    match vm.loadFile("hello", "mods/hello.j2") {   // runs the top level
        Result.Ok(exports) {
            let mod = vm.keep(exports);             // a GC root until release
            let onTick = vm.get(mod).get("onTick");
            match vm.call(onTick, [j2.num(0.016)]) {   // j2.noArgs() for none
                Result.Ok(v) { }
                Result.Err(e) { printLn(e.message); }
            }
            let speed = vm.get(mod).getNum("speed", 0.0);
            vm.release(mod);
        }
        Result.Err(e) {
            printLn(e.message);   // "mods/hello.j2:1:21: unknown name \"spwan\""
        }
    }
}

Other pieces of the API: vm.loadString(name, text), vm.callWithThis(f, self, args), vm.host = w as ptr (natives read it back as vm.host as &World), vm.seed(n) for repeatable rand/pick, vm.resolver to answer use name;, vm.raise("msg") to fail from a native, vm.stats() for a debug HUD, and vm.destroy() to free everything.

Rules for hosts

  • Order: openLibs and every bind before the first load. Names are checked at load, so a name bound later is an unknown name error. Rebinding a name replaces it for loaded scripts too.
  • Never panics: every entry returns Result<_, Error> and the VM keeps working after a parse error, runtime error, out of script memory, took too long, or stack overflow. e.chunk names the script whose code raised, so the host can disable just that one.
  • Error text lives briefly: e.message and e.trace are valid until the VM’s next error; copy them into a j3.text.Text first. Never put script text in a backtick string (512-byte limit, frame lifetime).
  • Keep what you hold: newTable, newArray, and j2.str results live until the current native or top-level call returns. keep anything held longer (exports, entity tables, callbacks) and release it when done. A vm.call result held in a J3 local across another call needs vm.protect(v).
  • Natives: read every argument before any side effect, so a wrong one changes nothing. Calling vm.call from a native is fine.
  • Numbers: vm.getNum/setNum are f32 (fine for positions). Read ints like colors or ids with vm.get(r).getNum(k, d), which is f64 and exact up to 2^53.
  • Reload: each load is a fresh module. Release the old exports and every Ref into them, then load again. Kept tables survive; store a kind’s name in them and look up its code in the new exports.
  • One VM per thread. Two threads that run scripts make two VMs.
  • Sandbox: scripts see only what you bind. Without natives for files, network, or processes, a mod can’t reach them.

Not yet: coroutines (wait(1.5)), metatables, try/catch in j2, sharing a VM across threads, a bytecode cache, state kept across a reload.