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

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 .j3s files, 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:

CommandWhat you get
j3c new mygameA console “hello” program
j3c new mygame --appAn SDL window with a game loop
j3c new mygame --template fpsA first-person 3D game with an editor scene
j3c new mygame --template uiA window built from .j3ui components

The everyday loop

CommandDoes
j3c checkParses and type-checks. Fast; produces nothing.
j3c runCompiles and runs the project.
j3c buildWrites the binary to target/<name>.
j3c fmt --writeFormats 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

TypeWhat it is
int64-bit signed integer (same as i64)
float64-bit float (same as f64)
i8 i16 i32 i64Signed integers
u8 u16 u32 u64Unsigned integers
f32 f64Floats. Gameplay math uses f32.
f16Half float, storage only (2 bytes). Convert with f16(x) / f32(h); no arithmetic.
booltrue or false
stringA read-only view of UTF-8 bytes
voidNo value
ptr, cstrRaw 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();
  • f must be visible by its bare name: declared in this module, or imported with use m.{ f }. A name reachable only as m.f does 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() with use j3.math.{ abs }), slices, strings.
  • Generic free functions work too: xs.first() infers T from 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 fn is 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 doc lists these methods under their struct.
  • Not available in .j3s scripts.

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.

AttributeOnEffect
#[Debug]structCan be printed in a `{hole}`
#[TryCatch(Ok, Err)]enumWorks with ? (see Enums and Errors)
#[singleton]letOne value for the whole process
#[thread]let (with #[singleton])One value per thread
#[impl]free fnGives a type you cannot edit a trait’s method (see Traits)
#[shader] #[shader(vertex)] #[shader(fragment)] #[vertex] #[varying] #[uniform] #[flat]fn, struct, field, letGPU 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.

IntrinsicWhat 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 @vstoreSIMD 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 themMemory order: relaxed, acquire, release, acqrel, seqcst (default); load takes no release, store no acquire
@script("path.j3s")The type of a script (see Scripts)
@j3uiImmediate-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 backgroundspawn(&t, name, f, arg), later join(&t)
Run many jobs on a few workersPool.create(&arena, workers, queueCap), then submit / trySubmit
Do the same work over 0..npool.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 loopA 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);
}
}
  • props are what the creator passes in. They are read-only inside the script. A prop with no default is required.
  • shared is one value every instance reads and writes.
  • Top-level let is state that belongs to each instance.
  • Only pub fn can 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 typeScene line
f32, f64param.seconds=1.5
int and sized intsparam.count=3
boolparam.locked=1
stringparam.label="text"
Vec3param.offset.x=0.5 (and .y, .z)
NodeIdparam.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 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.

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

AttributeOnMeaning
#[shader(vertex)] #[shader(fragment)]fnAn 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, structA helper fn, or a plain GPU struct. It exists only on the GPU
#[vertex] #[vertex(instance)]structA 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]structPassed from vertex to fragment. The first field is position: vec4
#[flat]varying fieldNo interpolation (integer fields are always flat)
#[uniform]module letA 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

CommandDoes
j3c new <name>New console project (--app for a window, --template fps or --template ui)
j3c guideHow to start a project
j3c checkType-check without building. --json prints one JSON object.
j3c runBuild 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 buildBuild target/<name>
j3c sizeWhat is inside the binary: sections, code per module, biggest functions

Build flags

These work with run and build, and override j3.toml.

FlagEffect
--releaseSmallest binary: opt s, no debug info, stripped. Checks stay on. build also writes packages.
--fastRemoves bounds-check aborts and debug info
--opt N0, 1, 2, 3, s, or z
--emit llvm|cPick the backend

Running windowed programs headless

FlagEffect
--headlessNo window and no sound (SDL offscreen)
--frames NStop after N frames
--screenshot f.pngSave the last frame
--hotRebuild and restart when a source file changes

See Testing Headless.

Code tools

