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 want | Go to |
|---|---|
| The smallest host | j3c doc recipes j2 |
| The host API and its rules | j3c doc j2, j3c doc j3.j2 --short |
| The language page to give modders | j3c doc j2 lang |
| A whole game made of scripts | examples/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
nilandfalseare false.0and""are true. - Numbers: ints and floats.
7 / 2is3;7.0 / 2is3.5.int(x)andfloat(x)convert. this:obj.f(a)passesobjasthisonly whenf’s first parameter is namedthis. That’s how methods are written.- Dot calls: when
xhas no fieldf,x.f(a)isf(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 justnil. - 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:
openLibsand everybindbefore the first load. Names are checked at load, so a name bound later is anunknown nameerror. 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, orstack overflow.e.chunknames the script whose code raised, so the host can disable just that one. - Error text lives briefly:
e.messageande.traceare valid until the VM’s next error; copy them into aj3.text.Textfirst. Never put script text in a backtick string (512-byte limit, frame lifetime). - Keep what you hold:
newTable,newArray, andj2.strresults live until the current native or top-level call returns.keepanything held longer (exports, entity tables, callbacks) andreleaseit when done. Avm.callresult held in a J3 local across another call needsvm.protect(v). - Natives: read every argument before any side effect, so a wrong one changes nothing. Calling
vm.callfrom a native is fine. - Numbers:
vm.getNum/setNumaref32(fine for positions). Read ints like colors or ids withvm.get(r).getNum(k, d), which isf64and exact up to 2^53. - Reload: each load is a fresh module. Release the old exports and every
Refinto 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.