Skip to content

Features ​

ThicketScript reads like TypeScript, then gives you the things games need that TypeScript never had. This page is a tour of those things. Every sample runs as it is with thicket run, and the block under it is the real output.

Structs with operators ​

A struct is a value, not an object. Copying one makes a new one, so a position you hand to a function can't be changed behind your back. Structs carry their own methods, and operator methods let +, * and friends work on them.

ts
struct Vec2 {
    x: float;
    y: float;

    operator add(other: Vec2): Vec2 {
        return Vec2{ x: this.x + other.x, y: this.y + other.y };
    }

    operator mul(k: float): Vec2 {
        return Vec2{ x: this.x * k, y: this.y * k };
    }

    length(): float {
        return Math.sqrt(this.x * this.x + this.y * this.y);
    }
}

let pos: Vec2 = Vec2{ x: 3.0, y: 4.0 };
const step: Vec2 = Vec2{ x: 1.0, y: 0.5 };
pos = pos + step * 2.0;
console.log(pos, pos.length());

let copy: Vec2 = pos;   // a value: copying makes a new one
copy.x = 0.0;
console.log(pos.x, copy.x);
text
Vec2 { x: 5, y: 5 } 7.0710678118654755
5 0

Packed numbers ​

Struct fields can use packed types: int8, int16, int32, uint8, uint16, uint32 and float32. A color with four uint8 fields is four bytes. A value that doesn't fit raises a RangeError instead of wrapping around, so a bad write shows up where it happens. A literal that can't fit, such as gold.r = 300, is caught at compile time.

ts
struct Rgba {
    r: uint8;
    g: uint8;
    b: uint8;
    a: uint8;
}

let gold: Rgba = Rgba{ r: 242, g: 184, b: 75, a: 255 };
console.log(gold);
let brighter: uint = gold.r + 58;
try {
    gold.r = brighter as uint8; // as is a checked narrowing
} catch (e) {
    console.log(e);
}
text
Rgba { r: 242, g: 184, b: 75, a: 255 }
RangeError: value 300 out of range for uint8 (0 to 255)

Packed types live in struct fields, FixedArray and ByteArray. Everywhere else you use int, uint and float, and the checker tells you so if you reach for int8 in a plain variable.

series and parallel ​

Loading a level is a sequence with a few things at once in the middle. series awaits every async call for you. parallel starts every call in the block at once and waits for the last one. Use them as function modifiers or as blocks, and nest them however the work is shaped.

ts
async function load(name: string, ms: int): Promise<string> {
    await sleep(ms);
    console.log(`loaded ${name}`);
    return name;
}

series function startLevel(): void {
    const map: string = load("map", 5);   // awaited for you
    parallel {                            // all at once
        load("music", 30);
        load("sprites", 10);
    }
    console.log(`${map} is ready`);
}

await startLevel();
text
loaded map
loaded sprites
loaded music
map is ready

More in Async, series and parallel.

defer ​

defer runs a statement when the scope exits, however it exits: a return, a throw, a break or the end of the block. Several defers run in reverse order, so cleanup unwinds the same way setup went in.

ts
function openLevel(name: string): void {
    console.log(`open ${name}`);
    defer console.log(`close ${name}`);
    defer console.log("release lock");

    if (name == "broken") {
        throw new Error("bad level data");
    }
    console.log(`play ${name}`);
}

openLevel("forest");
try {
    openLevel("broken");
} catch (e) {
    console.log(`caught: ${e.message}`);
}
text
open forest
play forest
release lock
close forest
open broken
release lock
close broken
caught: bad level data

dump() ​

dump() prints each value next to its variable name. The names are captured when your code compiles, so there are no labels to type and none to fall out of date. A packed float32 struct field prints at single precision, so a field stored from -1.2 shows as -1.2, not -1.2000000476837158.

ts
struct Vec2 { x: float; y: float; }

const velocity: Vec2 = Vec2{ x: 3.5, y: -1.25 };
const hp: int = 42;
const hero: string = "Ayla";
dump(hero, hp, velocity);
text
hero = Ayla
hp = 42
velocity = Vec2 { x: 3.5, y: -1.25 }

Workers: real threads, one keyword ​