CommandDoes
j3c fmt [path]Print formatted source. --write rewrites files, --check fails if any would change.
j3c lspLanguage server over stdio
j3c doc ...Stdlib docs, guides, and recipes (see below)

Documentation

CommandShows
j3c docTasks 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 langLanguage summary
j3c doc recipes [name]Complete example programs
j3c doc j3ui, css, testing, ship, threads, androidGuides
j3c help manifestEvery j3.toml key

Looking inside

CommandPrints
j3c dump-irThe checked IR
j3c dump-llvmLLVM IR
j3c dump-cC source
j3c dump-ui <file.j3ui>J3 generated from a UI component
j3c dump-bc <file.j3s>Script bytecode
j3c dump-syntaxA TextMate grammar for editors

Editor and assets

CommandDoes
j3c editor [project]Open the 3D scene editor
j3c -e scene <op>List, get, add, set, delete, or check scene objects
j3c -e renderDraw a scene to a PNG without a window
j3c -e genRun mesh generators in assets/gen/ that changed
j3c -e mcpMCP server, so AI agents can edit and render scenes
j3c golden <dir>Headless golden-image tests
j3c -e imgdiff a.png b.pngPixel 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]

KeyMeaningDefault
nameBinary name; build writes target/<name>required
entryFile containing mainsrc/main.j3
versionPackage version (used for .deb)0.1.0
descriptionOne line for packages and the menu entry

[emit]

KeyMeaningDefault
backendllvm or cllvm
opt0, 1, 2, 3, s, z2
incrementalReuse 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
debugDebug infotrue
fastStrip bounds-check aborts, like --fastfalse
targetA target triple or a list of them; empty is the hosthost
cpuCPU for clang, such as native
clangclang executableclang
flagsExtra clang flags
KeyMeaning
libsLibraries without -l ("SDL2" links -lSDL2)
searchLibrary search directories (-L)
filesExtra object files or archives
flagsExtra linker flags, such as an rpath

[dist]

What j3c build --release packages into target/dist/. See Shipping a Game.

KeyMeaning
build_outputsAny of "zip", "deb", "apk"
includeFiles and folders to ship. Default: whichever of assets, scripts, scenes, locales exist
icon.png (square 256 or 512) or .svg
titleMenu name. Default: the name, title-cased
categories.desktop categories. Default Game;
maintainerName <email>
dependsExtra 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]

KeyMeaningDefault
dirFolder of .j3ui components; they become the module <package>.uiui
themeA global CSS file
lintfalse turns off the CSS lint in j3c checktrue

[i18n]

KeyMeaningDefault
dirFolder of <locale>.txt string tableslocales
defaultLocale shown first, and the one check compares againsten
fallbackLocale used when a key is missingen

Environment variables

VariableEffect
J3_STDLIBUse the stdlib at this path (the parent of j3/) instead of the built-in copy
J3_EDITOR_SRCBuild 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

BackendHow to pick itNotes
LLVMdefaultFull 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

ModeCommandWhat you get
Devj3c run, j3c buildOpt 0, debug info, fast incremental rebuilds. On Linux, a crash prints a stack trace.
Release--releaseOpt s, no debug info, stripped. Bounds checks stay on.
Fast--fastBounds-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:

VariableEffect
J3_FRAMES=NStop after N frames
J3_SCREENSHOT=f.pngSave the last frame
J3_CAPTURE=out.png:NSave frame N, then quit
J3_FIXED_DT=0.016Fixed frame time, so runs repeat exactly
J3_STEADY=60:120Fail if idle frames do layout, restyle, or allocate
J3_IDLE_SKIP=1Sleep 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:

PathWhat
/opt/<name>/The program and its files
/usr/bin/<name>A launcher
/usr/share/applications/<name>.desktopThe 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

CheckExample
Array and slice boundsxs[10] on a 4-element array
Division by zeron / 0, n % 0
Interpolated string too longA `{...}` 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

