Wax Language Tour

If you know Java or C#, you can read most Wax code immediately. The important differences are how Wax handles nulls, errors, iteration, state, parallel work, memory-safe views, and communication with its host.

Wax is a statically typed, garbage-collected, object-oriented language. It has classes and value-type structs, interfaces, generics, lambdas, familiar control flow, and collection types. Types are explicit in declarations and can usually be inferred inside a function.

import Debug from Wax;

class Player {

    public string name;
    public int score;

    constructor(string name) {
        this.name = name;
        this.score = 0;
    }

    public fn Award(int points) {
        score += points;
    }

}

api fn Main() {
    var player = new Player("Ada");
    player.Award(10);
    Debug.Log("${player.name}: ${player.score}");
}

The syntax is intentionally unsurprising. Wax spends its novelty budget on the places where ordinary application code becomes difficult to reason about.

The familiar parts

Wax files can contain imports, types, constants, static fields, and functions at the top level. Variables may name their type or use var when the initializer makes it clear. Function parameters come before the return type, and omitting a return type means void. Default parameters, named arguments, string interpolation, and ref and out parameters work much as they do in C#.

const int PassingScore = 70;

fn Grade(int score, bool includeValue = false) : string {
    string result = score >= PassingScore ? "pass" : "retry";

    if (includeValue) {
        return "$result ($score)";
    }

    return result;
}

fn PrintGrades(int[] scores) {
    foreach (scores) |score, index| {
        string label = Grade(.score = score, .includeValue = true);
        Debug.Log("$index: $label");
    }
}

Control flow uses the expected if, else, switch, for, while, do-while, and foreach forms. A switch case has a block, and multiple case values may share it. Arrays use T[]; indexing is bounds-checked.

fn DescribeMode(int mode) : string {
    switch (mode) {
        case 0: {
            return "stopped";
        }
        case 1, 2: {
            return "running";
        }
        default: {
            return "unknown";
        }
    }
}

Classes use reference semantics and support inheritance. Structs are copied by value and do not inherit. Interfaces provide runtime polymorphism for classes. Enums, access modifiers, constructors, virtual methods, overrides, and abstract classes retain their familiar roles.

interface IHasScore {

    get Score() : int;

}

struct Range {

    public int start;
    public int end;

    constructor(int start, int end) {
        this.start = start;
        this.end = end;
    }

}

class Player implements IHasScore {

    public string name;
    private int score = 0;

    constructor(string name) {
        this.name = name;
    }

    // Named constructors are Wax's constructor-overloading mechanism.
    constructor Guest() {
        this.name = "Guest";
    }

    // Wax properties are get/set declarations, not C#-style property blocks.
    public get Score() : int {
        return score;
    }

    public set Score(int value) {
        score = value;
    }

}

fn Identity<T>(T value) : T {
    return value;
}

A struct or class whose shape is its fields can declare them in the header instead, which also declares the constructor that assigns them. A body may follow the header when the type needs more than data.

struct Range(int start, int end);

class Entry(string name, int points = 0) implements IHasScore {
    public get Score() : int {
        return points;
    }
}

fn MakeRange() : Range {
    return new Range(.start = 1, .end = 10);
}

Every field a header declares is public and writable, and header order is both the layout order and the positional-argument order. A compact class can implement an interface, satisfying it from its body, and can extend a base that has a parameterless constructor. A type that must pass arguments to its base, or that needs a field with different visibility, is written out in full.

Lists, dictionaries, sets, stacks, and queues are ordinary generic standard library types. Lambdas, closures, named functions, and function values are first-class. Function types spell out their parameters and return type.

import Dictionary, List from Wax;

fn IsPassing(int score) : bool {
    return score >= PassingScore;
}

fn CollectPassing(int[] scores) : List<int> {
    fn<(int) : bool> include = IsPassing;
    List<int> result = new List<int>();

    foreach (scores) |score| {
        if (include(score)) {
            result.Add(score);
        }
    }

    return result;
}

fn MakeThreshold(int minimum) : fn<(int) : bool> {
    return (value) => {
        return value >= minimum;
    };
}

