Introduction
J3 is an ahead-of-time compiled language for making games. It looks a little like Rust and a little like Zig, but it is smaller than either: there is no garbage collector, no borrow checker, and no escaping closures. What you get instead is plain data, explicit memory, and a compiler that turns your program into a native binary through LLVM.
module hello;
use j3.io.printLn;
fn main() {
let name = "world";
printLn(`Hello, {name}!`);
}
The compiler, j3c, is a single Rust binary with no dependencies. It carries the whole standard library inside it, so the version of the docs you read with j3c doc always matches the compiler you run.
What’s in the box
- The language: structs with methods, tagged-union enums, slices, arenas, generics with trait bounds, closures, and a
?operator for errors. - The standard library: windows, input, audio, 2D and 3D rendering, an ECS, physics, UI components styled with CSS, networking, threads, and more, all written in J3.
- Tools: a formatter, a language server, a 3D scene editor, a script VM for hot-reloaded
.j3sfiles, the j2 runtime for player mods, and packaging for zip,.deb, and Android.
How to read this book
Getting Started gets a program running. The Language part walks through J3 from values up to threads; each chapter builds on the last. The Compiler part covers j3c itself: commands, the j3.toml manifest, build modes, testing, and shipping.
When you need an exact function name, ask the compiler:
j3c doc # tasks and stdlib modules
j3c doc search <word> # find a name in any module
j3c doc <module> --short # one module on one screen
j3c doc recipes # complete, checked example programs
Getting Started
Installing
You need Rust (with Cargo) and clang on your PATH. Games that open a window also need SDL2, and 3D ones need OpenGL 3.3.
From the repository root:
cd j3c
cargo build --release
The binary is j3c/target/release/j3c. Put it on your PATH (the repo’s build.sh builds and installs it to $J3_BIN_DIR, else ~/.bin if that exists, else ~/.local/bin). Check it with:
j3c version
Your first project
j3c new mygame
cd mygame
j3c check
j3c run
j3c new writes a j3.toml manifest and src/main.j3. There are a few starting points:
| Command | What you get |
|---|---|
j3c new mygame | A console “hello” program |
j3c new mygame --app | An SDL window with a game loop |
j3c new mygame --template fps | A first-person 3D game with an editor scene |
j3c new mygame --template ui | A window built from .j3ui components |
The everyday loop
| Command | Does |
|---|---|
j3c check | Parses and type-checks. Fast; produces nothing. |
j3c run | Compiles and runs the project. |
j3c build | Writes the binary to target/<name>. |
j3c fmt --write | Formats every .j3 and .j3s file. |
With no path these use ./j3.toml. You can also point them at a directory, a j3.toml, or a single .j3 file.
A window
Here is the shape of a small game loop using j3.game directly:
module game_loop;
use j3.game;
use j3.math.Vec2;
fn main() {
let pos = Vec2 { x: 40, y: 40 };
let running = true;
while running {
let dt = game.poll(); // pump input, get the frame time
if game.quitRequested() || game.keyDown(game.Key.Escape) {
running = false;
}
pos.x += 60 * dt;
game.clear();
game.drawRect(pos, Vec2 { x: 8, y: 8 });
game.present();
}
}
Bigger games usually use j3.app, a runner with startup, update, and draw stages. j3c doc recipes app2d prints a complete one.
Editor support
editors/vscode/ is a VS Code and Cursor extension: syntax highlighting, completion and diagnostics through j3c lsp, and Run/Build/Format commands. Set j3.j3cPath if j3c is not on your PATH.
Values and Types
Variables
let declares a local. Every local is mutable, and its type is inferred unless you write one.
#![allow(unused)]
fn main() {
let hp = 3; // int
let speed: f32 = 4.5; // annotated
hp -= 1; // fine: let is mutable
}
A let lasts until the end of its block. A later sibling block can reuse the name, but a name still in scope cannot be shadowed:
#![allow(unused)]
fn main() {
let far: f32 = 2.0;
if far > 1.0 { let a = 1; printLn(`a={a}`); }
if far > 1.0 { let a = 2; printLn(`a={a}`); } // fine: the first `a` has ended
}
Primitive types
| Type | What it is |
|---|---|
int | 64-bit signed integer (same as i64) |
float | 64-bit float (same as f64) |
i8 i16 i32 i64 | Signed integers |
u8 u16 u32 u64 | Unsigned integers |
f32 f64 | Floats. Gameplay math uses f32. |
f16 | Half float, storage only (2 bytes). Convert with f16(x) / f32(h); no arithmetic. |
bool | true or false |
string | A read-only view of UTF-8 bytes |
void | No value |
ptr, cstr | Raw pointer and C string, for talking to C |
Numbers and conversions
A literal with no context is an int, or an f64 if it has a dot. A literal next to a typed value takes that value’s type, so with far: f32, both 2 * far and far * 0.5 are f32.
Variables never convert on their own. Convert explicitly by calling the type:
#![allow(unused)]
fn main() {
let n: int = 3;
let far: f32 = 10.0;
// let x = far * n; // error: f32 * int
let x = far * f32(n); // ok
let k = int(far);
}
f16 is for compact storage (colors, UVs, weights): keep it in structs and arrays, and convert to f32 to compute. f16(x) takes a float or int and rounds to nearest even (too big becomes infinity, NaN stays NaN); f32(h) and f64(h) are exact. An untyped literal stored in an f16 is rounded at compile time. +, <, ==, -h, += and interpolation on an f16 are compile errors that say to convert first. Both backends keep the 16 bits as an integer and convert with the same rounding, with or without _Float16 in the C compiler, so results match bit for bit. Not available in .j3s scripts or as a j3.gpu vertex attribute.
Integer overflow wraps. % truncates like C (-7 % 3 == -1); use math.wrapi(-7, 3) when you want an index that wraps around to 2. Dividing by zero stops the program.
The compound assignments +=, -=, *=, /=, %=, &=, |=, and ^= exist.
&, |, ^ and unary ~ work on integers of one type (x & 0xff types the literal; 0b1010 and 0xff are integer literals). Precedence, tightest first: unary - ! ~ &x, * / %, + -, << >>, &, ^, |, comparisons, &&, ||. Unlike C, x & MASK == 0 is (x & MASK) == 0. ~ on an unsigned type keeps its width (~u8(0) == 255). <, <=, >, >= take numbers; a string has no order (a compile error). Compare with == / !=, or text.same from j3.text. Bools use && / ||.
Constants
const declares a module-level constant. The value can be an integer expression, a float, a bool, or a string.
#![allow(unused)]
fn main() {
const MAX_ENEMIES = 64;
const GRAVITY = -9.8;
const TITLE = "Dungeon";
}
A typed array constant is a read-only table stored in the binary:
#![allow(unused)]
fn main() {
const LEVEL_XP: [4]int = [0, 100, 250, 500];
fn xpFor(level: int): int {
return LEVEL_XP[level]; // bounds-checked
}
}
Writing into a constant table (LEVEL_XP[0] = 1) is a compile error. Constant tables are private to their module.
There are no mutable globals. For process-wide state, see #[singleton].
String interpolation
A backtick string fills {holes} with values:
#![allow(unused)]
fn main() {
let name = "Ada";
let hp = 7;
printLn(`{name} has {hp} hp`);
}
A struct in a hole needs the #[Debug] attribute. The result lives in the calling function’s stack frame and holds up to 512 bytes; going past that stops the program. For longer text, build it with j3.text.Text.
Functions and Modules
Functions
#![allow(unused)]
fn main() {
fn add(a: int, b: int): int {
return a + b;
}
fn sayHello() { // `: void` is implied
printLn("Hello!");
}
}
Parameters are passed by value. To let a function change something, pass a pointer with & and write through it with *:
fn increment(n: &int) {
*n += 1;
}
fn main() {
let count = 0;
increment(&count);
}
Closures
A function literal fn(x: int): int { ... } is a closure. It captures the variables it names by address, so a write inside it is visible afterwards, and a loop variable is seen at the value of the current iteration:
fn apply(f: closure(int): int, x: int): int { return f(x); }
fn main() {
let total = 0;
let base = 10;
let add = fn(x: int): int { total += x; return base + x; };
add(1);
add(2); // total is now 3
printLn(`{apply(fn(x) { return x * base; }, 4)}`); // 40: the types come from `apply`
}
The type is closure(A, B): R. A closure value points into the stack of the function that made it, so it can only be a parameter or a let. The compiler rejects it as a struct field, a global, an array element, a generic argument (List<closure(int)>), a return type, a & target, or a cast, with a message that says what to do instead. For the same reason it never converts to a plain fn(..) or ptr: thread.spawn and pool.submit still take fn(ptr) plus a struct.
A named fn can be passed where a closure(..) is wanted, and a literal that captures nothing can be passed where a plain fn(..) is wanted. Inside a closure body, return leaves the closure, defer runs when it ends, and break/continue cannot cross its edge. A name the closure uses cannot be redeclared inside it (shadowing is an error everywhere). Closures are not available in .j3s scripts.
Modules
One file is one module. The module line names it, and that name is how other files import it.
#![allow(unused)]
fn main() {
module mygame.player;
use j3.io.printLn; // one name
use j3.memory.{ Arena, List }; // several names
use j3.game; // the module itself: game.poll(), game.Key.Escape
}
Only items marked pub can be named from another module:
#![allow(unused)]
fn main() {
pub struct Player { hp: int; }
pub fn spawn(): Player { return Player { hp: 10 }; }
fn helper() {} // private to this file
}
Imports cannot form a cycle; the compiler reports it.
Splitting a big module
A module can span several files. Files in a folder next to X.j3 (that is, X/*.j3) that begin with the same module line are more of the same module: they share its names and its imports. Put the use lines in X.j3.
src/
world.j3 module mygame.world; (all the `use` lines)
world/
spawn.j3 module mygame.world;
save.j3 module mygame.world;
A file in that folder with a different module line is its own module, and the folder becomes part of its name (mygame.world.tiles).
Control Flow
Conditions take no parentheses, and braces are always required.
if
#![allow(unused)]
fn main() {
if hp <= 0 && !invincible {
printLn("dead");
} else if invincible {
printLn("cheat");
} else {
printLn(`hp={hp}`);
}
}
if is also an expression. Used as a value, it needs a final else, and only the chosen arm runs:
#![allow(unused)]
fn main() {
let abs = if n < 0 { -n } else { n };
let tier = if xp > 500 { 3 } else if xp > 100 { 2 } else { 1 };
}
&& and || short-circuit.
while
#![allow(unused)]
fn main() {
let n = 3;
while n > 0 {
n -= 1;
}
}
for
for walks a range or a collection. a..b stops before b; a..=b includes it.
#![allow(unused)]
fn main() {
for i in 0..5 { } // 0 1 2 3 4
for i in 0..=5 { } // 0 1 2 3 4 5
for n in xs { } // each element of an array or slice
for i, n in xs { } // with its index
}
break and continue work on the innermost loop. Put a label on a while or for to reach an outer one: break 'rows; leaves it, continue 'rows; moves to its next step. The defers of the loops left run first, innermost first.
#![allow(unused)]
fn main() {
'rows: for r in 0..3 {
for c in 0..3 {
if c == 1 { continue 'rows; } // next row
if r == 2 { break 'rows; } // leave both loops
printLn(`r{r} c{c}`);
}
}
}
match
match must list every variant, each written with its enum name. There is no catch-all arm on an enum, so adding a variant to an enum points you at every match that needs it.
#![allow(unused)]
fn main() {
match dir {
Dir.N { return "north"; }
Dir.E { return "east"; }
Dir.S { return "south"; }
Dir.W { return "west"; }
}
}
Inside a payload, _ matches anything (Result.Err(_)). Payloads are covered in Enums and Errors.
match on an integer takes int literals or int consts as arms. It needs a final _ arm, and a repeated value is a compile error:
#![allow(unused)]
fn main() {
const MAX = 255;
match code {
0 { printLn("zero"); }
-1 { printLn("minus one"); }
MAX { printLn("max"); }
_ { printLn(`other {code}`); }
}
}
defer
defer runs a statement when its block ends, including on an early return. Several defers run last-first.
fn main() {
let mem = Arena.create(16 * 1024 * 1024);
defer mem.destroy();
let frame = mem.checkpoint();
defer mem.rewind(frame); // runs before destroy
}
Structs and Methods
Structs
A struct is plain data. Fields end with ;, and a field may have a default.
#![allow(unused)]
fn main() {
struct Enemy {
hp: int = 10;
speed: f32 = 2.0;
alive: bool = true;
}
let e = Enemy { speed: 3.5 }; // hp and alive use their defaults
}
Fixed-size array fields have no default, so write them in every literal:
#![allow(unused)]
fn main() {
struct State {
n: int;
ys: [3]int;
}
let s = State { n: 0, ys: [0, 0, 0] };
}
Methods
Methods live inside the struct body. The receiver is the first parameter, named this.
#![allow(unused)]
fn main() {
struct Vec2 {
x: f32;
y: f32;
// by value: small structs are copied
fn length(this: Vec2): f32 {
return sqrt(this.x * this.x + this.y * this.y);
}
// by pointer: changes the caller's value
fn scale(this: &Vec2, s: f32) {
this.x *= s;
this.y *= s;
}
}
let v = Vec2 { x: 3, y: 4 };
let len = v.length();
v.scale(0.5); // `&v` is taken for you
}
There is no fn Vec2.name(...) syntax outside the body.
. follows pointers automatically, and works on any value, not just variables: f().x, (a + b).x, and xs[a..b].len are all fine. A temporary like f().x can be read but not assigned.
Operators
Operators are methods named operator+, operator-, operator*, and so on:
#![allow(unused)]
fn main() {
struct Vec2 {
x: f32;
y: f32;
fn operator+(this: Vec2, o: Vec2): Vec2 {
return Vec2 { x: this.x + o.x, y: this.y + o.y };
}
fn operator*(this: Vec2, s: f32): Vec2 {
return Vec2 { x: this.x * s, y: this.y * s };
}
}
let next = pos + vel * dt;
}
Embedding with use
use on a field embeds it: the inner struct’s fields can be read straight through the outer one. This is composition, not inheritance.
#![allow(unused)]
fn main() {
struct Transform {
use position: Vec2;
use scale: Scale2;
}
let t = Transform {
position: Vec2 { x: 10, y: 10 },
scale: Scale2 { width: 1, height: 1 },
};
printLn(`{t.x} {t.width}`); // same as t.position.x, t.scale.width
}
Only fields are promoted, not methods: write t.position.length(). Two embedded structs with a field of the same name is a compile error.
A use statement brings a value’s fields into the current block as names. They are places, not copies, so assigning one writes the field:
#![allow(unused)]
fn main() {
use t;
width = 2; // writes t.width
}
Printing structs
Mark a struct #[Debug] to put it in a string interpolation hole:
#![allow(unused)]
fn main() {
#[Debug]
struct Vec2 { x: f32; y: f32; }
printLn(`pos = {pos}`);
}
Free functions as methods (UFCS)
x.f(args) calls the free function f(x, args) when x’s type has no method f. The type’s own method always wins.
fn sum(p: P): int { return p.x + p.y; }
fn bump(p: &P, by: int) { p.x += by; }
p.bump(10); // takes &p, like a method with this: &P
let t = p.sum();
fmust be visible by its bare name: declared in this module, or imported withuse m.{ f }. A name reachable only asm.fdoes not count.- The first parameter takes the receiver by value, or as
&T, and the receiver may itself be a&T. - It works on any receiver: structs, enums, primitives (
far.abs()withuse j3.math.{ abs }), slices, strings. - Generic free functions work too:
xs.first()infersTfrom the receiver (see Generics). - Same IR as
f(x, args); the receiver is evaluated once, before the other arguments.
See speculative/lang/ufcs.j3.
extend
extend T { .. } adds methods to a struct declared in the same module, in any file of that module: the main file or a part file (X.j3 plus X/*.j3 saying the same module). It is the same as writing the methods in the struct body, so big types keep their methods next to the code they use.
struct Counter { n: int; }
extend Counter {
fn bump(this: &Counter, by: int) { this.n += by; }
}
extend Box<T> { // a generic struct: parameters named as in `struct Box<T>`
fn first(this: &Box<T>): T { return this.v; }
}
- Methods are public like in a body, so
pub fnis an error. - Errors, each with a fix hint: no such struct, a struct of another module, an enum (write a free fn), a method name already in the body or another block, other type parameters than the struct has.
- An error inside a method points into the file where it is written.
j3c doclists these methods under their struct. - Not available in
.j3sscripts.
See speculative/lang/extend.j3.
Arrays, Slices, and Strings
Fixed arrays
[N]T is an array of N values that lives on the stack. Its size is part of its type.
#![allow(unused)]
fn main() {
let xs: [4]int = [10, 20, 30, 40];
xs[0] = 11;
printLn(`{xs.len}`); // 4
}
Slices
[]T is a slice: a pointer and a length. It views memory someone else owns and never allocates. An array turns into a slice when you pass it where one is expected.
#![allow(unused)]
fn main() {
fn sum(xs: []int): int {
let total = 0;
for n in xs {
total += n;
}
return total;
}
let all: []int = xs[:]; // whole array
let mid: []int = xs[1..3]; // elements 1 and 2
mid[0] = 21; // writes xs[1]
sum(xs); // array passed as a slice
}
xs[a..b] is half-open, like ranges in for.
Bounds checks
Every xs[i] is checked. An index out of range stops the program with the file and line (exit code 102). j3c build --fast removes those checks when you need the last bit of speed.
Strings
string is a read-only view of UTF-8 bytes, just like a []u8 you cannot write to. s.len is its length in bytes.
#![allow(unused)]
fn main() {
let name: string = "Coby";
printLn(`{name} is {name.len} bytes`);
}
To build or change text, use j3.text.Text:
#![allow(unused)]
fn main() {
let t = Text.scratch().add("score: ").add(`{score}`).str();
}
A Text.scratch() result lasts until the next frame. See j3c doc j3.text.
Growable lists
Arrays and slices have a fixed length. For a list that grows, use List<T> from j3.memory. It allocates from an arena, so it is covered in Memory and Arenas.
Enums and Errors
Plain enums
An enum without payloads is a set of named values (stored as an i32). Variants are always written with the enum name in front.
#![allow(unused)]
fn main() {
enum Dir {
N, E, S, W
}
let d = Dir.N;
}
Enums with payloads
Give any variant a payload and the enum becomes a tagged union. match pulls the payload out:
#![allow(unused)]
fn main() {
fn find(xs: []int, want: int): Option<int> {
for n in xs {
if n == want {
return Option.Some(n);
}
}
return Option.None;
}
match find(xs, 2) {
Option.None { printLn("miss"); }
Option.Some(n) { printLn(`hit {n}`); }
}
}
Patterns nest, and _ inside a pattern matches anything:
#![allow(unused)]
fn main() {
match greet() {
Result.Ok(msg) { printLn(msg); }
Result.Err(IoError.NotFound) { printLn("missing"); }
Result.Err(_) { panic("unexpected io"); }
}
}
Errors and ?
J3 has no exceptions and no null. A function that can fail returns an enum, usually Option<T> (j3.option) or Result<T, E> (j3.result).
? unwraps one: on success it gives you the value, on failure it returns the failure from the current function.
#![allow(unused)]
fn main() {
use j3.result.Result;
enum IoError { NotFound, Permission }
fn loadName(): Result<string, IoError> {
return Result.Err(IoError.NotFound);
}
fn greet(): Result<string, IoError> {
let name = loadName()?; // returns the Err early
return Result.Ok(`hello {name}`);
}
}
? is not tied to Option or Result by name. It works on any enum marked #[TryCatch(Ok, Err)], naming its success and failure variants. That is exactly how the standard library defines those two, and you can do the same for your own types.
Panics
j3.sys.panic("message") stops the program with exit code 101. It is a library call, not a keyword. Use it for “this can’t happen”, not for errors a caller should handle.
Generics and Function Pointers
Generic structs
Structs can take type parameters. Their methods can use them.
#![allow(unused)]
fn main() {
struct Pair<T> {
a: T;
b: T;
fn first(this: &Pair<T>): T {
return this.a;
}
}
let p = Pair<int> { a: 1, b: 2 };
let x = p.first();
}
Methods can also take their own type parameters, passed with ::<T> at the call. The arena’s alloc is one:
#![allow(unused)]
fn main() {
let enemies = mem.alloc::<Enemy>(256); // []Enemy
}
Generics are monomorphized: each Pair<int> and Pair<f32> is compiled as its own type, so there is no runtime cost.
Generic functions
Free functions take type parameters too.
#![allow(unused)]
fn main() {
fn first<T>(xs: []T): T { return xs[0]; }
fn swap<T>(a: &T, b: &T) { let t = *a; *a = *b; *b = t; }
pub fn maxOf<T>(a: T, b: T): T { if a > b { return a; } return b; }
let n = first(nums); // T = int
let m = maxOf(f, 2); // f is f32, so the literal is an f32
let k = nums.first(); // UFCS infers T from the receiver
let z = zero::<f32>(); // T only in the return type: write it
}
T comes from the arguments. A bare number takes the type of the other arguments, or int/float when nothing else says. A closure parameter works (fn apply<T>(x: T, f: closure(T): T): T), and the written types of a closure literal count toward T. If T cannot be found the compiler asks for name::<Type>(..).
Like generic structs, each combination of types is compiled once, shared by every module that uses it. The body is checked per instance with T replaced, so a > b compiles for int and fails with a note naming the call for a string. A generic function that calls itself with a bigger type (f<T> calling f<List<T>>) stops with an error at 32 levels. Write the return type; it is not inferred. Generic functions cannot be called from .j3s scripts.
Function pointers
fn(A, B): R is the type of a pointer to a function. Any named function can be passed as one.
#![allow(unused)]
fn main() {
fn inc(x: int): int { return x + 1; }
fn twice(f: fn(int): int, x: int): int {
return f(f(x));
}
twice(inc, 3); // 5
}
A slice of them makes a simple system list:
#![allow(unused)]
fn main() {
let systems: []fn(&World, f32) = [moveSystem, collideSystem];
for sys in systems {
sys(&world, dt);
}
}
What’s not here
J3 has traits as bounds on type parameters (see Traits), but no dyn or trait objects. A plain fn(..) carries no state, so a callback that must outlive the call (a thread job) takes the state as a pointer argument next to the function pointer; a callback that is used and dropped inside one call can be a closure (see Functions and Modules). The thread pool works this way: pool.parallelFor(n, f, ctx) calls f with your ctx.
Traits
A trait is a list of method signatures. A bound on a type parameter says which traits the type must satisfy, and the compiler checks that where the generic is used.
#![allow(unused)]
fn main() {
pub trait Hash { fn hash(this: Self): u64; }
pub trait Eq { fn operator==(this: Self, other: Self): bool; }
fn contains<T: Eq>(xs: []T, x: T): bool {
for i in 0..xs.len {
if xs[i] == x { return true; }
}
return false;
}
struct Map<K: Hash + Eq, V> { ... }
}
Self is the type being checked. The first parameter is this: Self or this: &Self; the rest and the return type must match exactly. A trait has no method bodies, no where, no type parameters of its own, and no supertraits. Traits are exported and imported like functions (pub trait, use m.Hash;), and share one namespace with types.
Satisfying a trait
A type satisfies a trait by having the methods. There is no impl Trait for T block, so every existing operator== already satisfies Eq.
#![allow(unused)]
fn main() {
struct Vec3 {
x: f32; y: f32; z: f32;
fn hash(this: &Vec3): u64 { ... } // Vec3 is Hash
}
}
For a type you cannot edit (int, string, a stdlib struct) write an #[impl] fn. It is a free fn whose first parameter is the receiver, and x.hash() finds it:
#![allow(unused)]
fn main() {
#[impl]
fn hash(x: int): u64 { return u64(x); }
}
There can be one #[impl] fn per (method name, type), so hash for int and hash for string live side by side. The orphan rule says an #[impl] fn sits in the module that declares a trait with that method, or in the module that defines the type. An own method wins over an #[impl] fn. A second #[impl] for the same method and type is an error that names the first.
int, floats, bool, string, ptr and C-like enums satisfy operator== without any help, and numbers satisfy operator<. An ordinary function (including one used through UFCS) does not satisfy a bound: bodies are lowered in the module that defines them, where the caller’s function is not visible. The error says so and points at #[impl].
Where bounds are checked
At the call of a generic fn, at a mention like Map<Vec3, int>, and at the call of a method that has its own <U: Trait>. The error points there:
error: `Vec3` does not satisfy `Hash` (needed by `K` of `Map`)
note: bound declared at j3/data/map.j3:5
note: missing: fn hash(this: Self): u64
help: add `fn hash(this: &Vec3): u64` to `struct Vec3`, or `#[impl] fn hash(x: Vec3): u64` in a module that declares `Hash` or defines `Vec3`
Bodies are still checked once per instance, with T replaced. A bound is the contract the caller sees, not a limit inside the body: calling a method outside the bounds works until an instance lacks it. The methods of a generic struct (Map<K, V>) are lowered with each instance, so they use the struct’s bounds rather than adding their own.
Comparison operators
Without operator!=, a != b is !(a == b). Without operator>, operator<= or operator>=, they use operator<: a > b is b < a, a <= b is !(b < a), a >= b is !(a < b). <= and >= are !(b < a) and !(a < b), so Ord assumes a total order; if a field can be NaN, define <= and >= yourself. An operator you write always wins. With no operator== (and no built-in one), == and != on a struct is still an error. So a type needs operator== for Eq and operator< for Ord, and gets the other four operators free.
In the standard library
j3.cmp declares Eq (operator==), Ord (operator<) and Hash (fn hash(this: Self): u64). It gives Hash to int, i32, u32, u8, u64, bool, f32, f64 and string through #[impl] fns (a splitmix64 mixer for numbers, FNV-1a plus the mixer for strings, so the low bits are safe to mask), and has combine(a, b) to build a hash from fields, plus the helpers hashOf, contains, indexOf, minOf and maxOf. Entity has hash. j3.data.map is Map<K: Hash + Eq, V>: Map<string, int>.new(&arena, 16), put, get (an Option), getOr, has, remove, clear, and slot iteration (for i in 0..m.cap { if m.live(i) { m.keyAt(i); m.valAt(i); } }). Like List, it takes its memory from an arena and is not shared across threads. j3c doc j3.cmp and j3c doc j3.data.map list them.
Limits
No dyn or trait objects, no default methods, no generic traits, no closure types in a trait method, and no traits in .j3s scripts (the VM has no generic instances). A script may call a plain function that uses them, but x.hash() in a script does not find #[impl] fns. A bound is a bare trait name, imported with use.
Memory and Arenas
J3 has no garbage collector and no malloc/free pairs to match up. Values live on the stack, and anything bigger or longer-lived comes from an arena.
Arenas
An Arena (j3.memory) is a bump allocator: it hands out memory by moving a cursor forward, and frees everything at once by moving it back.
use j3.memory.{ Arena, List };
fn main() {
let mem = Arena.create(16 * 1024 * 1024); // 16 MB to start
defer mem.destroy();
let pos = mem.alloc::<Vec2>(256); // []Vec2, 256 long
let vel = mem.alloc::<Vec2>(256);
}
When an arena fills up it adds another chunk. Old pointers stay valid when it grows.
Keep the arena itself in one let and pass &Arena around. Never copy an Arena by value into a struct: store &Arena.
#![allow(unused)]
fn main() {
struct Game {
mem: &Arena;
world: World;
}
}
Checkpoint and rewind
checkpoint() remembers where the cursor is. rewind(mark) moves it back, freeing everything allocated since. This is how a game throws away per-frame scratch memory for free:
#![allow(unused)]
fn main() {
fn tick(g: &Game, dt: f32) {
let frame = g.mem.checkpoint();
defer g.mem.rewind(frame);
// anything allocated from g.mem here is gone after this frame
}
}
Memory allocated before the checkpoint is untouched, so long-lived data and per-frame scratch can share one arena as long as the long-lived data comes first.
The scratch arena
memory.scratch(cap) gives a process-wide scratch arena (one per thread), and memory.rewindScratch() resets it. j3.app resets it every frame for you.
Lists
List<T> is a growable array that allocates from an arena:
#![allow(unused)]
fn main() {
let hits = List<int>.new(&mem);
hits.push(3);
hits.push(7);
hits.remove(0); // keeps order
hits.swapRemove(0); // O(1): moves the last item into the hole
for i in 0..hits.len {
printLn(`{hits.items[i]}`);
}
}
items and len are public fields; hits.items[0..hits.len] is the live slice. When the arena rewinds past where the list allocated, the list’s memory goes with it.
Entities are indices
The J3 style for game objects is struct of arrays + integer ids: keep each field in its own slice, and refer to an entity by its index rather than a pointer. Indices survive reallocation and compaction; pointers don’t.
#![allow(unused)]
fn main() {
struct World {
pos: []Vec2;
vel: []Vec2;
count: int;
}
fn spawn(world: &World, p: Vec2, v: Vec2): int {
let id = world.count;
world.pos[id] = p;
world.vel[id] = v;
world.count += 1;
return id;
}
}
j3.game.ecs builds on the same idea with generational ids, typed columns, and deferred commands.
Attributes, Intrinsics, and C
Attributes
Attributes go on the line before a declaration.
| Attribute | On | Effect |
|---|---|---|
#[Debug] | struct | Can be printed in a `{hole}` |
#[TryCatch(Ok, Err)] | enum | Works with ? (see Enums and Errors) |
#[singleton] | let | One value for the whole process |
#[thread] | let (with #[singleton]) | One value per thread |
#[impl] | free fn | Gives a type you cannot edit a trait’s method (see Traits) |
#[shader] #[shader(vertex)] #[shader(fragment)] #[vertex] #[varying] #[uniform] #[flat] | fn, struct, field, let | GPU code and its data (see Shaders) |
Singletons
There are no ordinary mutable globals. A #[singleton] is the one exception: a single process-wide value. Its initializer must be a constant, not a function call, and it cannot be pub.
#![allow(unused)]
fn main() {
#[singleton]
let FRAME_COUNT: int = 0;
#[singleton]
#[thread]
let JOBS_DONE: int = 0; // one per thread
}
Intrinsics
Intrinsics start with @ and are handled by the compiler.
| Intrinsic | What it does |
|---|---|
@sizeof(T) | Size of a type in bytes |
@slice(p, n) | Build a slice from a pointer and a length |
@syscall(...) | A raw system call |
@atomicLoad(p) @atomicStore(p, v) | Atomic read and write through a &T (int, i32, u32, u64, bool, ptr) |
@atomicAdd(p, v) @atomicSub(p, v) @atomicExchange(p, v) | Atomic update; returns the old value (no add or sub on bool, ptr) |
@atomicCas(p, old, new) | Compare and swap; returns whether it swapped |
@vmin @vmax @vsqrt @vabs @vsum @vshuffle @vfloat @vint @vload @vstore | SIMD on vec4f / vec4i (4 lanes, 16 bytes, align 16): lane-wise ops, horizontal sum, shuffle, conversions, slice load and store. vec4f(x, y, z, w) builds one, .x .y .z .w read and write lanes. Same bits on LLVM and C at every opt level: @vmin/@vmax give the second operand on NaN or a tie, @vsum adds (x + y) + (z + w), @vint truncates, saturates and maps NaN to 0, shifts mask the amount to 0..31. Not in .j3s scripts |
| a last argument to any of them | Memory order: relaxed, acquire, release, acqrel, seqcst (default); load takes no release, store no acquire |
@script("path.j3s") | The type of a script (see Scripts) |
@j3ui | Immediate-mode UI functions (j3c doc j3ui-immediate) |
Calling C
use lib declares functions from a shared library. Each one is written as a J3 signature with no body:
use lib "SDL2" as sdl {
fn SDL_Init(flags: u32): i32;
fn SDL_Quit();
}
fn main() {
sdl.SDL_Init(0x20);
defer sdl.SDL_Quit();
}
The library is linked by name ("SDL2" links -lSDL2); add any search paths under [link] in j3.toml. Use cstr for C strings and ptr for opaque pointers.
Raw casts
as reinterprets a value at the bit level, for the rare low-level code that needs it (pointer arithmetic, FFI):
#![allow(unused)]
fn main() {
let addr = p as int;
let header = (addr + 8) as ptr as &i32;
}
For number conversions, prefer the checked-looking call form (f32(n), int(x)) from Values and Types.
Threads
j3.thread gives you threads on Linux: spawn/join, a Pool of workers, Queue<T>, Mutex, and Cond.
The one rule
Hand work over; don’t share it. Give a worker a struct that only it writes, wait for its result, then read it. The compiler does not check for data races, so this rule is the whole safety net.
Picking a tool
| You want to… | Use |
|---|---|
| Run one thing in the background | spawn(&t, name, f, arg), later join(&t) |
| Run many jobs on a few workers | Pool.create(&arena, workers, queueCap), then submit / trySubmit |
Do the same work over 0..n | pool.parallelForEach(n, fn(lo, hi) { .. }) (a closure; it waits for every range, which is why a closure is safe here). Lower level: pool.parallelFor(n, f, ctx) |
| Get results back to the frame loop | A Queue<T>: workers push, the main thread tryPops |
| Share a counter or cancel flag | @atomicAdd / @atomicLoad / @atomicStore on an int, i32, u32, u64, bool or ptr (@atomicAdd only on the integers), with an optional order (acquire, release, relaxed, …) |
| One value per thread | #[singleton] + #[thread] on a let |
A thread’s entry point is a plain fn(ptr): its state arrives as one pointer to a struct the spawner owns.
The frame thread must never block
Feed a pool with trySubmit and drain results with tryPop, so a slow job never stalls a frame. Stamp each job with a version and drop results that come back stale.
Main-thread only
The window, OpenGL, SDL, audio, and every j3.game, j3.gfx, j3.scene3d, j3.ui, and j3.app value belong to the main thread. Each thread needs its own Arena.
j3c doc recipes threads prints a complete program; j3c doc threads has the full guide.
Scripts (.j3s)
A .j3s file is a script: J3 checked by the same type checker, used for per-object game logic. During development j3c run executes scripts on a bytecode VM, so editing one takes effect without recompiling the game. j3c build compiles every script into the binary like any other code.
Anatomy of a script
A script has no module line; its path names it. Each instance of a script has its own props and state.
#![allow(unused)]
fn main() {
// scripts/door.j3s
use j3.math.{ clamp, lerp };
use j3.io.printLn;
use j3.scene3d.{ Scene, NodeId };
props {
node: NodeId; // the object the script is on (see below)
openAngle: f32 = 1.57; // radians
seconds: f32 = 0.5;
}
shared {
doorsOpened: int = 0;
}
let open = false;
let t = 0.0;
pub fn start(scene: &Scene) {
}
pub fn update(scene: &Scene, dt: f32) {
let goal: f32 = if open { 1.0 } else { 0.0 };
let step = dt / seconds;
t = clamp(t + clamp(goal - t, 0.0 - step, step), 0.0, 1.0);
scene.node(node).rotation.y = lerp(0.0, openAngle, smooth(t));
}
pub fn toggle() {
open = !open;
if open { doorsOpened += 1; }
}
pub fn report() {
printLn(`door: open {open} t {t} opened {doorsOpened}`);
}
// Private: only this script can call it.
fn smooth(x: f32): f32 {
return x * x * (3.0 - 2.0 * x);
}
}
propsare what the creator passes in. They are read-only inside the script. A prop with no default is required.sharedis one value every instance reads and writes.- Top-level
letis state that belongs to each instance. - Only
pub fncan be called from outside.
All defaults and initializers must be literals. A script may declare its own structs and enums; they share the script’s namespace, and the script’s own type name is taken.
Using a script from J3
@script gives you the script’s type:
#![allow(unused)]
fn main() {
const Door = @script("scripts/door.j3s"); // path from the project root
let door = Door { node: n }; // props by name; ones with defaults can be left out
door.toggle();
}
Outside code cannot read an instance’s fields or copy one; pass &Door around. A script can hold other scripts the same way. j3c doc scripts/door.j3s summarizes a script’s props and functions. j3c doc recipes script is a complete small project.
Scripts on scene objects
In an editor scene, script=scripts/door.j3s on an object’s line attaches a script, and param.<prop>=value sets its props. A node: NodeId prop receives the object it is attached to.
| Prop type | Scene line |
|---|---|
f32, f64 | param.seconds=1.5 |
int and sized ints | param.count=3 |
bool | param.locked=1 |
string | param.label="text" |
Vec3 | param.offset.x=0.5 (and .y, .z) |
NodeId | param.target=#12 (an object id) |
A required prop (no default) must be set on every object the script is on; j3c check points at the scene line when one isn’t, or when a value has the wrong type. A NodeId prop follows copies: copy an object together with the one it points at and the copy points at the other copy.
The game runs attached scripts through j3.scene3d.scripts, which j3c generates from the project’s scenes:
#![allow(unused)]
fn main() {
use j3.scene3d.scripts;
let scene = scene3d.load(scripts.scene(), mem); // the scene Play picked, else j3.editor.toml's
scripts.start(&scene); // makes every instance, calls `pub fn start(scene: &Scene)`
scripts.update(&scene, dt); // every frame: `pub fn update(scene: &Scene, dt: f32)`
let d = scene.script::<Door>(node);
}
j3c new mygame --template fps and j3c doc recipes scene3d already wire this up.
Hot reload
j3c run (and the editor’s Play) builds in dev mode and runs scripts on the VM. Editing a script’s code while the game runs swaps the new code in at the next frame. Changing a script’s props or state relaunches the same game binary. Neither recompiles the game. j3c build compiles every script in, so a shipped game cannot take new .j3s files; for that use j2.
What scripts leave out
Scripts are checked by the same type checker but run on a smaller VM, so some features are J3-only: generic functions, traits and #[impl] lookup, closures, SIMD types, and the shift operators (& | ^ ~ work). Using one is a compile error.
Scripts as tools
A script with a pub fn main() can be run once from the command line, which makes it handy for generators and project checks:
j3c run tools/arena.j3s some args
It makes one instance and calls pub fn main(). Arguments are read with j3.sys.arg (arg(0) is the program). A scene script is still attached with script=, not run this way. Typical uses are a scene generator (tools/arena.j3s) and a project check (tools/check.j3s). j3c dump-bc file.j3s shows the bytecode.
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.
Shaders
GPU code is J3 code. A fn marked #[shader(vertex)] or #[shader(fragment)] is checked against a small GPU subset and
turned into GLSL text when you compile, so a mistake is an error with a span and a fix in j3c check, not a driver log
when the window opens.
#![allow(unused)]
fn main() {
use j3.gpu.{ Source };
use j3.math.Vec2;
#[vertex] struct Quad { q: Vec2; }
#[varying] struct V { position: vec4; uv: vec2; }
#[uniform] let uScreen: vec2;
#[uniform] let uTex: sampler2D;
#[shader(vertex)]
fn vs(q: Quad): V {
return V { position: vec4(q.q / uScreen * 2.0 - 1.0, 0.0, 1.0), uv: q.q };
}
#[shader(fragment)]
fn fs(v: V): vec4 {
return @texture(uTex, v.uv);
}
const PROG: Source = @shader(vs, fs);
}
PROG.vert and PROG.frag are the two GLSL strings (static data, #version 330 core; the game’s shader compile calls
turn them into ES 3.0 text at run time). PROG.uniforms names the uniforms, which you set from the CPU by exactly those
names. PROG.attrib(i) for i < PROG.count is the vertex layout: location, components, GL type, byte offset, stride
and divisor.
What the attributes mean
| Attribute | On | Meaning |
|---|---|---|
#[shader(vertex)] #[shader(fragment)] | fn | An entry. A vertex fn takes #[vertex] structs and returns a #[varying] struct; a fragment fn takes that struct and returns the color as vec4 |
#[shader] | fn, struct | A helper fn, or a plain GPU struct. It exists only on the GPU |
#[vertex] #[vertex(instance)] | struct | A vertex buffer layout. Fields are f32 i32 u32 Vec2 Vec3 vec4f Mat4 Rgba8 (j3.gpu.Rgba8: four bytes, a normalized vec4); the CPU struct is the buffer’s element. Its packed layout must equal the CPU layout (else the error says which _pad to add) |
#[varying] | struct | Passed from vertex to fragment. The first field is position: vec4 |
#[flat] | varying field | No interpolation (integer fields are always flat) |
#[uniform] | module let | A uniform with exactly this name. No storage on the CPU |
The subset
Types are f32 i32 u32 bool, vec2..4, ivec2..4, uvec2..4, mat2..4, sampler2D, structs and fixed arrays. They
exist only in shader code; the CPU keeps Vec2 Vec3 Mat4 vec4f. Operators follow GLSL (vec * f32, mat * vec,
swizzles). A literal takes the other operand’s type; a variable never converts (f32(n)).
Statements: let, assignment, if, while, for i in a..b (an i32), match on an integer with _, break,
continue, return, if as a value. A &T parameter is inout.
Everything else is an error naming the fix: pointers, heap, strings, slices, defer, labels, closures, generics, 64-bit
int, recursion (the cycle is named), method calls on structs, calls to ordinary fns (mark them #[shader]), and
globals that are not #[uniform].
Builtins are @ calls from one table: @mix @clamp @dot @cross @length @distance @normalize @min @max @abs @sign @floor @ceil @fract @pow @exp @log @sqrt @sin @cos @tan @atan @step @smoothstep @mod @fwidth @dFdx @dFdy @texture @textureLod @texelFetch @textureSize @reflect @refract @transpose @inverse, with @fragCoord(), @discard() (fragment) and
@vertexId() (vertex). All of them exist in both GLSL 3.30 and ES 3.00.
Looking at the output
j3c dump-glsl [path] prints each program (add --es for the ES 3.0 text). Only the fns an entry reaches are emitted,
in a fixed order, each under a // file:line comment. Names GLSL reserves (input, sample, mix) get a j3_ prefix;
uniform names are never renamed (a uniform called texture is an error). Sample: speculative/lang/shader.j3; the same
programs compiled by a real driver, as 3.3 core and as ES, are j3c/stdlib/tests/gpu_glsl_test.j3. The full reference is
j3c doc shaders.
The j3c Command
j3c <command> [file.j3 | project]
A path can be a .j3 file, a j3.toml, or a directory containing one. With no path, j3c uses ./j3.toml.
Projects
| Command | Does |
|---|---|
j3c new <name> | New console project (--app for a window, --template fps or --template ui) |
j3c guide | How to start a project |
j3c check | Type-check without building. --json prints one JSON object. |
j3c run | Build and run. --file f.j3 runs one file inside a project. |
j3c run tool.j3s [args] | Run a script’s pub fn main once |
j3c build | Build target/<name> |
j3c size | What is inside the binary: sections, code per module, biggest functions |
Build flags
These work with run and build, and override j3.toml.
| Flag | Effect |
|---|---|
--release | Smallest binary: opt s, no debug info, stripped. Checks stay on. build also writes packages. |
--fast | Removes bounds-check aborts and debug info |
--opt N | 0, 1, 2, 3, s, or z |
--emit llvm|c | Pick the backend |
Running windowed programs headless
| Flag | Effect |
|---|---|
--headless | No window and no sound (SDL offscreen) |
--frames N | Stop after N frames |
--screenshot f.png | Save the last frame |
--hot | Rebuild and restart when a source file changes |
See Testing Headless.
Code tools
| Command | Does |
|---|---|
j3c fmt [path] | Print formatted source. --write rewrites files, --check fails if any would change. |
j3c lsp | Language server over stdio |
j3c doc ... | Stdlib docs, guides, and recipes (see below) |
Documentation
| Command | Shows |
|---|---|
j3c doc | Tasks and the list of stdlib modules |
j3c doc <module> | A module’s public items (--short for one screen) |
j3c doc search <word> | Public names containing a word |
j3c doc lang | Language summary |
j3c doc recipes [name] | Complete example programs |
j3c doc j3ui, css, testing, ship, threads, android | Guides |
j3c help manifest | Every j3.toml key |
Looking inside
| Command | Prints |
|---|---|
j3c dump-ir | The checked IR |
j3c dump-llvm | LLVM IR |
j3c dump-c | C source |
j3c dump-ui <file.j3ui> | J3 generated from a UI component |
j3c dump-bc <file.j3s> | Script bytecode |
j3c dump-syntax | A TextMate grammar for editors |
Editor and assets
| Command | Does |
|---|---|
j3c editor [project] | Open the 3D scene editor |
j3c -e scene <op> | List, get, add, set, delete, or check scene objects |
j3c -e render | Draw a scene to a PNG without a window |
j3c -e gen | Run mesh generators in assets/gen/ that changed |
j3c -e mcp | MCP server, so AI agents can edit and render scenes |
j3c golden <dir> | Headless golden-image tests |
j3c -e imgdiff a.png b.png | Pixel diff two images |
Version
j3c version --verbose prints the build, the stdlib source, and the binary path, which helps spot a stale j3c on your PATH.
Project Manifest (j3.toml)
Every project has a j3.toml at its root. Only [package] name is required.
[package]
name = "mygame"
entry = "src/main.j3"
version = "0.1.0"
description = "A small arcade game"
[emit]
opt = 2
[link]
libs = ["SDL2"]
[dist]
build_outputs = ["zip", "deb"]
icon = "assets/icon.png"
[package]
| Key | Meaning | Default |
|---|---|---|
name | Binary name; build writes target/<name> | required |
entry | File containing main | src/main.j3 |
version | Package version (used for .deb) | 0.1.0 |
description | One line for packages and the menu entry |
[emit]
| Key | Meaning | Default |
|---|---|---|
backend | llvm or c | llvm |
opt | 0, 1, 2, 3, s, z | 2 |
incremental | Reuse one object file per module at opt 0 and 1 (opt 2 and up use ThinLTO objects on Linux with lld or mold). "all" does it at every level. | true |
debug | Debug info | true |
fast | Strip bounds-check aborts, like --fast | false |
target | A target triple or a list of them; empty is the host | host |
cpu | CPU for clang, such as native | |
clang | clang executable | clang |
flags | Extra clang flags |
[link]
| Key | Meaning |
|---|---|
libs | Libraries without -l ("SDL2" links -lSDL2) |
search | Library search directories (-L) |
files | Extra object files or archives |
flags | Extra linker flags, such as an rpath |
[dist]
What j3c build --release packages into target/dist/. See Shipping a Game.
| Key | Meaning |
|---|---|
build_outputs | Any of "zip", "deb", "apk" |
include | Files and folders to ship. Default: whichever of assets, scripts, scenes, locales exist |
icon | .png (square 256 or 512) or .svg |
title | Menu name. Default: the name, title-cased |
categories | .desktop categories. Default Game; |
maintainer | Name <email> |
depends | Extra Debian packages |
[dependencies]
Path dependencies: name = { path = "../dir" } (or name = "../dir"), relative to the j3.toml that declares it. If <dir> is a package (a j3.toml and a src/), use name.a.b; loads <dir>/src/a/b.j3; otherwise <dir>/a/b.j3. The file’s first line is module name.a.b;, the same rule project files follow (module <package>.a.b;), so a package’s own files work unchanged. A dep’s [package] name must equal the key and its own [dependencies] are used too. Only the modules you import (and what they import) are compiled, for your package’s target; the dep’s main, [link] and other settings are not used. Deps are compiled in, so there is nothing to ship; their assets are not located for you. Type errors point at the dep’s own file:line; manifest errors name the j3.toml line: missing folder, a cycle, a key that is j3, the package name or a module of src/, one name with two paths. Editing a dep file rebuilds what uses it.
# android/j3.toml: the phone app shares the desktop app's wire protocol
[dependencies]
jzero = { path = ".." }
use jzero.remote.proto.{ Frame, encode, decode };
[ui]
| Key | Meaning | Default |
|---|---|---|
dir | Folder of .j3ui components; they become the module <package>.ui | ui |
theme | A global CSS file | |
lint | false turns off the CSS lint in j3c check | true |
[i18n]
| Key | Meaning | Default |
|---|---|---|
dir | Folder of <locale>.txt string tables | locales |
default | Locale shown first, and the one check compares against | en |
fallback | Locale used when a key is missing | en |
Environment variables
| Variable | Effect |
|---|---|
J3_STDLIB | Use the stdlib at this path (the parent of j3/) instead of the built-in copy |
J3_EDITOR_SRC | Build the editor from this source directory |
Build bookkeeping lives in target/.j3c/. Deleting it only costs a full rebuild.
Builds, Backends, and Optimization
How a build works
j3c reads your project, type-checks it into an intermediate representation, and prints LLVM IR as text. The host clang compiles and links that into a native binary. There is no libLLVM inside j3c, so it stays a small, dependency-free Rust program.
parse → check → emit LLVM IR → clang → target/<name>
The standard library is written in J3 and embedded in the j3c binary, so it is compiled along with your code.
Backends
| Backend | How to pick it | Notes |
|---|---|---|
| LLVM | default | Full feature set |
| C | --emit c or [emit] backend = "c" | Portable C11; a few features (such as nested array globals) need LLVM |
j3c dump-llvm and j3c dump-c print what each backend produces.
Build modes
| Mode | Command | What you get |
|---|---|---|
| Dev | j3c run, j3c build | Opt 0, debug info, fast incremental rebuilds. On Linux, a crash prints a stack trace. |
| Release | --release | Opt s, no debug info, stripped. Bounds checks stay on. |
| Fast | --fast | Bounds-check aborts removed |
Dev builds are for the edit loop; ship release builds. As an example, a large game goes from 4.5 MB in dev to 1.3 MB in release. j3c size shows where the bytes go.
On Linux with ld.lld or mold available, release builds use ThinLTO: one object per module, optimized across modules at link time, with a cache.
Dev-only code
j3.build tells your program how it was built. DEV and RELEASE are compile-time constants, so the dead branch is removed.
#![allow(unused)]
fn main() {
use j3.build;
if build.DEV {
drawDebugOverlay();
}
}
Incremental builds
At opt 0 and 1, j3c keeps one object file per module and only recompiles modules that changed. Set [emit] incremental = "all" to do this at every opt level (faster rebuilds, but no inlining across modules).
Cross-compiling
[emit] target takes one triple or a list. With several, each binary goes to target/<triple>/<name>.
[emit]
target = ["x86_64-unknown-linux-gnu", "x86_64-pc-windows-gnu"]
Android has its own flow: see j3c doc android.
Testing Headless
Windowed programs can run with no display, which is how tests, CI, and AI agents check them.
Run without a window
timeout 60 j3c run --frames 120 --screenshot shot.png --headless
This runs the project for 120 frames on SDL’s offscreen video with silent audio, saves the last frame, and prints a J3STATS line with frame timings. The screenshot path is relative to where you ran j3c, not the project.
A built binary has no --headless flag. Set the SDL variables on the same command line instead:
j3c build
SDL_VIDEODRIVER=offscreen SDL_AUDIODRIVER=dummy timeout 20 ./target/mygame
Environment variables
Any program using j3.game or j3.app understands these:
| Variable | Effect |
|---|---|
J3_FRAMES=N | Stop after N frames |
J3_SCREENSHOT=f.png | Save the last frame |
J3_CAPTURE=out.png:N | Save frame N, then quit |
J3_FIXED_DT=0.016 | Fixed frame time, so runs repeat exactly |
J3_STEADY=60:120 | Fail if idle frames do layout, restyle, or allocate |
J3_IDLE_SKIP=1 | Sleep through idle frames (=0 forces it off) |
A .j3ui UI that is still does not redraw: App waits for input, resize, Dom.requestFrame, the caret blink or a 250 ms safety tick. Tests, captures, and the offscreen and dummy video drivers never sleep. J3_IDLE_STATS=1 prints idle: loops=N presented=N at exit. Details: j3c doc j3ui (Idle frames).
Golden images
A golden test compares a screenshot against a saved, known-good image.
j3c golden examples/j2_script # compare
j3c golden examples/j2_script --bless # accept new images
The states to capture are listed in the project’s golden.toml. To compare two images by hand:
j3c -e imgdiff a.png b.png --tol 2 --out diff.png
It exits with 1 when they differ.
Hot reload
j3c run --hot rebuilds and restarts the program when a .j3 or .j3ui file changes. A failed build keeps the old program running. Scripts (.j3s) and .j3ui styles reload without a restart.
More detail: j3c doc testing.
Shipping a Game
Release build
j3c build --release
This writes the small, stripped binary to target/<name> and, if you asked for any, packages to target/dist/.
Packages
List what you want in j3.toml:
[package]
name = "mygame"
version = "0.1.0"
description = "A small arcade game"
[dist]
build_outputs = ["zip", "deb"]
icon = "assets/icon.png"
zip
target/dist/<name>-<os>-<arch>.zip holds a <name>/ folder with the program and everything in [dist] include (by default whichever of assets, scripts, scenes, and locales exist). Windows builds also get the DLLs the program needs.
deb
target/dist/<pkg>_<version>_<arch>.deb, for Linux targets. It is built with tar, so you don’t need dpkg to make one. It installs:
| Path | What |
|---|---|
/opt/<name>/ | The program and its files |
/usr/bin/<name> | A launcher |
/usr/share/applications/<name>.desktop | The menu entry |
/usr/share/icons/... | The icon |
Because /opt is read-only, the launcher runs the game from ~/.local/share/<name>, so relative asset paths still work and saves land in the player’s folder. Package dependencies are worked out from the libraries the program loads.
sudo apt install ./target/dist/mygame_0.1.0_amd64.deb
apk
An Android package for phones and emulators. See j3c doc android.
steam
Steam depot folders and a steamcmd build script. See Shipping on Steam.
Assets and the working directory
Games read assets and write saves relative to the working directory. j3c run uses the package root, and the packages above arrange the same, so "assets/player.png" works everywhere.
Shipping on Steam
j3.steam lets a game use Steam: achievements, stats, leaderboards, Steam Cloud, the overlay, rich presence, the Steam Deck keyboard, Steam Input, lobbies and peer-to-peer networking. It loads Valve’s library when the game starts. If Steam is not there, every call does nothing, so the same binary runs everywhere.
This is the short version. The full guide, kept in step with your j3c, is j3c doc steam. A small program is j3c doc recipes steam.
Try it
use j3.steam;
use j3.steam.Status;
use j3.steam.game.attach;
let st = steam.init(480); // 480 is Valve's Spacewar test app
if st == Status.Restart { sys.exit(0); } // Steam relaunches the game
attach(&app); // pumps Steam each frame, shuts it down at exit
steam.unlock("ACH_WIN_ONE_GAME");
steam.store();
Everything runs on the main thread. In a dev build, J3_STEAM_FAKE=1 fakes Steam and prints each call, so you can test without the client.
Set up
Make a Steamworks partner account and download the SDK. Then in j3.toml:
[steam]
app_id = "480"
sdk = "~/steamworks_sdk"
depot_linux = "481"
depot_windows = "482"
branch = "beta"
j3c run gives the game the app id and the library path, so you do not need a steam_appid.txt. The bindings were written from the public API without the SDK. Run j3c -e steam-check --sdk ~/steamworks_sdk once to compare them with your copy.
Build a depot
[emit]
target = ["x86_64-pc-linux-gnu", "x86_64-pc-windows-gnu"]
[dist]
build_outputs = ["steam"]
j3c build --release writes target/dist/steam/ with a folder per OS and a steamcmd build script. j3c never uploads: you run steamcmd +login <user> +run_app_build <script> +quit yourself. On Linux, today’s builds need glibc 2.34, newer than Steam’s sniper runtime, so either build inside the sniper container (scripts/build-sniper.sh) or ship without sniper. The page j3c doc steam explains both, and lists the Steam Deck and store checks.
What is not covered
Workshop, in-game purchases and macOS depots are not part of j3.steam. The Steam client, the real SDK, a Windows machine and a Deck have not yet been used to test it; plans/01-steam.md lists what to check.
Runtime Checks and Exit Codes
J3 checks for a few mistakes at runtime and stops the program with a clear message instead of corrupting memory.
What is checked
| Check | Example |
|---|---|
| Array and slice bounds | xs[10] on a 4-element array |
| Division by zero | n / 0, n % 0 |
| Interpolated string too long | A `{...}` string past 512 bytes |
A failed check prints file:line: reason. --fast (or [emit] fast = true) removes the bounds-check aborts.
Integer overflow is not an error: it wraps.
Exit codes
| Code | Meaning |
|---|---|
0 | Normal exit |
101 | panic(...) was called |
102 | A runtime check failed |
128 + n | Killed by signal n |
Dev builds on x86-64 Linux install a crash handler that prints a symbolized stack trace.
Compile-time checks
These are caught before the program ever runs:
- Type mismatches, including mixing number types without a conversion
- A
matchthat misses a variant - Import cycles between modules
- Naming an item that isn’t
pub - A
#[singleton]with a non-constant initializer - Writing to a
consttable
Standard Library Map
The standard library is written in J3 and ships inside j3c. This page is a map; for names and signatures, run j3c doc <module>.
Core
| Module | What it’s for |
|---|---|
j3.io | Printing and files |
j3.sys | Process and OS: arguments, environment, panic, which OS (PLATFORM == PLATFORM_WINDOWS, PLATFORM_NAME; which CPU: ARCH == ARCH_AARCH64, ARCH_NAME) |
j3.math | f32 math, Vec2, Vec3, Quat, Mat4 |
j3.rand | Seedable random numbers |
j3.memory | Arena, List<T>, the scratch arena |
j3.text | Building and editing text |
j3.option, j3.result | Option<T> and Result<T, E> |
j3.thread | Threads, pools, queues, mutexes |
j3.build | DEV / RELEASE constants |
j3.proc | Run other programs |
Games
| Module | What it’s for |
|---|---|
j3.app | Game runner: startup, update and draw stages, fixed time step |
j3.game | Window, input, 2D drawing, text, audio |
j3.game.input | Named actions bound to keys and pad buttons |
j3.game.display | Video settings: window mode, resolution, vsync |
j3.game.ecs | Entities, components, commands, events |
j3.game.physics | 2D bodies, and 3D bodies on voxels |
j3.game.collide | AABB and circle tests, tile-grid sliding |
j3.game.space | Grid for broadphase queries |
j3.game.path | A* on a grid |
j3.game.anim | Sprite clips and transform tracks |
j3.game.fx | Timers, tweens, particle bursts |
j3.game.tile | Tile maps |
Graphics
| Module | What it’s for |
|---|---|
j3.gfx | OpenGL 3.3 forward renderer: meshes, lights, shadows |
j3.gfx.paint | Batched 2D painter: shapes, text, clip, transforms |
j3.gfx.canvas | Draw RGBA images in memory, no window; save PNGs |
j3.gfx.mesh, j3.gfx.meshgen | Mesh files and meshes built in code |
j3.gfx.voxel | Voxel grids |
j3.gfx.noise | Tileable noise for textures |
j3.gfx.urp.* | Optional ray-traced reflections, glass, and shadows |
j3.scene3d | Scenes from the editor: load, script, draw |
j3.scene3d.fpwalk | First-person controller |
UI
| Module | What it’s for |
|---|---|
j3.ui | .j3ui components: markup, CSS, events (j3c doc j3ui, j3c doc css; supported HTML/CSS overview: j3ui Feature Support) |
j3.i18n | Translated text with plurals |
Data and network
| Module | What it’s for |
|---|---|
j3.data.save | Simple key/value save files |
j3.data.assets | Load-once asset cache |
j3.data.json | JSON parse and write |
j3.j2 | j2 mod scripts the game loads at runtime (see Mods with j2) |
j3.net | Non-blocking UDP |
j3.net.tcp, j3.net.dns | TCP connections and name lookup |
j3.net.http, j3.net.tls | HTTP/1.1 and HTTPS clients |
j3.steam, j3.steam.game, j3.steam.ui | Steamworks: achievements, cloud, Deck, lobbies; no-op without Steam (j3c doc steam) |
Networking and processes work on Linux and Android.
Keywords, Types, and Attributes
Keywords
| Keyword | Use |
|---|---|
module | Name the file’s module |
use | Import names; embed fields; bring fields into scope |
lib, as | Declare C functions (use lib "x" as y); as also casts |
pub | Export an item |
fn | Function or method |
struct, enum | Types |
const | Module constant |
let | Local variable (always mutable) |
if, else | Branch, or a value |
while, for, in | Loops |
match | Branch on an enum |
return, break, continue | Leave a function or loop |
defer | Run at end of block |
operator | Define an operator method |
this | Method receiver |
Types
| Integers | int (= i64), i8, i16, i32, i64, u8, u16, u32, u64 |
| Floats | float (= f64), f32, f64 |
| Other | bool, void, string, ptr, cstr |
| Arrays | [N]T (fixed), []T (slice) |
| Pointers | &T |
| Functions | fn(A, B): R |
Attributes
| Attribute | Meaning |
|---|---|
#[Debug] | Printable in string interpolation |
#[TryCatch(Ok, Err)] | Enum works with ? |
#[singleton] | Process-wide value |
#[thread] | Per-thread (with #[singleton]) |
#[shader] #[vertex] #[varying] #[uniform] #[flat] | Shader code and data (Shaders) |
Intrinsics
@sizeof, @slice, @syscall, @atomicLoad, @atomicStore, @atomicAdd, @atomicSub, @atomicExchange, @atomicCas, @vmin, @vmax, @vsqrt, @vabs, @vsum, @vshuffle, @vfloat, @vint, @vload, @vstore, @script, @j3ui, @uiChoice, and the shader builtins and @shader (Shaders)
Operators
| Arithmetic | + - * / % |
| Bits (integers) | & | ^ ~ << >> |
| Compare | == != < <= > >= |
| Logic | && || ! |
| Assign | = += -= *= /= %= &= |= ^= |
| Pointers | &x (address), *p (deref) |
| Ranges | a..b, a..=b, xs[a..b], xs[:] |
| Errors | expr? |
Not in J3
No interfaces or inheritance; traits are structural (a type has the methods, no impl blocks). Closures cannot escape (no heap environments): a closure(..) is a parameter or a let. No garbage collector or borrow checker. No null and no exceptions. No mutable globals besides #[singleton].