CodeMeaning
0Normal exit
101panic(...) was called
102A runtime check failed
128 + nKilled 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 match that misses a variant
  • Import cycles between modules
  • Naming an item that isn’t pub
  • A #[singleton] with a non-constant initializer
  • Writing to a const table

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

ModuleWhat it’s for
j3.ioPrinting and files
j3.sysProcess and OS: arguments, environment, panic, which OS (PLATFORM == PLATFORM_WINDOWS, PLATFORM_NAME; which CPU: ARCH == ARCH_AARCH64, ARCH_NAME)
j3.mathf32 math, Vec2, Vec3, Quat, Mat4
j3.randSeedable random numbers
j3.memoryArena, List<T>, the scratch arena
j3.textBuilding and editing text
j3.option, j3.resultOption<T> and Result<T, E>
j3.threadThreads, pools, queues, mutexes
j3.buildDEV / RELEASE constants
j3.procRun other programs

Games

ModuleWhat it’s for
j3.appGame runner: startup, update and draw stages, fixed time step
j3.gameWindow, input, 2D drawing, text, audio
j3.game.inputNamed actions bound to keys and pad buttons
j3.game.displayVideo settings: window mode, resolution, vsync
j3.game.ecsEntities, components, commands, events
j3.game.physics2D bodies, and 3D bodies on voxels
j3.game.collideAABB and circle tests, tile-grid sliding
j3.game.spaceGrid for broadphase queries
j3.game.pathA* on a grid
j3.game.animSprite clips and transform tracks
j3.game.fxTimers, tweens, particle bursts
j3.game.tileTile maps

Graphics

ModuleWhat it’s for
j3.gfxOpenGL 3.3 forward renderer: meshes, lights, shadows
j3.gfx.paintBatched 2D painter: shapes, text, clip, transforms
j3.gfx.canvasDraw RGBA images in memory, no window; save PNGs
j3.gfx.mesh, j3.gfx.meshgenMesh files and meshes built in code
j3.gfx.voxelVoxel grids
j3.gfx.noiseTileable noise for textures
j3.gfx.urp.*Optional ray-traced reflections, glass, and shadows
j3.scene3dScenes from the editor: load, script, draw
j3.scene3d.fpwalkFirst-person controller

UI

ModuleWhat it’s for
j3.ui.j3ui components: markup, CSS, events (j3c doc j3ui, j3c doc css; supported HTML/CSS overview: j3ui Feature Support)
j3.i18nTranslated text with plurals

Data and network

ModuleWhat it’s for
j3.data.saveSimple key/value save files
j3.data.assetsLoad-once asset cache
j3.data.jsonJSON parse and write
j3.j2j2 mod scripts the game loads at runtime (see Mods with j2)
j3.netNon-blocking UDP
j3.net.tcp, j3.net.dnsTCP connections and name lookup
j3.net.http, j3.net.tlsHTTP/1.1 and HTTPS clients
j3.steam, j3.steam.game, j3.steam.uiSteamworks: achievements, cloud, Deck, lobbies; no-op without Steam (j3c doc steam)

Networking and processes work on Linux and Android.

Keywords, Types, and Attributes

Keywords

KeywordUse
moduleName the file’s module
useImport names; embed fields; bring fields into scope
lib, asDeclare C functions (use lib "x" as y); as also casts
pubExport an item
fnFunction or method
struct, enumTypes
constModule constant
letLocal variable (always mutable)
if, elseBranch, or a value
while, for, inLoops
matchBranch on an enum
return, break, continueLeave a function or loop
deferRun at end of block
operatorDefine an operator method
thisMethod receiver

Types

Integersint (= i64), i8, i16, i32, i64, u8, u16, u32, u64
Floatsfloat (= f64), f32, f64
Otherbool, void, string, ptr, cstr
Arrays[N]T (fixed), []T (slice)
Pointers&T
Functionsfn(A, B): R

Attributes

AttributeMeaning
#[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)
Rangesa..b, a..=b, xs[a..b], xs[:]
Errorsexpr?

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].