There is no method overloading: a name resolves to one declaration. Constructors can have named alternatives such as new Player.Guest(), giving a type multiple construction paths without ambiguous signatures. Types and imports are always top-level, so Wax has no nested types. Interfaces are implemented by classes; structs use the generic constraints in the next section for static polymorphism.

One access-control feature is intentionally unusual. Code can opt in to a non-public member by naming its real visibility at the access site, as in player.private.score. The compiler rejects a missing or incorrect visibility name. Ordinary access remains checked in the familiar way, while queries and other deliberate inspection code make each exception visible.

Constraints make generics static

An unconstrained type parameter has no assumed operations. A Wax constraint names the methods, properties, indexers, constructors, callable signatures, or type traits a generic algorithm needs. A where clause makes those requirements available inside the generic function.

constraint Addable is struct {

    static fn Zero() : this;
    fn Add(this other) : this;

}

fn Sum<T>(T[] values) : T where T is Addable {
    T total = T.Zero();

    foreach (values) |value| {
        total = total.Add(value);
    }

    return total;
}

fn StorageKind<T>() : string {
    static if (T is struct) {
        return "value";
    } else {
        return "reference";
    }
}

A type satisfies a constraint when its public shape matches. It does not need to inherit from anything, and an explicit implements is optional. The compiler checks each concrete use and emits direct calls for that type, with no boxing, interface object, or virtual dispatch. This is how the same generic algorithm works over both classes and value-type structs without adding runtime overhead.

static if can branch on facts about a type parameter. The compiler evaluates the condition for each concrete type and includes only the selected branch, so one generic algorithm can choose a type-specific implementation without a runtime test.

Interfaces remain the separate tool for runtime polymorphism and heterogeneous collections of class instances. Constraints are for compile-time polymorphism, including cases where only one method on a generic type needs the extra capability.

Nulls must be handled

Non-null is the default. A value that may be absent has an explicit nullable type such as string?. Wax will not implicitly turn that into a string.

fn FindName(int id) : string? {
    return id == 7 ? "Ada" : null;
}

fn ShowName() {
    if (FindName(7)) |name| {
        Debug.Log(name);
    }

    guard (FindName(7)) |name| else return;
    Debug.Log(name);

    string display = FindName(9) ?? "Unknown";
    string required = FindName(9)!;
}

Payload syntax binds a proven non-null value for the successful branch. guard does the same but lets the binding continue in the enclosing scope; its failure path must leave that scope. ?? supplies a default or can bail with throw, panic, or return. The force operator ! is available when a null value means the program cannot continue, and panics if the value is null.

Recoverable errors are declared

A throwing function declares its recoverable errors and can name their type. try is an expression, catches may narrow by error type, and a catch body uses yield to produce the expression's value. An uncaught error propagates only when the calling function has a compatible throws declaration.

error FileError {

    public string path;

    constructor(string path) : base("Cannot read $path") {
        this.path = path;
    }

}

fn ReadConfig() : string throws FileError {
    throw new FileError("settings.json");
}

fn ConfigName() : string {
    return try ReadConfig() catch FileError |err| {
        yield "Missing ${err.path}";
    };
}

panic is different: it reports an unrecoverable program error, is not catchable, and terminates the app. Recoverable failures remain visible in function signatures; programmer errors cannot be accidentally swallowed.

JSON is dynamic but safe by default

json is a first-class type for loosely structured data. Object keys may be unquoted, objects and arrays can be spread into new values, and access through a missing property or index produces JSON null instead of failing midway through a path. Converting back to a static type makes the check explicit.

json defaults = {
    theme: "dark",
    retries: 3,
};

json settings = {
    ...defaults,
    account: {
        name: "Ada",
    },
};

string? name = settings.account.name;
string? city = settings.account.address.city;
int retries = settings.retries as! int;

Assignment to a nullable primitive returns null when the value is absent or has the wrong type. as? performs the same kind of checked conversion explicitly; as! requires the expected type and panics if it does not match. JSON remains a deliberate dynamic island rather than weakening static typing elsewhere.

Iteration is a pipeline

foreach handles direct iteration. For transformation, Wax uses eager-looking pipeline syntax backed by fused iteration. A chain remains lazy until a sink such as ToList, Count, or Fold consumes it.

import List from Wax;