Mark a function async worker and every call runs on its own thread, in its own isolated VM. Arguments and results are copied across for you. There is no shared state, so there is nothing to lock. Workers are for pure computation: pathfinding, procedural generation, crunching numbers. File, network and other I/O modules stay with the caller, which passes the data in and acts on the result. Using an imported I/O module inside a worker is a check-time error, so the mistake shows up in your editor, not at run time.

ts
async worker function countPrimes(limit: int): Promise<int> {
    let count: int = 0;
    for (let n: int = 2; n < limit; n++) {
        let prime: boolean = true;
        for (let d: int = 2; d * d <= n; d++) {
            if (n % d == 0) {
                prime = false;
                break;
            }
        }
        if (prime) {
            count++;
        }
    }
    return count;
}

// Three threads, each in its own VM. The main loop keeps running.
const counts: Array<int> = await Promise.all([
    countPrimes(10000), countPrimes(100000), countPrimes(200000),
]);
console.log(counts);
text
[1229, 9592, 17984]

Mutex functions ​

An async mutex function runs one call at a time. Extra calls queue up behind the running one. Put $ in front of a parameter to make it the lock key: calls with the same key wait their turn, and calls with different keys run side by side. The $ is only in the declaration. Inside the body you use the bare name, and if you write $who there by habit, the checker tells you the name is who.

ts
const balances: Map<string, int> = new Map([["ayla", 100], ["brin", 20]]);

async mutex function spend($who: string, amount: int): Promise<void> {
    const before: int = balances.get(who) ?? 0;
    await sleep(5);                       // a save, a network call...
    balances.set(who, before - amount);
    console.log(`${who}: ${before} -> ${before - amount}`);
}

// Same key: queued one after another. Different key: runs alongside.
await Promise.all([spend("ayla", 30), spend("ayla", 30), spend("brin", 5)]);
console.log(balances.get("ayla"));
text
ayla: 100 -> 70
brin: 20 -> 15
ayla: 70 -> 40
40

Without the mutex, both ayla calls would read 100 before either wrote, and one spend would vanish.

Easing, built in ​

Ease comes with 41 curves: linear, plus in, out, in-out and out-in forms of quad, cubic, quart, quint, sine, expo, circ, back, elastic and bounce. Ease.apply(curve, t) shapes a 0 to 1 progress value, and Math.lerp turns it into a position, a size or an alpha.

ts
const t: float = 0.5;
console.log(Math.lerp(0.0, 200.0, Ease.apply(Ease.OUT_CUBIC, t)));
console.log(Math.lerp(0.0, 200.0, Ease.apply(Ease.OUT_ELASTIC, 0.25)));
console.log(Ease.apply(Ease.IN_OUT_SINE, 0.5), Ease.apply(Ease.OUT_BOUNCE, 1.0));
text
175
182.32232332229614
0.5 1

Math also has the frame-friendly helpers games reach for: moveTowards, snap, wrap, smoothstep and more. See the Ease and Math references.

Reactive state ​

Ref holds a value and tells listeners when it changes. computed derives a value from other refs and keeps it current. Both come from the reactivity module.

Writes take effect at once, and listeners fire on the next frame. Several writes in one frame reach each listener as a single call, carrying the first old value and the last new one. A hit and a poison tick in the same frame update the health bar once.

ts
import { Ref, computed } from "reactivity";

const hp: Ref<int> = new Ref(100);
const maxHp: Ref<int> = new Ref(100);
const pct: Ref<float> = computed((): float => hp.value / (maxHp.value as float));

hp.on((now: int, before: int): void => {
    console.log(`listener: hp ${before} -> ${now}`);
});

hp.value -= 25;   // a hit
hp.value -= 5;    // and poison, in the same frame
console.log(`value now ${hp.value}, ${pct.value * 100.0}%`);

await sleep(20);  // let a frame pass: one listener call for both writes
text
value now 70, 70%
listener: hp 100 -> 70

Signal, watchEffect and reactive arrays and records live in the same module. See the reactivity reference.

And more ​

Bitflag enums, typed catch, inline try, seeded random, collection helpers like unique and sum, image decoding, compression and databases are all in the box. The feature checklist lists them in one place, and Coming from TypeScript covers what's different. See how it compares with Adobe AIR, Unity, Godot and Haxe.