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.