fn ShowPipeline(int[] values) {
    List<int> topEvenSquares = values
        ->Filter(|value| => value % 2 == 0)
        ->Map(|value| => value * value)
        ->Take(10)
        ->ToList();

    foreach (topEvenSquares) |value, index| {
        Debug.Log("$index: $value");
    }
}

Pipelines cover filtering, mapping, flattening, zipping, grouping, sorting, windowing, aggregation, and collection. Stages such as Filter, Map, and Take fuse into one pass: each source element moves through the chain before the next element is read. They do not run one loop per stage or allocate intermediate collections. Operations that inherently need retained data, such as sorting or grouping, buffer only what their behavior requires. Ordering rules are specified so the same source produces the same sequence.

Borrowed views stay scoped

Wax has bounds-checked Span<T> and ReadOnlySpan<T> views for working with contiguous storage without copying it. A span borrows its storage and cannot be put in a heap object, array, static, or closure, or returned after its source would be gone. stackalloc creates the same kind of scoped view over temporary stack storage.

import Span, WriteOnlySpan from Wax;

fn Prepare(Span<int32> values) {
    var scratch = new stackalloc int32[128];
    scoped Span<int32> window = values.Slice(4, 8);

    lateinit string[] names = new lateinit string[2];
    names[0] = "Ada";
    names[1] = "Grace";
    string[] ready = names.AsArray();
}

api fn RenderPreview(WriteOnlySpan<uint8> pixels) {
    for (int i = 0; i < pixels.length; i++) {
        pixels[i] = RenderPixel(i);
    }
}

lateinit T[] handles the other awkward case: building a non-null array one slot at a time. Reads check that a slot has been assigned, and AsArray() verifies that the whole array is full before exposing it as an ordinary dense T[]. This avoids using nullable elements as temporary placeholders.

WriteOnlySpan<T> is a scoped output view with indexed writes but no reads. An api fn can fill a destination supplied by the host, which is useful for large pixel, audio, or data buffers. Live execution returns those writes to the host; a recording stores the span length but omits the output values. During replay, Wax writes into temporary storage and discards it, avoiding a large recorded payload without changing any other state updates made by the call.

Vectors are built-in value types

Wax includes domain value types for graphics, math, and simulation. Geometry vectors such as float2, float3, and float4 have component arithmetic and swizzles. Matrices, quaternions, colors, angles, durations, and monotonic time points are distinct types rather than conventions built on ordinary numbers.

float3 position = new float3(1.0f, 2.0f, 3.0f);
float3 velocity = new float3(0.5f, 0.0f, -1.0f);
float3 next = position + velocity * delta;

angle quarterTurn = 90.degrees;
timespan timeout = 250.milliseconds;
color8 accent = new color8(255, 128, 0, 255);

Width-explicit SIMD types are a separate family. Names such as int32x4, float32x4, and int8x16 state both the lane type and count. They support lane-wise arithmetic, comparisons and masks, reductions, and typed loads and stores through spans.

int32x4 values = new int32x4(10, 20, 30, 40);
int32x4 doubled = values * int32x4.Splat(2);
int total = doubled.ReduceSum();

Both families are copied and stored as values. SIMD lane access is bounds-checked, and the compiler maps the 128-bit SIMD family to SSE, NEON, or Wasm v128 operations.

Local state can have a lifecycle

state gives a function or lambda persistent local values without moving every piece of state into an owning class. A state slot is initialized on first entry and reused on later calls at the same structural location. state.create runs when that location first becomes active. state.destroy runs when it is no longer active.

state fn Counter() {
    state int count = 0;

    state.create => {
        Debug.Log("Counter created");
    }

    state.destroy => {
        Debug.Log("Counter destroyed");
    }

    if (Button("Increment")) {
        count++;
    }

    Label("Count: $count");
}

state fn Rows(Item[] items) {
    state foreach (items) |item| key item.id {
        state bool expanded = false;
        DrawRow(item, ref expanded);
    }
}

Keys preserve the identity of repeated stateful elements when their order changes. Wax runs state.destroy at the next safe opportunity. For active state, that is when its path is next not taken, usually near the start of the next frame. Like a C++ scope destructor, cleanup fires if the scope is not taken. Root state becomes ready for cleanup when its stateful lambda dies, and its destruction runs at the start of a later frame.

The feature is useful for UI components, simulations, and state machines. It applies anywhere local logic needs memory across repeated entry.

Parallel work is fork and join

Wax supports synchronous fork-and-join data parallelism. parallel runs one kernel over a range, potentially across many workers, and joins all work before the next statement. The system chooses the workers, divides the range into chunks, and schedules those chunks. Wax code cannot observe those choices.

Every input and output is declared, and bounds and writable overlap are checked before any iteration starts. The body is normal Wax code with a small set of restrictions. If the range or resource access could race, the kernel does not start. Data races inside parallel are prevented by design, rather than left for the programmer to reproduce and debug.

parallel (0, destination.size) : (
    readonly int value from source,
    out int result from destination,
) |index| {
    result = value * scale + index;
}

The kernel cannot allocate, touch app-global state, perform dynamic dispatch, call the host, or publish reference-bearing output. Those rules keep iterations independent. Worker count, chunk size, scheduling order, and completion order can change speed, but they cannot change anything the Wax program observes. You write the operation once and get race-free parallel execution without managing threads, locks, worker pools, or work queues.

Access to the outside world is declared

Wax code does not discover arbitrary native functions at runtime. The available host services and the functions exposed back to the host are declarations in the program.

host fn Log(string message);
host fn LoadProfile(int userId) : json throws;
host? fn TrackEvent(string name, json properties);

api fn Greeting(int userId) : string throws {
    json profile = try LoadProfile(userId);
    TrackEvent?.("profile_loaded", { id: userId });
    return "Hello, ${profile.name as! string}";
}

host fn describes a service supplied by the embedding application. api fn describes an entry point the host may call. Optional host functions use host?. Host marshalling supports primitives, strings, JSON, opaque handles, enums, built-in value types, compatible structs, and arrays of admitted types. Application classes cannot cross into or out of Wax.

Channels deliver frame input

Channels are the host-to-Wax stream for values that arrive each frame. The host stages updates during one frame, and Wax sees them together at the start of the next. Values do not change midway through Wax code, so every read during a frame sees the same input.

host channel Simulation {

    float speed;
    event int commands;

}

host channel? Viewport {

    float2 size;
    float scale;

}

fn Tick() {
    float speed = Simulation.speed;

    foreach (Simulation.commands) |command| {
        Apply(command);
    }

    if (Viewport) |viewport| {
        Resize(viewport.size, viewport.scale);
    }
}

A value field acts like a register. The most recently staged value persists until the host replaces it. An event field is a read-only batch containing only that frame's events, and is empty again on the following frame unless the host stages more. An optional host channel? is either present as one complete block or absent, and payload syntax unwraps it.

The reverse direction is an api channel. Wax writes its registers during a frame, and the host reads owned copies once the frame has ended.

import List from Wax;

struct Sample { public int32 id; public string label; }

api channel Telemetry {

    int32 count;
    List<int32> samples;
    event Sample readings;
    json summary;

}

fn Tick() {
    Telemetry.count = 42;
    Telemetry.samples.Add(3);
    Telemetry.readings.Add(new Sample { .id = 7, .label = "one" });
    Telemetry.summary = { seq: 1, items: [] };
    Telemetry.summary.GetProperty("items").Push({ id: 7 });
}

A value register holds its last store until the next one, and Telemetry.GetChanges() reports which value registers were stored this frame. A List<T> register is the app's own list, kept across frames; an event batch is a fresh empty list every frame. A json register is a document the app edits in place, stringified once when the frame closes; the host reads that text as its json value. Lists, batches, and documents carry no change bit: the host reads their contents instead.

Wax execution is synchronous within a frame and has no async functions. The host owns asynchronous I/O and stages completed results for Wax to consume on a later frame. This keeps suspension and host scheduling out of application code.

Channels are top-level singletons. Wax code reads them but cannot construct, assign, or retain their frame-scoped event views. Channel input is included in recordings and restored during replay without application code doing extra work.

For passwords and API keys, secret text input takes a narrower path. The characters stay outside serialized Wax state behind an opaque handle, and are omitted from snapshots and recordings. A recording can still contain the field's length, editing actions, and timing, but not the secret characters themselves. The host sees the original input and remains responsible for not copying or logging it elsewhere.

Start the interactive tutorial →

Read the language specification