bongle#

Read this guide top to bottom to learn the engine and its API, with examples and guidance. Reach for the API reference for the exhaustive signature list.

This guide is also served as plain markdown at bongle.io/docs.md, handy for reading offline or feeding to an LLM.

What is bongle#

bongle is a multiplayer voxel game engine built for the web. It powers bongle.io and is free, open-source software.

At its core is a programmable voxel world: terrain made of blocks you can shape, break, and rebuild while the game runs, with a scene of nodes living inside it that you bring to life through systems. Games are multiplayer by default, authoritative on the server and rendered in every player's browser. The engine gives you:

  • a built-in editor with client and server hot-module-reload
  • an asset pipeline for blocks, textures, models, sounds, and sprites
  • voxel editing with WorldEdit-style patterns and masks
  • an opinionated voxel world, with APIs that leave broad creative freedom within it
  • client-server multiplayer with distributed entity authority
  • one-click share, to edit a world alongside anyone, anywhere

The chapters build this up from zero: scaffold a project, then nodes, traits, and systems; the multiplayer model; then rendering, physics, voxels, and the rest.

Project structure#

The pieces you work with:

my-game/
├── src/
│   ├── index.ts        your game code (the entry point the engine loads)
│   └── generated/      generated code written by the editor (do not edit! changes will be wiped away!)
├── assets/             put your source files here: glTF, textures, audio, sprites
├── content/            editor-authored data (.scene.json)
├── dist/               build output: bundle.zip, from `bongle build`
├── package.json        the `bongle` dependency and scripts
└── tsconfig.json       typescript config, you probably don't need to touch this
  • src/index.ts is where your code lives, the entry the engine loads. Split it into more files and import them as the game grows.
  • src/generated/ is written for you, not by hand. The asset pipeline scans assets/ and content/ and regenerates typed handles (models.ts, sounds.ts, scenes.ts) so model('id') and friends resolve and type-check. Never edit these; every build and editor session overwrites them.
  • assets/ holds the raw files you reference: a .gltf for model(), a .png for texture(), an .ogg for sound(). Point a declaration's src at one with asset('./assets/...', import.meta.url).
  • content/ holds what you author in the editor, scenes saved as .scene.json. The editor regenerates src/generated/scenes.ts so code references them by name.
  • dist/ is the output of bongle build: a self-contained bundle.zip of client, server, and content, ready to serve or deploy.

Commit src/index.ts, assets/, content/, and the config files. The generated src/generated/, the pipeline's intermediate resources/, dist/, and node_modules/ are all regenerated, and the scaffold gitignores them.

Your first systems#

First, register content and size the room:

// register the kit block set so those blocks exist and show up in the editor
use(blocks);

// cap how many players matchmaking puts in one room
config({ server: { maxPlayers: 32 } });

use(blocks) makes the kit's blocks part of your game, so they appear in the editor palette and in the published game. What your own code declares is part of it already. Kit and vendored packages are libraries: nothing in them is included until your code uses it, and whatever it needs (a block's tiles and sounds) comes with it. Use one at a time, use(blocks.stone, blocks.dirt), or a whole set, as here. config({ server: { maxPlayers: 32 } }) sets how many players matchmaking puts in one room.

Server tick rate#

config({ server: { tickRate } }) sets how often the server simulates, an integer between 15 and 60, defaulting to 60. A game that does not need 60 halves the CPU its rooms cost, which is what decides how many rooms fit on a machine.

The client always simulates at 60 regardless. Owner-authority motion (the local character above all) is stepped only on its owner's client, so a cheaper server cadence does not make your own character feel coarser. What it does affect:

  • server-owned characters, such as an NPC with a CharacterController.Trait owned by the server, step at tickRate and respond that much less often.
  • server-owned dynamic rigid bodies integrate at tickRate: stacking is less stable and fast bodies are likelier to tunnel.
  • bodies with prediction, which are simulated on both sides at different rates and so need more correction.
  • rate.hz(n) above the tick rate silently becomes the tick rate. At tickRate: 30, rate.hz(20) still sends 20 times a second but rate.hz(60) sends 30.

Voxel streaming budgets are expressed per second, so a slower room streams the world in at the same speed.

Next, a system that sets up the sky and sun:

// sky + a late-morning sun. { editor: true } runs this in the editor too, so
// the world is lit while you build it, not only at play time.
system(
    'environment',
    (ctx) => {
        onInit(ctx, () => {
            setEnvironment(ctx, ENVIRONMENT_OVERWORLD);
            setEnvironmentTime(ctx, 9);
        });
    },
    { editor: true },
);

Game logic lives in systems. system('environment', factory, opts) declares one, and its factory runs once per scene. Systems act on the scene by querying for the nodes that carry the traits they care about and updating that data each tick, covered in the programming model. Inside, onInit registers a one-time setup callback that calls setEnvironment and setEnvironmentTime to choose a preset sky and a 9am sun. The { editor: true } option runs it in the editor as well as at play time, so the world is lit while you build it.

Finally, place players as they join:

// place each joining player. server-authoritative, so it only runs there.
system('spawn', (ctx) => {
    if (!env.server) return;

    onJoin(ctx, ({ playerNode }) => {
        const transform = getTrait(playerNode, Transform.Trait)!;
        Transform.setPosition(transform, [0, 5, 0]);

        // face the new player at a point of interest. setLookAt aims through
        // the character's eyes, setting its look yaw and pitch; the player controller
        // reads them, so the client's camera starts pointed that way.
        const controller = getTrait(playerNode, CharacterController.Trait)!;
        CharacterController.setLookAt(controller, [10, 5, 0]);
    });
});

The server places players, so it returns early anywhere else (the multiplayer model covers why). onJoin fires once per client that joins the room and hands you that client's playerNode. We read its Transform.Trait with getTrait and call Transform.setPosition to drop the player at [0, 5, 0], then face them toward a point of interest. The player node also carries a CharacterController.Trait, and CharacterController.setLookAt(controller, target) aims it at a world position, computing the look yaw and pitch through the character's eyes. The player controller reads those angles, so the client's camera starts pointed that way. (For a raw yaw and pitch, CharacterController.setLook(controller, yaw, pitch?) writes them directly.)

That is the whole kit: register blocks, size the room, light the world, spawn players. The rest of this guide unpacks the pieces it leans on, starting with the concepts behind the world model and then the programming model for nodes, traits, and systems in depth.

Concepts#

A world is a voxel grid plus a scene of objects living in it.

Voxels#

The world is a 3D grid of blocks, like Minecraft. The terrain, buildings, and anything you can stand on or break is voxels, and the grid can change while the game runs.

Block types: every cell holds a block type. Stone, a door, or one you define yourself, each with its own look and collision.

Chunks: the grid is divided into fixed-size chunks, so changing one part of the world only has to update that chunk, not the whole thing.

Scene#

Everything that isn't a block lives in the scene: a tree of nodes.

Nodes: a single object in the tree. A node on its own does almost nothing; what it is comes from its traits.

Traits: the building blocks of a node, as plain data: a transform gives a node a position, a rigid body gives it physics, a sprite makes it draw. A node is just the traits it carries.

Systems: your game logic. A system queries for the nodes carrying a set of traits and reads and writes their data on lifecycle hooks, so one system drives every entity it matches: spawning, scoring, AI, movement.

The multiplayer model#

bongle is multiplayer by default. A running game is a server that simulates a room (one instance of the world) plus one client per connected player. Your src/index.ts runs on both sides, and env.server / env.client are build-time booleans, so a guard like if (!env.server) return compiles the code it protects out of the client bundle entirely. (env.editor does the same for editor-only code.)

The server is authoritative: it owns the simulation, and a system can run on the server, the client, or both. Players join a room and each gets a playerNode; the server-only onJoin and onLeave hooks fire as they come and go.

Most state reaches clients without any networking code. A trait declares which of its fields replicate with sync (covered in Replication and authority): set a value on the server and clients receive it, on every change by default, or capped with rate: rate.hz(n) for a field that changes faster than anyone needs to see. When you need to send a discrete message instead of replicating state, reach for RPC, covered in Multiplayer.

The programming model#

A bongle game is built from three things: nodes, traits, and systems. Nodes form the scene tree, traits are the data a node carries, and systems are the behaviour: they query for nodes by their traits and act on that data. This chapter covers all three, then how code splits across the client and server.

Every system runs with a ctx, its ScriptContext: the handle it reaches everything through, from queries and the room's world (ctx.voxels, ctx.physics, ctx.clock) to the lifecycle hooks below. ctx is scoped to one room, and that matters from the start: a server runs many rooms at once, each its own independent world, and your system runs once in each room. So per-room state belongs on ctx-reachable things, a trait or the system's own factory scope, never in a module-scope variable, which every room in the process would share. Rooms get a fuller treatment in Multiplayer.

Nodes and the scene tree#

A node is one object in the scene tree. On its own it carries almost nothing; what it can do comes from the traits you add. createNode returns a detached node, addTrait gives it a capability, addChild attaches it under a parent so it goes live, and destroyNode removes a node and its subtree.

// build a small subtree: a turret with a barrel child
const turret = createNode({ name: 'turret' });
addTrait(turret, Transform.Trait);

const barrel = createNode({ name: 'barrel' });
addTrait(barrel, Transform.Trait);
addChild(turret, barrel); // barrel is now a live child of turret

// find a descendant by name, then detach the whole subtree from the scene
const found = findByName(turret, 'barrel');
if (found) destroyNode(found);

addTrait(node, Trait) returns the new trait instance. getTrait(node, Trait) reads it back later (or null if absent), and hasTrait tests presence. findByName runs a depth-first search from a node for the first descendant with a given name.

Every node has a realm that decides which sides it lives on. By default a node inherits its parent's realm, which resolves to 'shared' under the scene root: a shared node exists on the server and every client, with the server authoritative and its state replicated out to clients. The other realms never replicate: realm: 'server' lives only on the server, realm: 'client' only on the client that created it, and realm: 'each' gives the server and every client their own independent copy. Realm decides where a node exists; replication and authority decides what crosses the wire and who may write it.

Transforms#

Every node with a Transform.Trait has a position, rotation, and scale. You write local-space values with setters and read world-space values with getters. Setters propagate a dirty flag down the subtree; getters lazily recompute only when something upstream changed, so reading is cheap when nothing moved.

The local setters are Transform.setPosition, Transform.setQuaternion, and Transform.setScale, or Transform.setTransform to write all three at once:

/** set local position and mark dirty. only the position slice replicates. */
export function setPosition(transform: Trait, position: Vec3): void;

The world getters read where a node actually ended up after its parents' transforms apply: Transform.getWorldPosition, Transform.getWorldQuaternion, Transform.getWorldScale, and Transform.getWorldMatrix.

/** world-space position, read from the matrix translation; leaves the TRS decompose deferred. */
export function getWorldPosition(transform: Transform.Trait): Vec3;

To place a node at an absolute world position or orientation regardless of its parent, write through Transform.setWorldPosition and Transform.setWorldQuaternion. And for rendering, the Transform.getVisualWorld* family (Transform.getVisualWorldPosition and friends) reads the interpolated transform rather than the logic one, which is what camera work and other onFrame code should read (see Ticks, frames, and interpolation).

In practice you add a Transform.Trait to a node, set its local position, then read back where it lands in world space:

// give a node a transform, then position it in local space
const crate = createNode({ name: 'crate' });
const transform = addTrait(crate, Transform.Trait);
Transform.setPosition(transform, [4, 1, -2]);

// read where it ended up in world space (after any parent transforms apply)
const worldPos = Transform.getWorldPosition(transform);
console.log(worldPos);

See the API reference for the full set of transform setters and getters.

Traits#

If you have used an entity-component system, a trait is bongle's version of a component: the node is the entity, and you compose its capabilities by adding traits rather than subclassing.

A trait is named state, plus the replication and editor controls you attach to it. The engine ships builtin traits (Transform.Trait, Camera.Trait, RigidBody.Trait, and more), and you define your own with trait(id, body). The body is a plain object of fields; each value is either a literal default or a factory () => value called once per instance. A system gives the data behaviour (systems and queries come next):

// a trait is named state. fields are literals or factories (use a factory for
// any mutable default, such as a vector or array).
const HealthTrait = trait('health', {
    current: 100,
    max: 100,
});

// a system gives it behaviour: query every node carrying the trait and step them all.
system('regen', (ctx) => {
    const healths = query(ctx, [HealthTrait]);

    onTick(ctx, ({ step }) => {
        for (const [health] of healths) {
            health.current = Math.min(health.max, health.current + 5 * step);
        }
    });
});

Two registrars extend a trait. control exposes a field to the editor inspector and saves it in scene files; sync replicates a field across the network. Each takes a schema, from one of two vocabularies that describe different things. A prop schema describes an authored value: what the inspector shows, what a scene file holds, what counts as valid. A pack schema describes bytes on the wire, with explicit sizes and compression. Controls reach the network too, when a node is sent to a client, through a lossless lowering of their prop schema you never have to write.

control#

/** register a control on a trait, callable multiple times per trait; `controlId` is the persisted key in scene files. */
export function control<T extends TraitBase, V>(handle: TraitHandle<T>, controlId: string, body: ControlBody<T, V>): void;

sync#

/** register a sync on a trait, callable multiple times per trait; returns a SyncHandle for producer-side dirty hints. */
export function sync<T extends TraitBase, S>(handle: TraitHandle<T>, syncId: string, body: SyncBody<T, S>): SyncHandle<T>;

The prop builders cover the field types the inspector can edit:

BuilderField
prop.boolean(), prop.string(), prop.number({ min, max, step })a checkbox, text, or number input; a number with both ends set is a slider you can drag or type into
prop.number({ min: 0, softMax: 1 })a slider over 0..1 that still takes typed values past 1; softMin/softMax set the slider's ends without limiting the value
prop.radius(), prop.angle()a length that is never negative, an angle in radians
prop.keyCode()a key, edited by pressing it
prop.vec2(), prop.vec3(), prop.vec4(), prop.quaternion()vector and rotation inputs
prop.point(), prop.direction()a position or a unit vector in the node's frame
prop.rgb(), prop.rgba()a colour swatch and hex field, channels 0..1
prop.lightLevel()a light's red, green and blue as block light levels, 0 to 15
prop.enumeration([...]), prop.literal(...)a dropdown of fixed choices (each a value or { label, value }), or one fixed value
prop.list(of), prop.tuple([...])a variable-length or fixed array
prop.object({ ... }), prop.record(of), prop.union(key, [...])a nested struct, keyed map, or tagged variant
prop.optional(of), prop.nullable(of), prop.nullish(of)wrap any of the above as maybe-absent
prop.mesh(), prop.prefab(), prop.block(), prop.sprite()an asset reference picker
prop.node()another node, picked from the scene: the field holds its NodeId

A field that points at another node holds that node's id, and the control that edits it uses prop.node(). Ids only mean something while the game runs, so wherever the control's value is saved, loaded or copied, the reference is rewritten to match: copy a constraint together with the bodies it joins (cloneNodes, a pasted selection, a prefab's scene) and the copy joins the copied bodies, while a reference to a node outside what's copied is kept. One saved without the node it points at is saved as none. Find the node with getNodeById(ctx.scene, id), which gives undefined while the node isn't there: not yet arrived on a client, out of its range, or deleted.

The pack builders are for when the bytes matter:

BuilderWire type
pack.boolean(), pack.string()a boolean, a length-prefixed string
pack.uint8() … pack.uint64(), pack.int8() … pack.int64()sized integers (the 64-bit ones are bigint)
pack.varuint(), pack.varint()variable-length integers (small values cost fewer bytes)
pack.float32(), pack.float64(), pack.quantized(min, max, ...)floats, or a compressed fixed-range float
pack.enumeration([...]), pack.literal(...)a fixed choice
pack.list(of), pack.tuple([...])a variable or fixed array
pack.uint8Array(), pack.float32Array(), and the other typed arraysraw typed arrays
pack.object({ ... }), pack.record(of), pack.union(...)a struct, keyed map, or tagged variant
pack.optional(of), pack.nullable(of), pack.nullish(of)maybe-absent, maybe-null, or either
pack.quat(), pack.uv2(), pack.uv3()a compressed rotation, 2D unit vector, or 3D unit vector
pack.position(), pack.quaternion(), pack.scale(), pack.spherical()engine vectors as float32

To sync a field that is also a control, propToPack turns its prop schema into a pack one, so the field's meaning is written once: sync(Trait, 'block', { schema: propToPack(prop.block()), ... }). It lowers numbers and vectors to float64; write a pack schema by hand where bytes matter more.

sync's rate and authority (which side may write a field) get a fuller treatment under replication and authority.

Systems and lifecycle#

system(id, factory, opts?) declares behaviour. The factory runs once per scene: inside it you make the queries the system iterates, keep any scratch state it needs, and register lifecycle hooks. This system registers every one, with the args each hands you and a note on when it fires and on which side:

// every lifecycle hook a system can register, with the args each hands you.
system('hooks', (ctx) => {
    // once, when the system starts (and again on every hot reload).
    onInit(ctx, () => debug.log(ctx, 'init'));

    // every fixed-timestep tick, on both server and client. gameplay simulation
    // lives here. delta: seconds since the previous tick, always the fixed step.
    // the client ticks at 60; the server ticks at the game's `tickRate`.
    onTick(ctx, ({ step }) => debug.log(ctx, 'tick', step));

    // first thing each frame, ahead of onUpdate and onTick. client only.
    // read input and set intent here. delta: seconds since the previous frame.
    onInput(ctx, ({ delta }) => debug.log(ctx, 'input', delta));

    // once per frame, before that frame's ticks. client only. rarely needed
    // (prefer onInput for input). delta: seconds since the previous frame.
    onUpdate(ctx, ({ delta }) => debug.log(ctx, 'update', delta));

    // once per frame, after the ticks and interpolation. client only. use for
    // camera work and reading final visual positions. delta: as above.
    onFrame(ctx, ({ delta }) => debug.log(ctx, 'frame', delta));

    // a client joined the room. server only. client: the joiner's id;
    // playerNode: their spawned player node (args also carry user, joinData).
    onJoin(ctx, ({ client, playerNode }) => debug.log(ctx, 'join', client, playerNode.id));

    // a client left the room. server only.
    onLeave(ctx, ({ client, playerNode }) => debug.log(ctx, 'leave', client, playerNode.id));

    // the system is being torn down: the scene closing, or a hot reload. release
    // here anything it set up (mounted DOM, loaded assets).
    onDispose(ctx, () => debug.log(ctx, 'dispose'));
});

The opts argument takes { editor: true } to also run the system in the editor, as the environment system in Your first systems does.

Queries#

A system finds the nodes it acts on with a query, rather than by walking the tree. query(ctx, [TraitA, TraitB]) returns a live query that stays in sync as nodes gain and lose those traits; iterate it each tick, where every match is a tuple of the requested trait instances (reach the node itself with trait._node).

system('enemies', (ctx) => {
    // create the live query once; it stays in sync as nodes match and unmatch
    const enemies = query(ctx, [EnemyTrait, Transform.Trait]);

    onTick(ctx, () => {
        // each match is a tuple of the requested trait instances
        for (const [enemy, transform] of enemies) {
            if (enemy.hp <= 0) continue;
            const pos = Transform.getWorldPosition(transform);
            console.log(enemy.hp, pos);
        }
    });
});

filter(ctx, conditions) is the one-shot version that returns a plain array, and first(ctx, Trait) returns the nearest ancestor carrying a trait.

Conditions#

A bare trait handle in the list is the common case, "the node has this". For anything else, wrap it in a condition:

ConditionMatchesTuple slot
TraitA / With(TraitA)node has itTraitA
Not(TraitA)node does not have itnone
Up(TraitA)this node or the nearest ancestor bearing itTraitA
Ancestor(TraitA)strictly above: parent, then its parentsTraitA
Optional(x)either wayTraitA | null

Optional wraps any of the others except Not (which would be meaningless, and doesn't typecheck), so Optional(Up(TraitA)) is "the group above me, if there is one". The tuple you iterate follows this table exactly: Not terms contribute no slot, so the destructuring skips them, and an optional slot is typed nullable.

system('nearby-enemies', (ctx) => {
    // a bare handle means With(). the rest are explicit terms.
    const targets = query(ctx, [
        EnemyTrait, // must have it, yields the instance
        Not(DeadTrait), // must NOT have it, yields nothing
        Optional(ShieldTrait), // may have it, yields the instance or null
        Up(TeamTrait), // nearest TeamTrait on this node or above
        Optional(Ancestor(SquadTrait)), // strictly above, and allowed to be absent
    ]);

    onTick(ctx, () => {
        for (const [enemy, shield, team, squad] of targets) {
            //          ^ EnemyTrait
            //                 ^ ShieldTrait | null
            //                         ^ TeamTrait
            //                               ^ SquadTrait | null
            console.log(enemy.hp, shield?.hp ?? 0, team.colour, squad?.name);
        }
    });
});

Up and Ancestor resolve against the hierarchy rather than the node's own traits, so their cost lands on structural mutations, not on reads. A match holds its resolved value, and the tree re-resolves it when a node moves or the target trait is added or removed. Reading it back is an array index.

Reacting to a query changing#

Iterating covers "do something with every match this tick". When you need to run setup and teardown as nodes join and leave the set, subscribe instead:

system('spawn-markers', (ctx) => {
    const spawns = query(ctx, [SpawnPointTrait, Transform.Trait]);
    const markers = new Map<SpawnPointTrait, Marker>();

    onQueryEnter(ctx, spawns, (spawn, transform) => {
        markers.set(spawn, addMarker(transform));
    });
    onQueryExit(ctx, spawns, (spawn) => {
        removeMarker(markers.get(spawn)!);
        markers.delete(spawn);
    });
});

Two rules make this safe to build on:

  • Subscribing is itself an enter. The handler fires immediately for every node already matching, so a system registered after the scene loaded still sees the whole set. You never write a backfill loop.
  • Unsubscribing is itself an exit. When the system disposes, each exit handler fires one last time for every node still matching.

Together those mean every enter is followed by exactly one exit, so a per-node resource opened in an enter handler cannot leak, not on scene teardown and not across a hot reload (the rebuilt instance simply re-enters the same set).

An enter fires once the node is fully live: its subtree is registered and its own scripts have run their onInit. Adding or removing traits from inside a handler takes effect immediately.

There is no onQueryChange. Traits are plain mutable objects with no write barrier, so nothing can observe a field being assigned. Poll the value in onTick, or have whatever writes it announce the change.

Ticks, frames, and interpolation#

The simulation advances on a fixed timestep while rendering runs as fast as the display allows. On the client that timestep is always 60 Hz; the server runs at the game's tickRate, so a system's onTick can fire at different rates on the two sides and should scale by its step rather than counting ticks. Frame hooks (onUpdate, onFrame, onInput) get a real, variable delta instead. Each rendered frame the engine runs zero or more fixed ticks (onTick) to catch up to real time, then renders. Because a frame usually falls between two ticks, it interpolates: each moving object is drawn a fraction of the way from its previous tick position to its current one, so motion stays smooth at any framerate.

This gives a node's transform two values. The logic transform steps once per tick; the visual transform is the interpolated value used for rendering. Transform.getWorldPosition and its siblings read the logic transform, and Transform.getVisualWorldPosition reads the interpolated one. So gameplay in onTick works in logic space, while camera-follow code lives in onFrame and reads Transform.getVisualWorldPosition, so the camera tracks the smoothed body rather than the stepping one.

Interpolation is opt-in per node, and you opt in by adding a trait:

addTrait(node, Interpolation.Trait);   // eased between ticks
removeTrait(node, Interpolation.Trait); // back to stepping

The engine adds it for you in exactly two cases: a node with a rigid body, and a node with a CharacterController.Trait. Both are tied to the lifetime of the thing that added them, which is worth knowing before you take one away. Strip a character controller and the node loses interpolation with it, so a node you still move and still replicate will start stepping. If you strip a controller and keep moving the node, add Interpolation.Trait back yourself.

Nothing else is automatic. In particular a replicated transform is not enough: a plain node whose pose arrives over the network is drawn wherever the last update put it, so it steps at the rate those updates arrive rather than at your framerate. Anything replicated and moving wants this trait.

A node you move every frame in onFrame, or one that never moves, does not need it.

Interpolation eases between poses, which is wrong when a pose change is meant to be a jump. Call Transform.teleport(transform) after moving a node somewhere discontinuous and the next frame snaps instead of sliding across the gap. The marker replicates, so observers snap too.

In short: read input and set intent in onInput, put gameplay in onTick, and camera and visual-following work in onFrame.

Time#

Lifecycle hooks hand you a delta, but for cooldowns, durations, and scheduled events read the room clock. ctx.clock.time is the local tick-aligned time in seconds, and ctx.clock.serverSmoothed is the shared server time, the same on every client, for anything that must agree across the network (Server time covers it in depth).

// nextFireAt is a moment on the room clock, in seconds
const WeaponTrait = trait('weapon', { cooldown: 1.5, nextFireAt: 0 });

system('fire-cooldown', (ctx) => {
    const weapons = query(ctx, [WeaponTrait]);

    onTick(ctx, () => {
        for (const [weapon] of weapons) {
            // ctx.clock.time is local tick-aligned time, ideal for cooldowns and
            // durations. for a deadline every client must agree on, use ctx.clock.serverSmoothed.
            if (ctx.clock.time < weapon.nextFireAt) continue; // still cooling down
            weapon.nextFireAt = ctx.clock.time + weapon.cooldown;
            // ... fire the weapon ...
        }
    });
});

To run something later, timeout(ctx, seconds, fn) calls fn once and interval(ctx, seconds, fn) calls it every seconds. Both count on ctx.clock.time, fire after that tick's onTick, and return a function that cancels them. They end with the system: nothing to clean up in onDispose. An interval shorter than a tick fires several times in one tick to keep its count.

system('round-timers', (ctx) => {
    let stopWave = () => {};

    onInit(ctx, () => {
        // every 5 seconds while the system runs; the returned function cancels it
        stopWave = interval(ctx, 5, () => {
            // ... spawn a wave ...
        });
    });

    onJoin(ctx, () => {
        // once, 3 seconds after each join
        timeout(ctx, 3, () => {
            // ... show the rules ...
        });
    });

    // ... when the round ends: stopWave();
});

Logging#

Use log, warn, and error instead of bare console.log. Each surfaces the message in the editor as well as the console, tagged with where it came from: a system's name ([health-log]), or a script's trait, name and node ([door.swing#12]). A system runs over many entities, so name the one a message is about.

system('health-log', (ctx) => {
    const healths = query(ctx, [HealthTrait]);

    // log/warn/error surface in the editor as well as the console
    onQueryEnter(ctx, healths, (health) => {
        debug.log(ctx, health._node.name, 'spawned with', health.hp, 'hp'); // routine info
        if (health.max <= 0) debug.warn(ctx, health._node.name, 'max hp is not positive'); // a smell
    });

    onTick(ctx, () => {
        for (const [health] of healths) {
            if (health.hp < 0) debug.error(ctx, health._node.name, 'hp went negative:', health.hp); // a bug
        }
    });
});

Systems and scripts#

Systems are the default. Entities are data-only traits, and a few systems query for those traits and iterate them each tick, so the logic for every enemy, projectile or pickup lives in one place and runs over all of them at once. That keeps the hot loop tight, puts the order things happen in under your control, and keeps state on traits where sync, the inspector and other systems can see it.

For a small self-contained object, a script puts behaviour on one node's trait instead: script(Trait, id, factory, opts?) runs its factory once per node carrying the trait, with ctx.trait the bound instance (fully typed) and ctx.node its node. It takes the same lifecycle hooks as a system. A system is itself a script on the always-present World.Trait world node, which is why it runs once per scene.

const DoorTrait = trait('door', { open: false, angle: 0 });

// one instance per node carrying DoorTrait: ctx.trait is that node's door, ctx.node the node
script(DoorTrait, 'swing', (ctx) => {
    const transform = getTrait(ctx.node, Transform.Trait);
    if (!transform) return;
    const _rotation = quat.create();

    onTick(ctx, ({ step }) => {
        const door = ctx.trait;
        const maxStep = DOOR_SWING_RADIANS_PER_SECOND * step;
        const remaining = (door.open ? Math.PI / 2 : 0) - door.angle;
        door.angle += Math.max(-maxStep, Math.min(maxStep, remaining));
        Transform.setQuaternion(transform, quat.setAxisAngle(_rotation, [0, 1, 0], door.angle));
    });
});

Reach for a script when the object's behaviour touches only itself, a door, a lever. Anything that spans entities or runs over many of them belongs in a system.

Structuring a game#

bongle code is data first. State is plain data on traits, behaviour is free functions that read and write it, and there are no classes. A game grows as one module per subsystem: a file with an exported State type, init(ctx) that makes the state (live queries included), dispose(s, ctx), and functions that take the state or the traits they change as arguments.

import { addTrait, getTrait, type Node, pack, query, removeTrait, type ScriptContext, sync, type TraitType, trait } from 'bongle';

const BURN_DAMAGE_PER_SECOND = 5;
const BURN_SECONDS = 3;

export const HealthTrait = trait('health', { hp: 100 });
sync(HealthTrait, 'hp', {
    schema: pack.float32(),
    pack: (health) => health.hp,
    unpack: (hp, health) => {
        health.hp = hp;
    },
});

export const BurningTrait = trait('burning', { until: 0 });

export type State = {
    burning: ReturnType<typeof query<[typeof HealthTrait, typeof BurningTrait]>>;
    extinguished: Node[];
};

export function init(ctx: ScriptContext): State {
    return { burning: query(ctx, [HealthTrait, BurningTrait]), extinguished: [] };
}

export function dispose(_s: State, _ctx: ScriptContext): void {}

export function ignite(ctx: ScriptContext, node: Node): void {
    const until = ctx.clock.time + BURN_SECONDS;
    const burning = getTrait(node, BurningTrait);
    if (burning) burning.until = until;
    else addTrait(node, BurningTrait, { until });
}

export function damage(health: TraitType<typeof HealthTrait>, amount: number): void {
    health.hp = Math.max(0, health.hp - amount);
}

export function tick(s: State, ctx: ScriptContext, step: number): void {
    s.extinguished.length = 0;
    for (const [health, burning] of s.burning) {
        damage(health, BURN_DAMAGE_PER_SECOND * step);
        if (ctx.clock.time >= burning.until) s.extinguished.push(burning._node);
    }
    for (const node of s.extinguished) removeTrait(node, BurningTrait);
}

A single system, world in src/index.ts, owns the schedule: it inits each module and calls it from the lifecycle hooks, in the order you choose rather than the order imports happen to run.

import { ENVIRONMENT_OVERWORLD, env, onDispose, onTick, setEnvironment, system } from 'bongle';
import * as Burning from './agents-burning.snippet';

system(
    'world',
    (ctx) => {
        setEnvironment(ctx, ENVIRONMENT_OVERWORLD);
        if (ctx.mode !== 'play') return;

        if (env.server) {
            const burning = Burning.init(ctx);
            onTick(ctx, ({ step }) => Burning.tick(burning, ctx, step));
            onDispose(ctx, () => Burning.dispose(burning, ctx));
        }
    },
    { editor: true },
);

Names carry the meaning, so the code needs few comments. Every project's AGENTS.md holds this same guidance for coding agents.

Client, server, and editor#

The same source runs on both sides; a few flags decide where and when each piece runs, and the unused branches are stripped at build time.

Which side. env.server and env.client are build-time booleans, so a guard like if (!env.client) return compiles its body out of the other bundle entirely. Put visuals, input, and UI behind env.client, and code that must never ship to players behind env.server. Inside a system, ctx.server and ctx.client are the matching runtime handles, present only on their side.

Who changes the world. Spawning, scoring, pickups, anything every player should see, runs on the server, behind env.server, so each change is made once and replicates rather than being made once per side.

system('sides', (ctx) => {
    // server-only: authoritative logic, compiled out of the client bundle
    if (env.server) {
        onJoin(ctx, ({ playerNode }) => {
            debug.log(ctx, 'player joined', playerNode.id);
        });
    }

    // client-only: visuals, input, and UI, compiled out of the server bundle
    if (env.client) {
        onFrame(ctx, () => {
            // inside `env.client`, ctx.client is guaranteed, so `!` is fine
            const mouseKeyboard = ctx.client!.input.mouseKeyboard;
            if (isKeyDown(mouseKeyboard, 'KeyE')) {
                // ... interact ...
            }
        });
    }
});

For moving state from the server to clients, a sync on a trait is the usual path; for discrete events, use RPC (Multiplayer covers both).

Editor builds. env.editor is true only when the project runs under the editor in development, and false in production deploys, so authoring helpers and debug overlays live behind it and never ship.

Edit vs play. A system's lifecycle hooks do not run while the editor is in edit mode by default; pass { editor: true } as the system's options to opt in (the environment system in Your first systems does exactly this, so the world is lit while you build it). A system that runs in both then reads ctx.mode, which is 'edit' or 'play' per room, to tell which it is. A classic use is an authoring-only marker, a label over each spawn point while editing, gone at play time.

// an authoring aid: a label floating over each spawn point while you build the
// level. `{ editor: true }` lets the script run in edit mode at all; the guard
// limits it to an editor build (env.editor) in edit mode (ctx.mode), so it never
// appears in play or in a shipped bundle.
system(
    'spawn-markers',
    (ctx) => {
        if (!env.editor || ctx.mode !== 'edit') return;

        const spawns = query(ctx, [SpawnTrait, Transform.Trait]);
        const painted = new Set<Node>();

        onFrame(ctx, () => {
            for (const [, transform] of spawns) {
                const point = transform._node;

                let marker = findChildByName(point, 'marker');
                if (!marker) {
                    // a client-only canvas billboard. paint it next frame, once the
                    // visuals layer has installed (and one-time cleared) its canvas.
                    marker = createNode({ realm: 'client', name: 'marker' });
                    Transform.setPosition(addTrait(marker, Transform.Trait), [0, 1.5, 0]);
                    addTrait(marker, Canvas.Trait, { mode: 'y-billboard', worldScale: 1 / 128 });
                    addChild(point, marker);
                    continue;
                }
                if (painted.has(marker)) continue; // a static label: paint it just once

                const canvas = getTrait(marker, Canvas.Trait);
                const g = canvas?.canvas?.getContext('2d');
                if (!canvas || !g) continue;
                g.fillStyle = '#000';
                g.fillRect(0, 0, canvas.width, canvas.height);
                g.fillStyle = '#fff';
                g.font = 'bold 48px sans-serif';
                g.textAlign = 'center';
                g.textBaseline = 'middle';
                g.fillText('SPAWN', canvas.width / 2, canvas.height / 2);
                canvas.needsUpdate = true;
                painted.add(marker);
            }
        });
    },
    { editor: true },
);

Hot reload#

In the editor, saving a file re-runs the factories of the systems and scripts it declares live, so edits take effect without a restart. The old instance is disposed first, so its onDispose runs, then the new factory runs. Factory-scope locals reset by design; to carry state across a reload, register onSwap with a serialize and deserialize pair.

Math#

bongle is built on math, the math engine for the interactive web. It is what the engine itself uses for transforms, physics, and rendering, and it is yours to use in scripts: vectors, quaternions, and matrices, plus shapes and spatial queries, seeded random, noise, springs and easing, and inverse kinematics. The API docs list every export with its signature.

Everything is grouped behind subpath imports, so you only pay for what you use:

ImportWhat's in it
mathvec2 vec3 vec4 quat mat3 mat4 euler spherical, plus scalar helpers like clamp, lerp, remap, deltaAngle
math/shapesbox3 sphere plane3 obb3 frustum raycast3 and friends
math/geometryconvex hulls, polygon triangulation and decomposition
math/timeeasing, and spring spring2 spring3 spring4
math/randomseeded generators (mulberry32, isaac32) and random helpers
math/noiseperlin, simplex, and worley noise in 2D to 4D, plus fbm, ridged, domain warping
math/colorcolor and colorspace conversion
math/ikfabrik2 fabrik3

Plain data, free functions#

Every type is a plain array of numbers: a Vec3 is [x, y, z], a Quat is [x, y, z, w], and a Mat4 is 16 numbers, column-major. There are no classes to construct and nothing to unwrap, so a literal is a valid value anywhere one is expected. Operations are free functions that take their output first, write into it, and return it. The output may be one of the inputs, so vec3.normalize(v, v) normalizes in place.

// types are plain arrays, the constructors just return literals
const a: Vec3 = [1, 2, 3];
const b = vec3.fromValues(4, 5, 6);
const out = vec3.create(); // [0, 0, 0]

// functions write into their first argument and return it
vec3.add(out, a, b); // out = [5, 7, 9]
vec3.normalize(out, out); // passing the same array as output and input is fine

// scalars and booleans return directly
const distanceSq = vec3.squaredDistance(a, b);

Scratch buffers#

That output-first shape is what keeps hot paths like onTick allocation-free: allocate a few reusable vectors once and write through them every tick, rather than creating a new vector per operation. Declare them once in the system's factory, reuse them for every entity it iterates, and prefix them with an underscore (_toTarget) to mark them as throwaway working memory, not state anything reads later.

system('move-to-target', (ctx) => {
    const movers = query(ctx, [MoverTrait, Transform.Trait]);

    // scratch buffers live in the system and are reused for every mover, every
    // tick, so the hot path allocates nothing. the leading underscore marks them
    // as throwaway working memory, not state to read elsewhere.
    const _toTarget: Vec3 = vec3.create();
    const _step: Vec3 = vec3.create();
    const target: Vec3 = [10, 1, 5];

    onTick(ctx, ({ step }) => {
        for (const [mover, transform] of movers) {
            const position = Transform.getWorldPosition(transform);

            // step `speed` metres/second toward the target, writing through the
            // scratch buffers instead of allocating a new vector each tick
            vec3.subtract(_toTarget, target, position);
            vec3.normalize(_toTarget, _toTarget);
            vec3.scaleAndAdd(_step, position, _toTarget, mover.speed * step);
            Transform.setPosition(transform, _step);
        }
    });
});

Springs and easing#

For anything that should move smoothly toward a goal, like a follow camera, a pet, or a UI element sliding in, reach for a spring instead of hand-rolled lerps. A spring's state is a value and a velocity that you keep on a trait and pass in each tick, and it stays smooth even when the goal changes mid-motion. damp is the critically damped form that never overshoots; update takes a damping ratio for bouncier motion. For angles, spring.dampAngle wraps the short way round.

// spring state is plain data on the trait: a value and its velocity per spring
const FollowerTrait = trait('follower', {
    smoothTime: 0.3,
    state: () => ({ follow: spring3.create(), yaw: spring.create() }),
});

system('follow-smoothly', (ctx) => {
    const followers = query(ctx, [FollowerTrait, Transform.Trait]);
    const UP: Vec3 = [0, 1, 0];
    const waypoint: Vec3 = [0, 2, 8];
    const _facing = quat.create();

    // start each spring where its node already is
    onQueryEnter(ctx, followers, (follower, transform) => {
        vec3.copy(follower.state.follow.value, transform.position);
    });

    onTick(ctx, ({ step }) => {
        for (const [follower, transform] of followers) {
            const { follow, yaw } = follower.state;

            // critically damped: eases in, never overshoots, and stays smooth
            // when the waypoint jumps mid-flight
            spring3.damp(follow, waypoint, follower.smoothTime, step);
            Transform.setPosition(transform, follow.value);

            // face the way it's moving (forward is -Z), holding the last facing once
            // it settles. dampAngle takes the short way round, so turning from 350
            // to 10 degrees swings 20 degrees rather than 340
            const velocity = follow.velocity;
            if (velocity[0] * velocity[0] + velocity[2] * velocity[2] > 0.01) {
                spring.dampAngle(yaw, Math.atan2(-velocity[0], -velocity[2]), follower.smoothTime, step);
            }
            quat.setAxisAngle(_facing, UP, yaw.value);
            Transform.setQuaternion(transform, _facing);
        }
    });
});

Seeded random and noise#

Math.random() gives a different answer every run. For procedural content you want to reproduce, like a scattered forest or a generated level, create a seeded generator from math/random and draw from it. Pair it with math/noise for values that vary smoothly over space instead of jumping between neighbours.

system('scatter-rocks', (ctx) => {
    onInit(ctx, () => {
        // the same seed gives the same sequence, so the layout is identical on
        // every run and every machine
        const generator = mulberry32.create(1234);
        const rng = () => mulberry32.sample(generator);
        const terrain = simplex2d.create(1234);

        for (let i = 0; i < 50; i++) {
            const x = random.float(rng, -40, 40);
            const z = random.float(rng, -40, 40);
            // simplex returns [-1, 1]; keep only the spots in the high half
            if (simplex2d.sample(terrain, x * 0.05, z * 0.05) < 0) continue;

            const rock = createNode({ name: 'rock' });
            Transform.setPosition(addTrait(rock, Transform.Trait), [x, 0, z]);
            addChild(ctx.node, rock);
        }
    });
});

Getting the most out of it#

  • Compare squared lengths. vec3.squaredDistance and vec3.squaredLength skip the square root, and give the same answer for "is it within range" checks when you compare against the squared range.
  • Guard degenerate input. Normalizing a zero-length vector, or facing a direction that points straight up, has no good answer. Check with a squared length and a small threshold first, sized to the scale you work at.
  • Keep state as plain data you own. Springs, generators, and scratch vectors are all plain values: per-entity ones live on a trait, scratch ones are made once in the system's factory, all are mutated in place, and there is nothing to dispose.
  • Look before writing it yourself. Angle wrapping (wrapAngle, deltaAngle), range mapping (remap, remapClamp), ray and shape tests (math/shapes), and rotating toward a direction (vec3.rotateTowards, quat.slerp) are already there.

Multiplayer#

The multiplayer model introduced replication, where most state crosses the wire for free. This chapter goes deeper: how sync replication, authority, and ownership actually work; client-side prediction; explicit messages with RPC; and managing multiple rooms.

Replication and authority#

Most multiplayer state never needs an explicit message: give a trait field a sync and it replicates from its authoritative side to every other side automatically, on every change. (Replication applies only to shared-realm nodes; the realm section covers the others.)

A change is found by packing the field each tick and comparing the bytes with what was last sent, so a plain assignment replicates and a value that holds still sends nothing. Two options tune this:

  • rate caps how often a changing field sends. rate.realtime(), the default, sends every tick it changed; rate.hz(n) sends at most n times a second, always the latest value, which suits a noisy field such as an aim direction.
  • dirty decides which ticks the comparison runs on. dirty.diff(), the default, checks every tick. dirty.explicit() checks only on ticks where you called the handle sync returned, handle.dirty(instance), which saves the packing for a field that rarely changes. The catch is that every write must call it: a write that skips it never replicates.

Authority decides which side may write a synced field. By default it is the server, so writes from clients are ignored. Set authority: 'owner' on the sync to let the node's owning client write it instead, which is how player-controlled and client-predicted entities work.

// `score` is server-authoritative (the default) and emitted on every change.
sync(PlayerStateTrait, 'score', {
    schema: pack.uint32(),
    pack: (t) => t.score,
    unpack: (value, t) => {
        t.score = value;
    },
});

// `aimX` is written by the node's owning client (authority: 'owner') and capped to
// 20 sends/sec; byte-diff (the default) keeps it off the wire while the value holds.
sync(PlayerStateTrait, 'aimX', {
    schema: pack.float32(),
    pack: (t) => t.aimX,
    unpack: (value, t) => {
        t.aimX = value;
    },
    authority: 'owner',
    rate: rate.hz(20),
});

Ownership is the separate axis behind that. Each shared node has an owner, a player or none: a player's own node is owned by their client from the moment they join, and an unowned node is driven by the server. isOwner(ctx, node) answers "do I have write authority here": on the server it is true for unowned nodes, and on a client it is true only for that client's own nodes, so one shared script can run on both sides and act only where it has authority. On a client, a node it does not own is a proxy: it renders the replicated state but does not drive it. The engine assigns ownership; you read it with isOwner but do not reassign it.

Ownership in bongle is fixed this way rather than transferable at runtime: there is no take-ownership call. A player owns their own node and nothing else; everything else is server-owned. For an entity a player should control, like a vehicle they enter or an object they carry, keep it server-authoritative and route that player's input to it (over RPC, or by reading their owned player node), rather than handing the entity itself to the client.

These two axes, per-field authority and per-node ownership, compose, and a player is the classic case. The player node is owned by its client so movement stays responsive, but the things a player must not forge, health, score, an inventory, stay server-authoritative on the same entity. Do this per field by leaving those syncs at the default authority: 'server' (the engine's own player node works this way: its character-controller input is owner-authoritative while its identity is server-owned), or per node by hanging a server-owned child off the player. Any node you create has no owner, so the server drives everything on it, which makes a child node a clean home for a server-owned subsystem like an inventory.

const InventoryTrait = trait('inventory', { coins: 0 });

// `coins` replicates from the server: authority defaults to 'server', so clients
// see it but cannot write it, even on a node their own player owns.
sync(InventoryTrait, 'coins', {
    schema: pack.uint32(),
    pack: (t) => t.coins,
    unpack: (value, t) => {
        t.coins = value;
    },
});

system('inventories', (ctx) => {
    if (!env.server) return;

    onJoin(ctx, ({ playerNode }) => {
        // the player node is owned by its client, which drives its movement. attach a
        // server-owned child for state the server must control: a node you create has
        // no owner, so the server is authoritative over everything on it.
        const inventory = createNode({ name: 'inventory' });
        addTrait(inventory, InventoryTrait);
        addChild(playerNode, inventory);
    });
});

Client-only nodes#

A shared node replicates to every client; there is no per-client visibility filter that shows it to some clients and hides it from others. When you want something to exist on one client only, make it a client-only node: create it with realm: 'client' and it lives on that client alone, never replicated and never serialized.

The common pattern is to hang client-only nodes under a server-authoritative parent for purely local visuals: a name tag, a particle trail, a held-item model, a selection highlight. The shared parent replicates, and each client builds its own decoration as a child that rides the parent's transform and is removed automatically when the parent goes away. Build it in a client context (guard with ctx.client or env.client) from an onQueryEnter, and remove it in the matching onQueryExit, so a hot reload rebuilds the decorations rather than adding a second set.

// give every player a name tag, built and kept entirely on the client.
system('nameplates', (ctx) => {
    if (!ctx.client) return; // a local visual; this never runs on the server

    const players = query(ctx, [Player.Trait]);

    // fires for every player already in the room, then once for each that joins
    onQueryEnter(ctx, players, (player) => {
        // realm 'client': lives on this client alone, never replicated or serialized.
        // as a child of the shared player node it rides the player's transform.
        const plate = createNode({ realm: 'client', name: 'nameplate' });
        Transform.setPosition(addTrait(plate, Transform.Trait), [0, 3.1, 0]);

        // a screen-space DOM overlay at constant css size (distanceFactor null), so it
        // stays readable at any distance instead of shrinking like a world quad.
        const html = addTrait(plate, Html.Trait, { mode: 'screen', center: true, distanceFactor: null });
        if (html.element) {
            html.element.textContent = player.username;
            html.element.style.cssText = 'color:#fff; font:bold 12px ui-monospace, monospace; pointer-events:none;';
        }
        addChild(player._node, plate);
    });

    // fires when a player leaves, and for every player when the system is torn down,
    // so a hot reload rebuilds the plates rather than stacking a second set
    onQueryExit(ctx, players, (player) => {
        const plate = findChildByName(player._node, 'nameplate');
        if (plate) destroyNode(plate);
    });
});

The server never knows these nodes exist, so you animate and update them freely on the client without touching replication. This is also the answer to showing something to only some players: there is no visibility flag, so create the node client-side instead of making it shared.

Client-side prediction#

Waiting for the server to confirm every action would make the game feel laggy, so predicted entities run their simulation locally and reconcile against the server afterward. A client simulates the entity the instant it needs to, from your own input or a dynamic body moving between server snapshots; the server runs the authoritative version; and when the server's result arrives the client blends its transform toward it rather than snapping. Your own inputs feel instant while the server stays the source of truth.

Rigid bodies predict by default. With RigidBody.Trait's prediction flag on (the default), each client runs the dynamic body locally instead of only snapping to snapshots, so it stays smooth between updates; where a body has a client owner, that owner runs it ahead of the server and reconciles. The player controller predicts a player's own movement the same way. Set def.prediction: false on a body where a brief snap on correction is fine and you would rather not pay the cost, such as distant, low-stakes objects.

Server time#

ctx.clock.time is a private per-side timeline: it starts at 0 on each side and is not comparable across the wire, so it is only for local cooldowns and durations. For anything that must agree across clients, a projectile's spawn instant, an ability's deadline, a round timer, use ctx.clock.serverSmoothed, which reads the same timeline on the server and every client.

On the server clock.serverSmoothed is just the tick clock. On a client it is a continuously synced estimate of the server's clock, and deliberately not "now": it is held about one-way latency behind true server-now, plus a small jitter buffer. The client seeds it from the join handshake, then locks onto the server clock that rides each tick packet, converging smoothly and snapping only on a large gap (the first sync, or a backgrounded tab catching up). That smoothing is what the name refers to.

Its unsmoothed counterpart is ctx.clock.serverLatest: the raw server time carried by the most recent tick packet, exactly as it arrived. It moves in steps, one per packet, and carries every packet's network jitter, so it is the wrong clock for gameplay; the engine uses it to timestamp replicated transforms. It is 0 on the server and before the first packet.

That render-behind offset is the point, not a flaw: it makes a server-stamped event line up. Stamp the event's time on the server with ctx.clock.serverSmoothed, replicate the stamp, and on the client compare against ctx.clock.serverSmoothed. Because the client's clock sits one-way latency behind, the event's data arrives just as the local clock crosses its timestamp, so a projectile appears at the muzzle as you see it fired, not already downrange.

const PROJECTILE_LIFETIME_SECONDS = 5;

const ProjectileTrait = trait('projectile', { spawnTime: 0 });

// spawnTime is stamped by the server and replicated (server-authoritative)
sync(ProjectileTrait, 'spawnTime', {
    schema: pack.float32(),
    pack: (projectile) => projectile.spawnTime,
    unpack: (value, projectile) => {
        projectile.spawnTime = value;
    },
});

// on the server: stamp the spawn instant in the shared server clock
export function spawnProjectile(ctx: ScriptContext, parent: Node): Node {
    const node = createNode({ name: 'projectile' });
    addTrait(node, ProjectileTrait, { spawnTime: ctx.clock.serverSmoothed });
    addChild(parent, node);
    return node;
}

system('projectiles', (ctx) => {
    const projectiles = query(ctx, [ProjectileTrait]);

    onFrame(ctx, () => {
        for (const [projectile] of projectiles) {
            // age in that same shared timeline. the client's clock.serverSmoothed is held
            // about one-way latency behind, so a server-stamped event lines up: the projectile
            // appears at the muzzle as clock.serverSmoothed crosses spawnTime, not already downrange.
            const age = Math.max(0, ctx.clock.serverSmoothed - projectile.spawnTime);
            if (age > PROJECTILE_LIFETIME_SECONDS) continue;
            // ... advance the projectile and its trail by `age` ...
        }
    });
});

Use it carefully. Treat clock.serverSmoothed as "when the things I am seeing happened on the server", not as a precise current time, and clamp a derived age to be non-negative (Math.max(0, now - stamp)), since a just-arrived stamp can sit a hair ahead of the local clock. It can jump on a snap, so do not write logic that breaks on a discontinuity. And for smooth per-frame visuals that never cross the wire, read ctx.clock.wall instead: it advances every frame by real elapsed time and never stalls, but it is local to each side.

RPC#

Replication suits continuous state; for a one-off event, send a message instead. Declare a command with command(id, direction, schema). The direction is CLIENT_TO_SERVER or SERVER_TO_CLIENT, and the schema both types the payload and serializes it. Handle incoming commands with listen. send(ctx, cmd, data) goes from a client to the server, send(ctx, cmd, data, client) from the server to that one client, and broadcast(ctx, cmd, data) from the server to every client. A command sent the wrong way, or from a local room with no server to reach, warns and goes nowhere.

// a typed client-to-server command
const FireWeaponCommand = command('fire-weapon', CLIENT_TO_SERVER, pack.object({ charge: pack.float32() }));

system('weapon-rpc', (ctx) => {
    // the server is the only side that handles an incoming client command
    if (env.server) {
        listen(ctx, FireWeaponCommand, (data, from) => {
            debug.log(ctx, 'fire', data.charge, 'from', from);
        });
    }

    // the client is the only side that sends it
    if (env.client) {
        onInit(ctx, () => {
            send(ctx, FireWeaponCommand, { charge: 1 });
        });
    }
});

The schema is a pack schema, composed from the same pack builders that back trait sync (tabled under Traits), so the command serializes to a compact binary frame rather than JSON.

Matchmaking#

A game declares its room size once with config({ server: { maxPlayers } }) (default 10). That per-room cap is the only knob; the platform does the placing, in two separate steps.

Room allocation puts each joining player into a room. Players sharing a region, build, and join options are eligible for the same rooms: the matchmaker fills a matching room that still has space, fullest first so lobbies don't fragment, and opens a new one only when they are all at maxPlayers.

Server allocation decides where a new room runs. Each new room is placed on a server in the player's region, the one running the fewest rooms, so rooms fan out across the fleet instead of piling onto one machine.

In the editor, the server does the same on its own: pressing Play in a scene puts you with everyone else playing that scene, fullest room first, up to maxPlayers. Each scene's Play is its own session, the way each published game is.

A client can re-enter matchmaking itself with client.transfer, handing over new options to switch gamemodes or move from a lobby into a match.

// move this client into another gamemode by re-entering matchmaking
system('switch-mode', (ctx) => {
    onInit(ctx, () => {
        if (ctx.client) void client.transfer(ctx, { options: { mode: 'ffa' } });
    });
});

Naming a project sends the player to a different project instead. That is not something a game may do silently, so the platform asks them first and shows them what they are being sent to; the call resolves whether they went. A refused target rests for a few seconds, so the natural spelling, asking while the player stands on the pad, does not re-ask every tick.

// send this player to a DIFFERENT project. the platform asks them first, so
// resolve tells you whether they actually went.
system('portal-pad', (ctx) => {
    onInit(ctx, async () => {
        if (!ctx.client) return;
        const went = await client.transfer(ctx, { project: 'neon-drift', joinData: { from: 'lobby' } });
        if (!went) console.log('they stayed');
    });
});

Rooms#

A room has its own players, scene, voxels, and physics. Everything a script reaches through ctx belongs to its room, and one server can run many rooms at once.

Beyond the rooms matchmaking opens for you, you can manage rooms yourself, for lobbies, private matches, or instanced dungeons. rooms.create opens one from a scene; rooms.join, rooms.swap, and rooms.leave move a client in and out; rooms.list and rooms.view inspect them; rooms.active and rooms.observed report which room a client is in; and rooms.stop closes one.

// a second scene the game opens rooms from on demand
const Arena = scene('arena');

// a client asks to be sent to the arena
const EnterArena = command('enter-arena', CLIENT_TO_SERVER, pack.object({}));

// open a dedicated arena room and move the requesting client into it. rooms.create
// returns the new room's id; rooms.view hands back a ScriptContext for another room
// so ordinary script APIs read through it; rooms.swap moves a client between rooms.
system('arena-portal', (ctx) => {
    if (!env.server) return;

    listen(ctx, EnterArena, (_data, from) => {
        // reuse a running arena room, or open a fresh one from the scene
        let arenaId = rooms.list(ctx).find((id) => rooms.view(ctx, id)?.server?.room.sceneId === Arena.id);
        if (!arenaId) arenaId = rooms.create(ctx, { sceneId: Arena.id });
        rooms.swap(ctx, from, arenaId);
    });
});

Rooms from rooms.create all run in the same process as the caller. That makes moving a client between them cheap, with no reconnect, and lets a script read another through rooms.view, which suits a hub, a lobby, or an instanced side-area. But they share one server's CPU and memory, so keep the count small.

clientToUser resolves a connected client to its durable User, the cross-session identity you key persistence by.

Chat#

Every room has a chat channel. Players send messages, which the server takes in; each client's chat shows lines. On the server, chat.broadcast(ctx, text) puts a system line in everyone's chat, chat.send(ctx, text, client) in one player's, and chat.onMessage(ctx, fn) hears each plain line a player sends, with who sent it, as it goes out. On a client, chat.onMessage hears each line arriving in its chat instead (without the sender's client or playerNode, which a client isn't told), and chat.display(ctx, text) shows a line in that client's chat alone, sent nowhere. Text carries inline formatting tags that the chat panel applies as it renders, [#rrggbb] for colour, [b], [i], [u], and [s] for bold, italic, underline, and strike, and [/] to reset, so you can colour a kill feed or highlight an announcement.

system('announcer', (ctx) => {
    if (ctx.server) {
        onInit(ctx, () => {
            // a system line to everyone in the room. inline tags style the text:
            // [#rrggbb] colour, [b]/[i]/[u]/[s] for bold/italic/underline/strike,
            // and [/] to reset back to the default.
            chat.broadcast(ctx, `[#ffcc00][b]Round starting![/]`);
        });

        // what players say, as the server takes it in: a welcome back to just them.
        chat.onMessage(ctx, ({ client, username, text }) => {
            if (text === 'hi' && client !== null) chat.send(ctx, `hi ${username}!`, client);
        });
    }

    if (ctx.client) {
        // a line in this client's chat only, sent nowhere.
        onInit(ctx, () => chat.display(ctx, 'press [b]T[/] to chat'));

        // on a client, each line arriving in its chat, from players and the server.
        chat.onMessage(ctx, ({ username, text }) => debug.log(ctx, `${username}: ${text}`));
    }
});

Chat is also a command surface. chat.command(ctx, spec) registers a typed slash command from a { name, description, args } spec and returns a handle; chat.listen attaches the handler that runs it. Register the command in a shared script so it exists on both sides, the client gets autocomplete and argument validation as the player types, then listen on the side that should execute it, usually the server. A matched command is consumed rather than shown as a chat line. Each argument has a type: a built-in from chat.types (string, number, block), an inline enum from chat.enumType, or your own ArgType object ({ name, parse, suggest?, describe }) for a custom resolver. The handler receives the parsed args, any flags, and the from client.

// a typed slash command: `/tp <x> <z>`
system('commands', (ctx) => {
    // register the spec on both sides (this is a shared script), so the client
    // gets autocomplete and argument validation as the player types.
    const teleport = chat.command(ctx, {
        name: '/tp',
        description: 'teleport to coordinates',
        args: [
            { name: 'x', type: chat.types.number },
            { name: 'z', type: chat.types.number },
        ],
    });

    // execute it on the server, where it has authority. a matched command is
    // consumed (not shown as a normal chat line); `from` is the client that ran it.
    if (ctx.server) {
        chat.listen(ctx, teleport, ({ args, from }) => {
            debug.log(ctx, 'teleport', from, args.x, args.z);
        });
    }
});

Scenes & prefabs#

You rarely build a whole level node by node in code. Instead you author content in the editor and reference it from scripts. The editor saves each authored scene as a .scene.json file and regenerates src/generated/scenes.ts so the engine and editor know about it.

Scenes#

A scene is a saved chunk of content: a subtree of nodes and, optionally, voxels. Scenes exist as content whether or not your code mentions them. scene(id) is how you make one referenceable from code: it returns a stable SceneHandle you read through, with handle.node for the node subtree, handle.voxels for its blocks, and handle.version to detect reloads. The engine fills the handle in when the scene loads, and options control which side loads it, for example scene('navmesh', { client: false }) for a server-only scene.

Only declare a handle for scenes your code actually uses: to clone from one, read its blocks, or list it as a prefab dependency. A large level that simply loads as the world needs no handle.

Prefabs#

A prefab is a template you instantiate many times. prefab(id, fn, options) declares one. fn builds an instance by attaching children under ctx.scene and writing blocks into ctx.voxels, so one prefab can make nodes, blocks, or both. When a scene or model it reads changes, the editor rebuilds its instances.

// a prefab clones a scene's node children into each instance's scene, together, so a node
// that points at another (a constraint at its bodies) points at that one's copy
const PenguinPrefab = prefab('penguin', (ctx) => {
    for (const copy of cloneNodes(PenguinScene.node.children)) {
        addChild(ctx.scene, copy);
    }
});

To place an instance, call createPrefab from a script, with the args it should differ by. Like createNode, it returns a detached node, with a transform at its parent's origin to place it by; addChild attaches it, and the engine builds the prefab's contents on the next tick.

// instantiate inside a script: createPrefab returns a detached node with a transform;
// place it, and attach it to make it live. The server adds it, so the penguin is made
// once and reaches every client, not once per side.
system('spawn-penguins', (ctx) => {
    if (!env.server) return;
    onInit(ctx, () => {
        const penguin = createPrefab(PenguinPrefab);
        Transform.setPosition(getTrait(penguin, Transform.Trait)!, [4, 1, 4]);
        addChild(ctx.node, penguin);
    });
});

Prefabs are placeable in the editor too. A declared prefab appears in the editor inventory, so you can drop instances into a scene while authoring, the same template placed by hand instead of spawned from code, and the saved scene carries those instances with it.

The inventory shows each prefab as it looks. The editor renders it by running its fn, but no scripts, so build whatever a prefab looks like in fn rather than adding it from a system. A prefab with nothing to draw, such as a spawn point, shows a generic prefab icon. To show your own instead, pass icon with any sprite id, such as a kit icon like 'kit:icon:player' or one of your game's sprites.

Prefabs can take arguments. Pass args: { schema, default } in the options and a second parameter arrives in fn, so one prefab can produce variants such as a color, a difficulty, or a team. createPrefab takes only the args that differ from the defaults.

A prefab that does something gets its behaviour the way everything else does: a trait and a system. An instance's anchor, the node createPrefab returns, is where the instance was put, the user's to move in the editor, so fn leaves it alone: it gives the nodes it builds the traits, and the game's systems find every instance by them, however it was placed.

// the anchor is where the instance was put, and stays the user's: fn builds under it,
// and gives what it builds the traits the game's code looks for
const BobTrait = trait('bob', { phase: 0 });

const BuoyPrefab = prefab('buoy', (ctx) => {
    const buoy = cloneModel(BuoyModel.scene);
    addTrait(buoy, BobTrait);
    addChild(ctx.scene, buoy);
});

// one system moves every buoy, placed by hand or from code, found by its trait. Positions
// are the buoy's own, relative to its anchor, so each bobs where it was put
system('bobbing', (ctx) => {
    const buoys = query(ctx, [BobTrait, Transform.Trait]);
    onTick(ctx, ({ step }) => {
        for (const [bob, transform] of buoys) {
            bob.phase += step * 2;
            Transform.setPosition(transform, [0, Math.sin(bob.phase) * 0.1, 0]);
        }
    });
});

The editor#

bongle dev starts the editor, the visual workspace for building your game. It runs your project live on http://localhost:5566, so code changes and content changes show up immediately.

The editor is where you author the content your code references. You place and paint blocks straight into the world, add nodes and attach traits to them, and edit trait fields in an inspector. The fields the inspector shows are the ones a trait exposes with control, which is why control-backed values are the ones that persist.

What you author is saved under the project's content/ directory as scene files, and the editor regenerates the typed handles in src/generated/, so your code can reference scenes, models, and sounds by name. Scripts marked { editor: true } run inside the editor too, so world setup such as lighting is visible while you build.

Patterns and masks#

The voxel tools, brushes, fill and replace, the heightmap sculptors, share two parameters borrowed from WorldEdit: a pattern that decides what block to place, and a mask that decides which voxels a stroke is allowed to touch.

A pattern is sampled per voxel to answer "what block goes here":

SyntaxDescriptionExample
blocka single blockstone
a,ban even random mixstone,dirt
N%a,M%ba weighted random mix10%stone,90%dirt
$activethe active hotbar slot's block$active

A mask filters where the op applies, answering "does this voxel match":

SyntaxDescriptionExample
blockmatches that blockstone
#existingany non-air voxel#existing
!masknegation!stone
a,bor-list (matches either)stone,dirt
a bintersection (matches all, space-separated)#existing !stone
%Na random N% of voxels%50

So a brush with pattern moss and mask stone paints moss onto existing stone only. These are a small subset of WorldEdit's grammar, enough to place and constrain blocks across the toolset without scripting.

Assets#

Models, textures, sounds, and sprites come from asset files in your project. You declare each as a handle at module scope and point it at its source: model(id, { src }) for a glTF, sound(id, { src }) for audio, and texture for images. That handle is what the rest of your code and the editor reference.

Images have one extra layer, and it is worth getting straight early. A texture is the picture itself, named for what it IS. The two things that consume a texture are named for what they are FOR: a tile is a 16x16 entry in the voxel atlas that a block wears on its faces, and a sprite is an arbitrary-size entry in the sprite atlas. A tile is made of textures; a sprite is made of textures. Both take a texture: a texture handle, or an image file, which declares the texture for you so the common one-image case stays a single call. An array of them is a flipbook, one per frame, and can mix files and handles.

Give an image file as an asset('./file', import.meta.url). The asset then co-locates with the module that declares it and resolves relative to that module wherever it's installed, which is what lets a shared pack ship its assets alongside its code. A plain string path relative to the project root also works, but prefer the asset() form.

// declare each asset once at module scope; the handle is what you reference
// src is `asset('./file', import.meta.url)`, so each asset co-locates with the
// module that declares it and resolves wherever it's installed (a plain project-root path also works)
const MascotModel = model('mascot', { src: asset('./assets/mascot.gltf', import.meta.url) });
const ChimeSound = sound('chime', { src: asset('./assets/chime.ogg', import.meta.url) });
const MarbleTile = tile('marble', { texture: asset('./assets/marble.png', import.meta.url) });
const SmokeSprite = sprite('smoke', { texture: asset('./assets/smoke.png', import.meta.url) });

// a tile feeds a block model
const MarbleBlock = block('guide:marble', {
    name: 'Marble',
    model: () => ({ type: 'cube', tiles: { all: MarbleTile } }),
});

The asset pipeline processes these sources when you build or edit, generating the typed handles in src/generated/ (models.ts, sounds.ts, scenes.ts) so named content is available without hand-wiring it. Everything your own code declares is part of your game, whether or not anything else in code names it. A kit or package declaration only is once something uses it (see use in Your first systems).

A texture need not come from a file. Give texture() a size and an fn instead of a src and it paints the image at bake time with a 2D canvas context, which is handy for procedural textures. Such a texture can also take other textures as inputs, by handle, and compose them: because the input is a handle and not a path, editing the source re-bakes everything derived from it.

// a texture can be COMPUTED, painted at bake time by a function, instead of
// loaded from a file. `tile()` and `sprite()` consume either kind the same way.
const CheckerTexture = texture('checker', {
    size: [16, 16],
    fn: (c) => {
        c.fillStyle = '#222';
        c.fillRect(0, 0, 16, 16);
        c.fillStyle = '#eee';
        c.fillRect(0, 0, 8, 8);
        c.fillRect(8, 8, 8, 8);
    },
});
const CheckerTile = tile('checker', { texture: CheckerTexture });

Voxels & blocks#

The world's terrain is a voxel grid. Every cell holds a block type, the grid is split into fixed-size chunks, and you can change it freely while the game runs.

Your first cube#

The simplest block is a full cube wearing your own tile. It takes two declarations at module scope: a tile for the image, and a blockPreset.cube that wraps it into a block.

A tile is a small square image, drawn at 16x16 pixels: the voxel atlas is a fixed 16x16 grid, so that is the size to author at and a wrong size is rejected at declaration. PNG is the usual format. Drop the .png in your project's assets/ folder next to the module that declares it, then point the tile's texture at it with asset('./assets/...', import.meta.url), so it resolves relative to that module wherever the code is installed (the same pattern as model() and sound(), see Assets).

// 1. declare a tile from your own image. a tile is one 16x16 entry in the voxel
//    atlas. drop the .png in assets/ and point src at it with
//    asset(rel, import.meta.url).
const StoneTile = tile('guide:stone', { texture: asset('./assets/stone.png', import.meta.url) });

// 2. wrap it in a cube. one tile argument paints all six faces the same.
const StoneBlock = blockPreset.cube('guide:stone', { name: 'Stone', tiles: StoneTile });

A block often wears several tiles, one per distinct face. A grass block needs a grass top, a dirt bottom, and a grassy side, so it draws from three images: declare one tile each, then hand cube a per-face map instead of a single tile. top/bottom/sides splits the top and bottom from the four sides; or name all six faces (top, bottom, north, south, east, west) for full control. Each face takes a bare tile handle, or { tile, rotation } to turn it.

// a block can wear a different tile per face. declare one tile per image, then
// pass a per-face map instead of a single tile: top/bottom/sides (a grass-topped
// dirt block), or name all six for full control
// (top/bottom/north/south/east/west).
const GrassTop = tile('guide:grass_top', { texture: asset('./assets/grass_top.png', import.meta.url) });
const GrassSide = tile('guide:grass_side', { texture: asset('./assets/grass_side.png', import.meta.url) });
const DirtTile = tile('guide:dirt', { texture: asset('./assets/dirt.png', import.meta.url) });

const GrassBlock = blockPreset.cube('guide:grass', {
    name: 'Grass',
    // a bare handle is the common case; use `{ tile, rotation }` to turn a face.
    tiles: { top: GrassTop, bottom: DirtTile, sides: GrassSide },
});

The pipeline builds the atlas when you build or edit. A tile whose file is missing renders as a bright magenta placeholder, so a wrong path shows up in the world instead of crashing. Two variations reuse the same wiring: for an image painted in code rather than loaded from a file, declare a computed texture() and pass it as the tile's texture (see Assets); for an animated tile, pass texture an array of frames plus an fps.

Block presets#

blockPreset.cube is one of a family. The blockPreset namespace builds the common block shapes for you, wiring up the model, collision, and any block states the shape needs: blockPreset.stairs, slab, wall, fence, pane, carpet, trapdoor, door, plate, ladder, torch, plant, leaves, liquid, column, and cube. These cover most of what a world needs without authoring a model, and each takes the same tiles you would give a cube.

blockPreset.barrier is a wall you can't see: a full block to collide with that draws nothing and lets light and the blocks beside it show through. Give it tiles and visible: true to see it while you build, and turn visible off to play. The kit's tiles.barrier, a red X on a see-through square, is made for it. The kit blocks are the worked examples: bongle/kit is built almost entirely from these presets, so its source (src/kit/blocks.ts) is the best place to see them in use.

The block() API#

Every preset is sugar over block(id, options), the lower-level declaration. Reach for it directly when a preset's shape or defaults do not fit. The example below is exactly what blockPreset.cross expands to: a flower is not a cube at all, but two crossed quads, and it needs several options set together to behave like vegetation.

// every preset is sugar over block(). here is what blockPreset.cross expands to:
// a flower is not a cube at all but two crossed quads (blockModel.cross), plus
// the handful of options that make vegetation behave. reach for block() directly
// whenever a preset's shape or defaults do not fit.
const PoppyTile = tile('guide:poppy', { texture: asset('./assets/poppy.png', import.meta.url) });
const PoppyBlock = block('guide:poppy', {
    name: 'Poppy',
    model: () => ({ type: 'custom' as const, quads: blockModel.cross(PoppyTile) }),
    collision: false, // walk straight through it
    cull: CullType.SELF, // only hide faces against other poppies, never neighbours
    lightOpacity: 0, // sparse quads, let light pass instead of shadowing
    material: MaterialType.TRANSPARENT, // cutout alpha around the petals
    vertexAnimation: VertexAnimation.PLANT_WIND_SWAY, // sway in the wind
});

The model function returns the block's geometry. A type: 'cube' model carries the per-face tiles map from above; a type: 'custom' model returns a raw list of quads for any shape a preset does not cover, and blockModel provides helpers that build the common ones (cross for the crossed vegetation quads here, box for an axis-aligned box). The remaining options tune behaviour rather than shape: collision, lighting, sounds, friction, and culling, among them cull, lightOpacity, surfaceHeight, collision, and material. The preset source (src/core/voxels/block-presets.ts) is the best reference for how each shape assembles its model and options. As with any content handle, reference the block in code (or pass it to use) so the bundler keeps its declaration.

A block's shape is what it is to a player: what a ray aimed at it picks, and what a selection outline traces. Build one with blockShape.aabbs([...]), boxes in the block's own 0 to 1 space; leave it out and the block is the full cube. By default the shape is also what collides. collision: false makes the block walk-through while keeping its shape pickable, as the poppy above does, and collision set to a shape of its own collides as that instead, the way a fence stands 1 tall but blocks a jump at 1.5. A shape with no boxes is nothing: the block can't be picked, and doesn't collide as it.

// a low post: aimed at and outlined as the post itself, but too tall to jump onto.
const PostBlock = block('guide:post', {
    name: 'Post',
    model: () => ({
        type: 'custom' as const,
        quads: blockModel.box([6 / 16, 0, 6 / 16], [10 / 16, 1, 10 / 16], { all: StoneTile }),
    }),
    shape: blockShape.aabbs([[6 / 16, 0, 6 / 16, 10 / 16, 1, 10 / 16]]),
    collision: blockShape.aabbs([[6 / 16, 0, 6 / 16, 10 / 16, 1.5, 10 / 16]]),
    cull: CullType.PARTIAL,
});

Block states#

A block can carry named properties, so one block type covers several states: a lamp that is lit or not, a log with an axis, crops at a growth stage. Define them with the states option, building the schema from blockState.bool, blockState.enumeration, and blockState.int. Address a specific state by its property values with the handle's stateKey (or stateId), the key you then pass to setBlock.

// a block with a boolean `lit` property, so it has two states; the description says what it means
const LampBlock = block('guide:lamp', {
    name: 'LampBlock',
    states: blockState.create({ lit: blockState.bool('whether it glows') }),
    model: () => ({ type: 'cube', tiles: { all: tiles.stone } }),
});

// address a specific state by its property values; pass the key to setBlock
const litKey = LampBlock.stateKey({ lit: true });
console.log(litKey);

Each property constructor takes an optional description, which says what the property means to whoever places the block by key: the editor, or a coding agent reading the game's blocks. The kit's blocks describe all of theirs.

Directions are the world's: north is -Z, south +Z, east +X, west -X. The kit's directional states each mean something specific:

  • Stairs facing is the way the low front faces, toward whoever climbs them, so facing=north rises to the south. half=top hangs them upside down, and shape (the corner pieces) is set by the stairs themselves from their neighbours.
  • Ladders facing is the way the ladder faces, away from the wall it hangs on.
  • Torches mount is floor, or the side whose wall they lean against.
  • Trapdoors facing is the wall they swing against when open.
  • Doors facing is the side of the cell the closed door sits on. A door is two cells, half=lower with half=upper above it.
  • Logs and other columns: axis is the way their length runs.
  • Fences, panes and walls join their neighbours by themselves: set them by id.

Liquids stay where they are set: they don't flow. A liquid's level is how full the cell is, its highest (the default) being full. A liquid can't be selected unless its preset says selection: true, so players aim and mine through it; the editor still picks it like any block.

Reading and writing the world#

Blocks live in the per-room Voxels, reachable in any script as ctx.voxels, and the block types themselves in the per-room block registry, ctx.blocks. setBlock writes a block by world coordinate and getBlock reads its key back; getBlockState reads the numeric state id, the block kind plus its block-state values in one integer (the same id a raycast hit reports). The empty cell has state id AIR, so compare getBlockState against it to test for air. forEachBlock walks every block that has been set. Server edits replicate to clients automatically.

// read and write blocks through ctx.voxels, addressed by world x/y/z
system('place-grass', (ctx) => {
    onInit(ctx, () => {
        // write a block; server edits replicate to clients automatically
        setBlock(ctx.voxels, 0, 0, 0, GrassBlock.defaultKey());

        // read a block's key, and its numeric state id (block kind + block state)
        const key = getBlock(ctx.voxels, 0, 0, 0);
        const stateId = getBlockState(ctx.voxels, 0, 0, 0);
        debug.log(ctx, key, stateId);

        // AIR is the empty-cell state id: compare a state against it to test for air
        if (getBlockState(ctx.voxels, 0, 1, 0) === AIR) {
            debug.log(ctx, 'nothing above the block');
        }

        // walk every non-air block that has been set
        forEachBlock(ctx.voxels, (x, y, z, blockKey) => {
            debug.log(ctx, 'block at', x, y, z, blockKey);
        });
    });
});

To find which block a ray hits, for a build cursor or a hitscan weapon, use raycastVoxels (covered under Scene queries). The kit blocks also include presets such as doors; toggle one with getDoorOpen and setDoorOpen.

Chunks#

The grid is stored in 16x16x16 chunks, and the block calls above find the right one for you, so most scripts never think about them. Reach for a chunk directly when you care whether part of the world is loaded: getChunkAt takes the same block coordinates as getBlock and returns the chunk holding that block, or undefined if it is not resident. That is a real distinction getBlock cannot express, since it reports air both for an empty cell and for a chunk that has not streamed in yet.

getChunk is the same lookup in chunk coordinates, for when you already have them, from a neighbour walk or a bounds scan. The two differ only in coordinate space, and one chunk step is 16 blocks, so pick deliberately. To go between them, toChunkCoord converts one axis at a time; floor any fractional position first, since it truncates toward zero and a raw negative float lands one chunk too high.

// chunks are 16x16x16. getChunkAt takes the same block coordinates as getBlock.
system('chunk-lookup', (ctx) => {
    onInit(ctx, () => {
        const chunk = getChunkAt(ctx.voxels, 0, 64, 0);

        // undefined means the chunk is not loaded here, which is NOT the same as
        // "all air": getBlock reports air for both, so test with getChunkAt when
        // the difference matters (streaming, worldgen, or a scan you want to skip).
        if (chunk === undefined) {
            debug.log(ctx, 'not loaded yet');
        }

        // already holding chunk coordinates? getChunk takes those directly.
        const origin = getChunk(ctx.voxels, 0, 4, 0);
        debug.log(ctx, origin !== undefined);
    });
});

Procedural generation#

Laying a world is the one voxel workload where the cost of a single write matters: a player's edit happens once, a generator runs millions in a burst.

First decide when the world is generated:

  • While you build ("a big ocean", "scatter rocks over the hills"): the generator is a one-off. Run it once against the scene in the editor, with setBlock and SetBlockFlags.BULK, and the blocks are saved in the scene like any built by hand: undoable, and seen by everyone editing. The generator is not game code, so it is not a file in src/, and the game never runs it.
  • While the game runs (endless terrain, a level that differs every round): the generator is game code, in a module the game runs on the server. The tiers below are for this.

Three tiers produce identical blocks, differing only in the per-block work the engine does:

Per blockYou handle
setBlockchunk lookup, palette, counts, mesh-dirty, light, opnothing
setChunkBlockpalette, counts, mesh-dirty, light, opresolving the chunk
raw chunkData writesone array storeinvalidateChunk, and no replication

Generate on the server, before anyone is near the region.

Simple: setBlock#

The gameplay call, and the right one until you measure otherwise: world coordinates, chunks created as needed.

Pass SetBlockFlags.BULK when generating. Block-def hooks still settle inline so fences join and stairs shape, but script events don't fire (you do not want your own onBlockBuild running a million times). Lighting is the same either way: edits that change light relight per block, and once a tick holds thousands of them, every chunk they touched relights whole in one pass.

// a 64x64 stone platform, one call per block. BULK still settles block-def
// hooks (fences join, stairs shape) but fires no script events.
function generateFlat(voxels: Voxels): void {
    for (let x = 0; x < 64; x++) {
        for (let z = 0; z < 64; z++) {
            setBlock(voxels, x, 0, z, StoneBlock.defaultKey(), SetBlockFlags.BULK);
        }
    }
}

Faster: setChunkBlock#

setBlock is a wrapper that resolves world coordinates to a chunk and calls setChunkBlock. Once you are filling whole chunks you already know which chunk you are in, so hoist that lookup out of the inner loop yourself. Coordinates become chunk-local, so the loops step in chunks on the outside and 0 to 15 on the inside (toLocalCoord converts a world axis to its in-chunk one, the pair of toChunkCoord above). Nothing else about the write changes, so this tier trades nothing away.

// the same platform, resolving the chunk once per chunk instead of once per
// block. coordinates are chunk-local now, so the outer loops step in chunks.
function generateFlatByChunk(voxels: Voxels): void {
    for (let cx = 0; cx < 4; cx++) {
        for (let cz = 0; cz < 4; cz++) {
            const chunk = ensureChunk(voxels, cx, 0, cz);
            for (let lx = 0; lx < 16; lx++) {
                for (let lz = 0; lz < 16; lz++) {
                    setChunkBlock(voxels, chunk, lx, 0, lz, StoneBlock.defaultKey(), SetBlockFlags.BULK);
                }
            }
        }
    }
}

Fastest: raw chunk data#

chunkData(chunk) hands you the chunk's writable Uint16Array, one entry per cell. Entries are chunk-local palette slots, not global block ids, so resolve a slot once per key with ensureChunkPaletteSlot and reuse it, and voxelIndex(lx, ly, lz) gives the index. Call invalidateChunk once per chunk when the writes are done: it rescans the counts, marks the chunk mesh-dirty, and schedules its light.

// the same platform again, writing palette slots straight into the chunk array.
function generateFlatRaw(voxels: Voxels, blocks: BlockRegistryData): void {
    for (let cx = 0; cx < 4; cx++) {
        for (let cz = 0; cz < 4; cz++) {
            const chunk = ensureChunk(voxels, cx, 0, cz);
            const data = chunkData(chunk);

            // one slot per key per chunk, reused for every cell below
            const stone = ensureChunkPaletteSlot(chunk, StoneBlock.defaultKey(), blocks);
            for (let lx = 0; lx < 16; lx++) {
                for (let lz = 0; lz < 16; lz++) {
                    data[voxelIndex(lx, 0, lz)] = stone;
                }
            }

            // once per chunk, after the writes: rescan counts, mark mesh-dirty,
            // schedule the relight. nothing above did any of that.
            invalidateChunk(voxels, chunk);
        }
    }
}

This tier is for play-time generation only, never for building a scene, because it skips:

  • Ops. Nothing replicates, so a client already streaming the region never hears about the writes, and an editor cannot undo them.
  • Block-def hooks. Fences will not join and stairs will not shape. Write the settled state instead.
  • Script events. onBlockBuild and friends never fire.

At this scale, work one chunk column at a time: resolve the vertical band of chunks the column needs into a map, and route every write through a function that picks the band entry and drops anything outside the column. Iterate a margin ring around each column so a tree rooted next door still places, clipped to the part you own. Every write then lands in a chunk you already hold, with no cross-chunk lookups and no seams.

Reacting to changes#

To run logic when the world changes, register a block event for a block type. onBlockBuild and onBlockBreak fire when a block of that type is placed or broken, and onBlockStateChange fires when it changes state in place. All three are server-only and hand you the world coordinates of the change.

// react when a block of this type is placed or broken (server-only)
system('grass-events', (ctx) => {
    onBlockBuild(ctx, GrassBlock, (ev) => {
        console.log('placed at', ev.worldX, ev.worldY, ev.worldZ);
    });
    onBlockBreak(ctx, GrassBlock, (ev) => {
        console.log('broke at', ev.worldX, ev.worldY, ev.worldZ);
    });
});

Rendering & visuals#

Everything the player sees comes from a handful of built-in pieces: the camera and lighting, the traits that draw a node, the model and character system that brings in glTF art, and particles. This chapter covers them all, then drops to the renderer for anything they do not:

  • Camera: the room's view and projection, and the controllers that move it.
  • Lighting and sky: sky presets, time of day, and voxel lighting.
  • Models and meshes: bringing in glTF geometry (the 99% path) and the low-level mesh trait.
  • glTF support: exactly which glTF/GLB features are imported.
  • Visual modifiers: per-instance tint, flash, glow, unlit, and dither.
  • Characters: rigged humanoids that players and NPCs render as.
  • Avatars: the model a humanoid renders with, and spawning NPCs.
  • Animation: playing a model's glTF clips.
  • Procedural animation: posing bones from code each frame.
  • Structures and voxel meshes: things built from blocks that move as one piece.
  • Sprites: 2D billboards and extruded sprite slabs.
  • Particles: short-lived sprite effects such as smoke, sparks, and dust.

Camera#

Every room has a default camera node, reachable in a client script as ctx.client.camera. Its Camera.Trait holds the projection (fov, near, far). ctx.client.camera is the active camera node: what the renderer composes the render camera from each frame. The builtin controllers (orbit, fly, player) write its pose each frame; you can read the trait to adjust field of view or seed a pose before adding a controller, and setCamera(ctx, node) repoints it at a different camera node.

// the room already has a camera node; read its Camera.Trait to set field of view
system('camera-setup', (ctx) => {
    onInit(ctx, () => {
        if (!ctx.client) return;
        const camera = getTrait(ctx.client.camera, Camera.Trait);
        if (camera) camera.fov = (60 * Math.PI) / 180;
    });
});

Each client has a subject: the node local input drives and the engine treats as this client's point of view (renderer + audio). getSubject(ctx) returns it. Builtin controllers and view-only scripts gate their per-frame work on it with getSubject(ctx) === ctx.node, so only the active subject writes the camera or consumes input. Read the active camera pose off getCamera(ctx)'s Transform.Trait for aiming, reticles, or raycasts from the eye (the render camera object itself is renderer-private).

You write your own controller the same way: gate on being the subject, then drive getCamera(ctx)'s transform however you like (follow, orbit, first-person). The builtin orbit / fly / player controllers are just this pattern; the snippet below is a minimal one.

To possess a different node, a free-flying spectator or death cam, or a vehicle you own, call setSubject(ctx, node). That node needs its own controller so your input drives it and the camera follows; setSubject alone only redirects input + POV. It is client-only and purely local: it changes what that client controls, never ownership, and never the server-side streaming anchor (that stays the player node). Pass null to clear it. To merely view something you do not control, a fixed shot, another player, call setCamera(ctx, node) instead, which repoints just the render camera and leaves control where it is. The client also holds defaultSubject / defaultCamera (seeded to the player node and the room camera) as the values to restore when a temporary override ends.

// The subject is the node local input drives and the engine treats as this
// client's point of view (camera + audio). `getSubject(ctx)` returns it; it
// defaults to the player node.
//
// A minimal DIY controller: when the subject carries your trait, drive the active
// camera node's transform yourself. Same shape the builtin orbit / fly / player
// controllers use, so you can write bespoke camera behaviour without the engine.
const FollowCam = trait('follow-cam');
system('follow-cam', (ctx) => {
    onFrame(ctx, () => {
        const subject = getSubject(ctx);
        if (!subject || !hasTrait(subject, FollowCam)) return; // only a follow-cam subject drives the view
        const cameraNode = getCamera(ctx); // the active render camera node
        if (!cameraNode) return; // no camera wired (e.g. offline icon render)
        const camera = getTrait(cameraNode, Transform.Trait);
        const target = getTrait(subject, Transform.Trait);
        if (!camera || !target) return;
        // ...position `camera` relative to `target` here (follow / orbit / first-person).
    });
});

// Possess a node you control: a free-flying spectator / death cam, or a vehicle
// you own. It needs its own controller (like FollowCam above) so your input
// drives it and the camera follows, setSubject alone only redirects input + POV.
// Purely local: never changes ownership or the server-side streaming anchor (the
// player node stays put, so the world keeps streaming around it). To merely VIEW
// something you don't control (another player, a fixed shot), use setCamera
// instead. Restore control with the client's `defaultSubject`.
export function possess(ctx: ScriptContext, node: Node): void {
    setSubject(ctx, node);
}
export function release(ctx: ScriptContext): void {
    if (ctx.client) setSubject(ctx, ctx.client.defaultSubject);
}

Environment - lighting and sky#

Each room has one environment: its sky, sun, moon, stars, clouds, and distance fog. You drive it with two calls. setEnvironment sets the look, and setEnvironmentTime sets the time of day. Set them once in an onInit, or change them later on game events (nightfall, a storm rolling in).

The quickest start is a preset. setEnvironment(ctx, ENVIRONMENT_OVERWORLD) gives you a full daylight scene with sun, moon, stars, and clouds all on, which is what the snippet below does. ENVIRONMENT_DEFAULT is the barer look a fresh room boots with (sky and fog only, everything else off).

// sky, atmosphere and voxel light are one config, set once on the world
system(
    'lighting',
    (ctx) => {
        onInit(ctx, () => {
            setEnvironment(ctx, ENVIRONMENT_OVERWORLD);
        });
    },
    { editor: true },
);

To tune the look, pass a partial config instead of a preset. The merge is per-field: only the fields you set change, and any group you leave out keeps its current value. So you can nudge one thing without restating the rest.

// dim the sun and roll in heavier clouds, leave sky and stars alone
setEnvironment(ctx, {
    sun: { intensity: 0.2 },
    clouds: { enabled: true, density: 0.9, thickness: 4 },
});

The groups are sky (a preset name or a custom stops LUT), sun (enabled, intensity), moon (enabled), stars (enabled, density), clouds (enabled, density, wind, altitude, thickness), and fog (enabled, color, end, start, opacity). The top-level enabled is a master switch: turn it off and the sky and cloud meshes stop rendering entirely.

Fog is the one group that is already on in a fresh room. It fades the world out toward the edge of what the client can see, so the streamed chunk boundary does not read as a wall.

end is where fog reaches full strength: world units, or 'view' (the default) to track that client's own view radius. View radius is a device performance setting, so it is per-client and a script cannot know it. That is why start is a fraction of end rather than world units, which lets you author the fade without knowing the radius. 0.9 (the default) is a narrow lip right at the boundary; 0.1 is haze across the whole view.

color defaults to 'sky', which tracks the sky LUT's horizon at the current time of day, so sunsets and night work with nothing authored. Pass a linear rgb triple to pin it instead. opacity is how opaque fog gets at end, where 1 fully replaces the colour behind it.

// near, thick, atmospheric fog
setEnvironment(ctx, { fog: { end: 30, start: 0.1 } });

// no fog: the world stops hard at the streamed boundary
setEnvironment(ctx, { fog: { enabled: false } });

A numeric end nearer than the view radius does not re-expose the chunk boundary, since fog is already saturated well before it. One further out leaves the engine's own boundary fade in place underneath.

setEnvironmentTime takes hours on a 24h clock (0 midnight, 6 sunrise, 12 noon, 18 sunset, wrapping past 24). It is the per-frame hot path, one uniform write, so you can animate a day/night cycle by advancing it every tick. Sun, moon, and sky color all follow from it.

// a slow day/night cycle: one in-game day every 20 real minutes
onTick(ctx, ({ step }) => {
    setEnvironmentTime(ctx, getEnvironmentTime(ctx) + (24 / 1200) * step);
});

The same config carries voxel light, under light. With floodfill on (the default) light propagates through the grid, so blocks cast and occlude it and a cave gets dark because the sky cannot reach it. ambient sets how dark that darkest dark goes, from 0 (black) to 1 (nothing is ever shaded). It applies when the world is shaded rather than when light is stored, so it costs nothing to change and takes effect the next frame, and it lifts models, sprites and particles by the same amount as the blocks around them.

Worlds that rewrite huge volumes per tick can turn floodfill off and render fully lit instead, trading block shadows for the cost of relighting every write.

Unlike the rest of the environment, floodfill is not purely visual: client and server each light the blocks they write, so both need the same value. Call setEnvironment from a system that runs on both realms rather than one gated behind ctx.server, and each side takes the half it owns.

// `ambient` is how dark the darkest dark gets: 0 lets an unlit cave go black.
system('voxel-lighting', (ctx) => {
    onInit(ctx, () => {
        setEnvironment(ctx, { light: { floodfill: true, ambient: 0 } });
    });
});

// worlds that rewrite huge volumes per tick (procgen, fast-fill builders) can
// skip the BFS and render fully lit instead, trading block shadows for the
// cost of relighting every write.
system('bulk-terrain-lighting', (ctx) => {
    onInit(ctx, () => {
        setEnvironment(ctx, { light: { floodfill: false } });
    });
});

Models and meshes#

You bring 3D art into the world by declaring a model and placing a copy of it. A model is loaded from a glTF file (.gltf or .glb, the format bongle supports) and can be anything: a prop, a pickup, a piece of scenery, or a character. A character is just a model that follows the humanoid rig, so it can be animated and driven like a player or an NPC; it gets its own treatment under Characters, but everything here applies to it too.

model(id, { src }) declares a model from a glTF at module scope and returns a handle. cloneModel(handle.scene) makes a copy of its node subtree, installing the render slot a visible node needs, which you attach to the scene. You almost never build geometry by hand.

// declare a model from a glTF at module scope
const ChestModel = model('chest', { src: asset('./assets/chest.gltf', import.meta.url) });

system('place-chest', (ctx) => {
    onInit(ctx, () => {
        // clone the model's scene and attach it; cloneModel installs the
        // render slot a visible subtree needs
        const chest = cloneModel(ChestModel.scene);
        addChild(ctx.node, chest);
    });
});

A model is a tree of named nodes, and you often want to drive one part of it from code: open a chest lid, mount an item on a hand, attach an effect to a turret. The handle indexes everything the glTF contains by name, as handle.nodes, handle.meshes, and handle.animations. On a placed clone, reach the live instance of a named node with findByName(clone, name), then read or write its traits.

// a model's named glTF nodes are reachable on the placed clone by name, so you can
// drive a sub-part from code: open a lid, mount an item on a hand, attach an effect.
system('open-chest', (ctx) => {
    onInit(ctx, () => {
        const chest = cloneModel(ChestModel.scene);
        addChild(ctx.node, chest);

        const lid = findByName(chest, 'lid');
        if (lid) {
            const lidTransform = getTrait(lid, Transform.Trait);
            if (lidTransform) Transform.setPosition(lidTransform, [0, 0.4, -0.4]); // swing the lid up and back
        }
    });
});

Underneath, the trait that actually draws geometry is Mesh.Trait: it renders one mesh referenced by meshId, such as handle.meshes.<Name>.id. Reach for it directly only when you want a single mesh without the surrounding model subtree. It carries the shared render knobs every visual trait has (tint, glow, flash, unlit, visible), set by writing the fields directly.

Models load at build time from your declarations. For the rare case where a model's source is only known at runtime, loadModel, getModel, ensureModel, and releaseModel fetch and reference-count one on the fly; prefer a declared model() when you can.

glTF support#

TLDR: author in the bongle editor and you stay inside the supported subset by construction. It embeds a build of Blockbench set up for bongle (the same tool the Characters section uses) that exports engine-ready glTF, so you rarely need the specifics below.

If you bring a model from elsewhere, bongle imports a deliberate subset. Either .gltf or .glb works; the asset pipeline normalizes the source at build time and the engine reads the canonical result. Exactly what it uses:

  • Geometry: triangle meshes with POSITION, optional NORMAL, and one UV set, TEXCOORD_0. Multiple primitives on a mesh are flattened into one. Indices may be unsigned byte, short, or int.
  • Materials: the PBR base-color texture only, sampled through TEXCOORD_0. Metallic-roughness, normal, emissive, and occlusion maps are not used.
  • Animation: node TRS tracks (translation, rotation, scale) with LINEAR, STEP, or CUBICSPLINE interpolation.
  • Hierarchy: the node tree and each node's local transform.

Everything else is ignored: skinning, morph targets, vertex colors, tangents, cameras, lights, and glTF extensions. Because there is no skinning, animation moves whole nodes rather than deforming a mesh, which is why character rigs are built from separate bone nodes (see Animation).

Visual modifiers#

Every rendered mesh carries a set of per-instance, client-only visual fields you drive from script to restyle an instance without touching its geometry or material. The same vocabulary recurs across the renderer, on sprites, voxel meshes, and characters, and particles expose it through their update pool (tintR/G/B/A, glow).

They are plain fields on the trait, read every frame, so you write them directly: vec4.set(mesh.tint, 1, 0, 0, 0.5), mesh.glow = 1.

FieldDoes
tint [r,g,b,a]recolour toward rgb at intensity a, lightness-preserving
flash [r,g,b,a]a transient overlay over the tint but under lighting
glowself-illumination 0–1: light the mesh in its own colour, 1 = shadow-free
litMina minimum light floor so it stays readable in the dark
unlitskip world lighting entirely and render the texture flat
dithera screen-door fade 0–1 that drops fragments to fade an instance out

tint and flash both recolour, but tint is the persistent one (a team colour you set once) while flash is the momentary one you pulse and decay (a red hit-flash, a charge-up glow). litMin, glow, and unlit are three points on a lighting-override scale: litMin lifts the dark floor a little, glow lights the instance in its own colour up to shadow-free, and unlit drops world lighting altogether, for UI overlays, icon meshes, and hologram-style effects. dither is a transparency you can afford in bulk: fragments are discarded against a dither pattern, so it stays in the opaque pass with no sorting or blending (the cost is a slightly pixelly edge). It is how a character mesh fades out when the camera pushes inside it.

Characters#

A character is a node with a Character.Trait, which carries its model, sounds, and effects and pairs with the CharacterController.Trait from Physics. Player nodes get one automatically from their avatar (covered just below); for NPCs you assign one yourself.

Character models follow a canonical humanoid rig, the 6bone rig: a waist hub with body, head, arm_left, arm_right, leg_left, and leg_right bones, plus three attach sockets for gear, hand_left, hand_right, and back:

waist
├── body
│   └── back          (socket)
├── head
├── arm_left
│   └── hand_left     (socket)
├── arm_right
│   └── hand_right    (socket)
├── leg_left
└── leg_right

The feet are origined at world y=0. The bones may sit at scene root or under whatever parent the authoring tool produced; the rig contract only requires the seven bones be present somewhere reachable, so resolve any of them by name with findByName(node, 'head'). The three sockets are always built as persistent rig nodes for mounting held items and back-mounted props; when an avatar doesn't author one, the engine derives its rest transform from the parent bone's geometry, so creators get usable mount points for free, while an authored socket keeps its own transform.

The derived hand sockets are rotated, not just positioned. hand_left and hand_right are given a +90° rotation about X, a grip convention, so a model authored lying along its own +Y ends up gripped rather than sticking out of the fist. back gets no rotation. This matters the moment you mount something and pose the arm yourself, because the socket's rotation sits between the two:

world = (arm bone rotation) · Rx(90°) · (your item's local rotation)

So an item parented to a hand is already rotated by armPitch + 90° before its own transform applies. If you pose the arm, raising it to aim for instance, give the item a local rotation that cancels the total, or it will be held at that angle:

// arm posed 92° forward to aim; socket adds 90°
Transform.setQuaternion(getTrait(gun, Transform.Trait)!, quat.setAxisAngle(quat.create(), [1, 0, 0], -degreesToRadians(92 + 90)));

Deriving that from the same constant you pose the arm with keeps the two in step, so retuning the pose can't silently rotate the item. If you're only mounting gear and letting the engine's own locomotion move the arms, you don't need any of this: the convention already holds the item correctly.

You author character models in the bongle editor, which embeds a build of Blockbench set up for bongle. It starts you from that rig, validates it as you work, and exports an engine-ready glTF in one click.

Avatars#

An avatar is the model a humanoid renders with. Player nodes receive one automatically on join, resolved by the platform, so you rarely touch avatars for players directly. The script-facing API is mainly for NPCs, ambient characters you spawn yourself: sampleAvatars pulls a batch of platform avatars (it resolves to an empty array off-server, so fall back to a default), and loadAvatar loads one and returns the { modelId, rigType } you hand to Character.assignAvatar, which points a node's Character.Trait at that model. Balance each loadAvatar with a releaseAvatar when the NPC despawns, and randomDisplayName gives ambient NPCs a plausible name.

// spawn an NPC and give it a platform avatar. server-only.
system('spawn-npc', (ctx) => {
    if (!env.server) return;

    async function spawnNpc() {
        const avatars = await sampleAvatars(ctx);
        if (avatars.length === 0) return; // none available; fall back to a default

        const npc = createNode({ name: randomDisplayName() });
        addTrait(npc, Transform.Trait);
        addTrait(npc, Character.Trait);
        addChild(ctx.node, npc);

        // load, then point the node's Character.Trait at the model
        const { modelId, rigType } = loadAvatar(ctx, avatars[0]!);
        Character.assignAvatar(npc, modelId, rigType);
    }

    onInit(ctx, () => {
        void spawnNpc();
    });
});

Animation#

Any model that ships clips can be animated, not just characters. bongle plays the glTF's TRS animation tracks, keyframed node translation, rotation, and scale, so a clip moves whole nodes of the model: a crab's legs, a turning gear, a swinging door. There is no skinning, so it does not deform a mesh by bone weights. That makes clips ideal for props, machines, creatures, and one-shot character emotes.

Animation is driven by an Animator.Trait on the model node. It samples the model's clips each tick and blends between them. The rest of the Animator namespace is the script-facing API: Animator.clip(animator, clipDef) resolves one of the model's clips to an Animator.Action, and Animator.play, Animator.stop, Animator.crossFadeTo, and Animator.setEffectiveWeight drive playback and blending. A model's clips are reachable by name off its handle, as CrabModel.animations.idle.

// any glTF that ships clips can be animated, not just characters. bongle plays the
// glTF's TRS tracks (node translation/rotation/scale). there is no skinning.
const CrabModel = model('crab', { src: asset('./assets/crab.gltf', import.meta.url) });

system('crab-anim', (ctx) => {
    onInit(ctx, () => {
        const node = cloneModel(CrabModel.scene);
        addChild(ctx.node, node);

        const animator = getTrait(node, Animator.Trait);
        if (!animator) return;

        // resolve clips to actions, then blend from idle into scuttle
        const idle = Animator.clip(animator, CrabModel.animations.idle);
        const scuttle = Animator.clip(animator, CrabModel.animations.scuttle);
        Animator.play(idle);
        Animator.crossFadeTo(idle, scuttle, 0.3);
    });
});

On a character, prefer procedural animation (below) for ongoing motion. Clip playback writes the same bone TRS as the built-in procedural locomotion and head-look, so the two fight; reserve clips on characters for one-shot emotes layered on top, and let procedural code drive the moment-to-moment pose.

Procedural animation#

Most motion is better computed than keyframed: legs that swing as fast as a creature walks, a head that turns to what it looks at, wings that beat harder as it climbs, a tail that lags behind a turn. Code turns a model's parts each frame from how the thing is moving. What that needs to know about bongle:

  • A part is a node you turn about its pivot. Each group of a Blockbench model is a node named after the group, its origin at the group's pivot, with its boxes inside it. Turning the node turns the part about its joint. Find parts on the model's clone with findByName, by group name (box names can repeat across groups).
  • Turn from the pose it was modelled with. Keep a part's rotation from when you first find it, and compose your turn with it, rather than overwriting it.
  • It runs on clients, in onPostAnimate. How a thing looks is each client's own business, so nothing here replicates. onPostAnimate fires after any clips have sampled this frame and before the pose is drawn, so a turn written there sits on top of a clip instead of being overwritten by it.
  • Drive it from how it moves. Both character controllers keep state.velocity, state.grounded, state.bobPhase (a walk cycle phase that advances with ground speed) and input.look current on every client. Something moved another way can measure its own position change each frame.

A sheep, its legs swinging in diagonal pairs as it walks and its head turning toward where it looks. Its parts are found once on each side and kept on a trait, whose fields stay local to the side:

import {
    addChild,
    addTrait,
    asset,
    cloneModel,
    DynamicCharacterController,
    findByName,
    getTrait,
    model,
    type Node,
    prefab,
    query,
    type ScriptContext,
    type TraitType,
    Transform,
    trait,
} from 'bongle';
import { type Quat, quat, type Vec3, vec3 } from 'math';

/** how far a leg swings each way at walking speed. */
const LEG_SWING_RADIANS = 0.5;
/** the furthest the head turns and tilts from the body. */
const HEAD_TURN_LIMIT_RADIANS = 0.9;
const HEAD_TILT_LIMIT_RADIANS = 0.5;

/** each leg and the way it swings: diagonal pairs step together. */
const LEGS = [
    ['leg_front_left', 1],
    ['leg_back_right', 1],
    ['leg_front_right', -1],
    ['leg_back_left', -1],
] as const;

export const SheepModel = model('sheep', { src: asset('./sheep.glb', import.meta.url) });

/** the model's moving parts and the rotations they were modelled with, found the first time a side draws it. */
export const SheepPartsTrait = trait('sheep-parts', {
    found: false,
    legs: [] as { node: Node; rest: Quat; side: number }[],
    head: null as { node: Node; rest: Quat } | null,
});
export type SheepPartsTrait = TraitType<typeof SheepPartsTrait>;

export const SheepPrefab = prefab('sheep', (ctx) => {
    const sheep = cloneModel(SheepModel.scene);
    addTrait(sheep, DynamicCharacterController.Trait).config.walkSpeed = 1.2;
    addTrait(sheep, SheepPartsTrait);
    addChild(ctx.scene, sheep);
});

export type State = {
    sheep: ReturnType<typeof query<[typeof SheepPartsTrait, typeof DynamicCharacterController.Trait]>>;
};

export function init(ctx: ScriptContext): State {
    return { sheep: query(ctx, [SheepPartsTrait, DynamicCharacterController.Trait]) };
}

const LEFT_TO_RIGHT: Vec3 = [1, 0, 0];
const UP: Vec3 = [0, 1, 0];
const _turn = quat.create();
const _tilt = quat.create();
const _forward = vec3.create();

const clamp = (value: number, limit: number): number => Math.max(-limit, Math.min(limit, value));

function restOf(node: Node): Quat {
    return quat.clone(getTrait(node, Transform.Trait)!.quaternion);
}

function findParts(parts: SheepPartsTrait, sheep: Node): void {
    for (const [name, side] of LEGS) {
        const leg = findByName(sheep, name);
        if (leg) parts.legs.push({ node: leg, rest: restOf(leg), side });
    }
    const head = findByName(sheep, 'head');
    if (head) parts.head = { node: head, rest: restOf(head) };
    parts.found = true;
}

/** a part turned from the rotation it was modelled with, about its pivot. */
function turnPart(part: { node: Node; rest: Quat }, axis: Vec3, radians: number): void {
    quat.setAxisAngle(_turn, axis, radians);
    Transform.setQuaternion(getTrait(part.node, Transform.Trait)!, quat.multiply(_turn, part.rest, _turn));
}

export function animate(s: State): void {
    for (const [parts, walker] of s.sheep) {
        const sheep = walker._node;
        if (!parts.found) findParts(parts, sheep);

        // legs: a stride from the walk cycle, as big as it is walking fast
        const speed = Math.hypot(walker.state.velocity[0], walker.state.velocity[2]);
        const stride = Math.min(speed / walker.config.walkSpeed, 1);
        const swing = Math.sin(walker.state.bobPhase) * LEG_SWING_RADIANS * stride;
        for (const leg of parts.legs) turnPart(leg, LEFT_TO_RIGHT, swing * leg.side);

        // head: toward where it looks, as far as a neck goes
        if (parts.head) {
            vec3.transformQuat(_forward, [0, 0, -1], Transform.getWorldQuaternion(getTrait(sheep, Transform.Trait)!));
            const bodyYaw = Math.atan2(-_forward[0], -_forward[2]);
            const turn = Math.atan2(Math.sin(walker.input.look[1] - bodyYaw), Math.cos(walker.input.look[1] - bodyYaw));
            const tilt = walker.input.look[2] - Math.PI / 2;
            quat.setAxisAngle(_turn, UP, clamp(turn, HEAD_TURN_LIMIT_RADIANS));
            quat.setAxisAngle(_tilt, LEFT_TO_RIGHT, clamp(tilt, HEAD_TILT_LIMIT_RADIANS));
            quat.multiply(_turn, parts.head.rest, _turn);
            Transform.setQuaternion(getTrait(parts.head.node, Transform.Trait)!, quat.multiply(_turn, _turn, _tilt));
        }
    }
}

And the lines in the world system that run it, only on clients:

// how it looks, so every client animates its own view; after any clips, so it poses on top of them
if (env.client) {
    const animation = SheepAnimation.init(ctx);
    onPostAnimate(ctx, () => SheepAnimation.animate(animation));
}

A bat, a bee or a dragon is the same pattern: wings turned on a fast or slow beat, harder as it climbs, a tail's segments each following the one before. Humanoid characters get theirs from Character.Trait (see Characters).

Block meshes#

BlockMesh.Trait draws a single block as a scene node, looking exactly as it does in the world: its model, textures, animation, and glass or water blending all carry over. Use it for held and dropped items, falling blocks, placement previews, and display pedestals. Set block to a block id for its default state or to a full state key like 'oak_slab[type=top]', the same strings setBlock takes. One block is one unit, centred on the node, so it spins about its middle and a node at a cell's centre lines up with the grid. It shares the same render knobs as Mesh.Trait, and it is visual only: it has no collision.

system('spinning-pedestal', (ctx) => {
    onInit(ctx, () => {
        // one block, drawn as a node: a block id for its default state, or a
        // full state key like 'oak_slab[type=top]', exactly what setBlock takes
        const display = createNode({ name: 'display' });
        const transform = addTrait(display, Transform.Trait);
        Transform.setPosition(transform, [0.5, 1.5, 0.5]); // the block is centred on the node
        addTrait(display, BlockMesh.Trait, { block: CrystalBlock.defaultKey() });
        addChild(ctx.node, display);

        let angle = 0;
        onFrame(ctx, ({ delta }) => {
            angle += delta;
            Transform.setQuaternion(transform, quat.setAxisAngle(quat.create(), [0, 1, 0], angle));
        });
    });
});

Block outlines and overlays#

Two traits draw on a block rather than as one: BlockOutline.Trait traces the edges of its shape, Minecraft's outline on the block a player looks at (a stair's step, a crop's height, a fence's post and arms, with no line where a shape's boxes meet), and BlockOverlay.Trait lays a tile over its own faces, Minecraft's cracks while a block is mined. The overlay's tile runs on across a stair's steps rather than following each face's own texture, and its default multiply blend is Minecraft's: a mid grey tile leaves the block as it is, darker darkens, lighter lightens. The kit's tiles.crackStages are ten such stages; pass them to use like any kit content.

Each says which block in one of two ways. Set cell and it draws the world's block in that cell, read every frame, exactly where the world draws it, a plant's jitter and per-position variant included; the node needs no Transform.Trait. Leave cell null and it draws block (and, for the overlay, variant) one unit centred on its node, as BlockMesh.Trait places a block: for a block outside the world's grid, such as a BlockMesh.Trait or a block in a hand. The outline's color and width (in CSS pixels) default to Minecraft's 40% black and 2.

// the block the player looks at, outlined, and cracking while it's mined. One node, no transform: `cell` names a
// block in the world, and both traits draw it where the world does.
use(tiles.crackStages);

system('block-cursor', (ctx) => {
    const hit = createVoxelRaycastHit();
    const cursor = createNode({ name: 'block cursor', realm: 'client' });
    const outline = addTrait(cursor, BlockOutline.Trait);
    const cracks = addTrait(cursor, BlockOverlay.Trait);
    addChild(ctx.scene.root, cursor);

    let mined = 0;
    onFrame(ctx, ({ delta }) => {
        const view = ctx.client?.camera ? getTrait(ctx.client.camera, Transform.Trait) : null;
        const aimed =
            view && raycastVoxels(ctx.voxels, hit, Transform.getWorldPosition(view), [0, 0, -1], 5, { shape: 'selection' });
        const cell = aimed ? ([hit.voxelX, hit.voxelY, hit.voxelZ] as Vec3) : null;
        outline.cell = cell;
        cracks.cell = cell;
        mined = cell ? Math.min(1, mined + delta / 2) : 0;
        cracks.tile = cell ? tiles.crackStages[Math.min(9, Math.floor(mined * 10))]! : null;
    });
});

Structures and voxel meshes#

A structure is a thing built from blocks that moves as one piece: a raft, a cart, a drawbridge, a slab of wall knocked loose, a car a player built. A prop, a vehicle or a creature with a shape of its own is a model instead (see Models and meshes).

Structure.Trait holds a structure's blocks: its voxels, a grid of its own made with createVoxels(ctx.blocks) and painted with setBlock, separate from the world. Other traits point at it by the id of the node holding it, so a node has to be in the scene before anything points at it. VoxelMesh.Trait draws it, with the same greedy-meshed look as the terrain and the same render knobs as Mesh.Trait. A rigid body's voxels shape collides with it (see Rigid bodies). Each takes a pivot, the grid point its node's transform places, rotates and scales about. Any number of meshes and bodies can point at one structure, and they all share its blocks.

Build a structure on the server. A client that sees its node gets its blocks along with it, and the server's mesh and body pointing at it, so the snippet below is all a raft takes:

system('spawn-raft', (ctx) => {
    if (!ctx.server) return; // built once, on the server: the structure reaches every client

    onInit(ctx, () => {
        // a node has its id once it's in the scene, so add it before pointing anything at it
        const raft = createNode({ name: 'raft' });
        addChild(ctx.node, raft);
        Transform.setPosition(addTrait(raft, Transform.Trait), [0, 10, 0]);

        // a grid of its own, separate from the world, painted with setBlock
        const grid = createVoxels(ctx.blocks);
        for (let x = 0; x < 4; x++) {
            for (let z = 0; z < 4; z++) setBlock(grid, x, 0, z, PlankBlock.defaultKey());
        }
        addTrait(raft, Structure.Trait, { voxels: grid });

        // drawn and collided with about the grid's middle, both pointing at the structure by its node's id
        const pivot: Vec3 = [2, 0, 2];
        addTrait(raft, VoxelMesh.Trait, { structure: raft.id, pivot });
        addTrait(raft, RigidBody.Trait, {
            shape: { type: 'voxels', structure: raft.id, pivot },
            motionType: RigidBody.MotionType.KINEMATIC,
        });
    });
});

The grid stays live. setBlock into it on the server, then call Structure.changed to send the structure to clients: every mesh drawing it redraws and every body colliding with it reshapes, on both sides. Calls in one tick send once, and a client never sends its own edits back. Each call sends the whole structure, which suits a structure that changes now and then. A structure edited many times a second is better as a game's own commands.

// knock a plank out of the raft: every mesh drawing it redraws and its body reshapes
function breakPlank(raft: Structure.Trait, x: number, z: number): void {
    if (!raft.voxels) return;
    setBlock(raft.voxels, x, 0, z, BLOCK_AIR);
    Structure.changed(raft); // send the edit to clients
}

A structure also holds a scene: a node tree in the structure's own space that nothing draws and no script runs on, which travels with its blocks. Clone nodes out of it with cloneNodes. Structure.copy(to, from) fills a structure with a copy of another's blocks and nodes, or of a scene once it has loaded, so a structure can be edited without touching what it came from. A structure is never saved with the scene.

Sprites#

Sprite.Trait draws 2D art as a billboard that always faces the camera. Point its sprite at a sprite() handle, whose texture is a file or a texture, computed ones included, or an array of them for animation frames. Size it with width and height (in source pixels) and worldScale, and set fps to play those frames as an animation. Billboards suit items, pickups, foliage, and cheap characters. ExtrudedSpriteMesh.Trait takes the same sprite art but extrudes it into a 3D slab of depth, the chunky paper-craft look (think Crossy Road) that reads from any angle rather than only head-on.

Both take an occlusion: 'world', the default, is depth-tested like any solid, so the world hides it. 'none' draws over the world, which is what a nametag or a waypoint marker wants.

Text#

Text.Trait draws a run of the kit's pixel font in the world. Set text and you have a label; newlines start a new line, and anything outside printable ASCII draws as ?. worldScale is world units per font pixel, align puts the left edge, centre, or right edge on the node, and mode orients the quad exactly like a sprite, billboard by default. It carries the sprite render knobs too, tint, glow, unlit, litMin, dither, visible, and the same occlusion, so a nametag that reads through walls is one field.

// a nametag over each player as they join. the server builds it as a child of the
// player node, so it replicates to everyone and rides the player around
system('nametags', (ctx) => {
    onJoin(ctx, ({ playerNode, user }) => {
        const label = createNode({ name: 'nametag' });
        Transform.setPosition(addTrait(label, Transform.Trait), [0, 2.2, 0]);
        const text = addTrait(label, Text.Trait);
        text.text = user.username;
        text.occlusion = 'none'; // ignores what is in front of it; 'world' is depth-tested like any solid
        text.worldScale = 1 / 32; // world units per font pixel
        addChild(playerNode, label);
    });
});

Under the hood a run is one sprite instance per character, sharing the batch with every other sprite, so labels are cheap in bulk: nametags, floating damage, signs, debug readouts in the world. For rich text, any font, wrapping, or colour per run, reach for a Canvas.Trait (see UI) and pay a texture per node instead.

Glyphs directly#

The font is ordinary sprites, one per printable ASCII character, and bongle/kit hands them out: glyphs(text) returns a handle per character, glyph(char) one at a time, and glyphMetrics gives the width, height, and per-character advance in font pixels to step a run by. Reach for these when a Text.Trait is the wrong shape, because you want the characters to be separate nodes, or to fly apart. A particle takes its sprite at the spawn call, so flinging a character is a single spawnParticle with the glyph handle.

// the kit's font is ordinary sprites, so anything that draws a sprite can draw text
function spellOut(parent: Node, text: string, worldScale = 1 / 16): void {
    const { width, height, advance } = sprites.glyphMetrics;
    sprites.glyphs(text).forEach((glyph, i) => {
        const node = createNode({ name: `glyph-${i}` });
        Transform.setPosition(addTrait(node, Transform.Trait), [i * advance * worldScale, 0, 0]);
        const quad = addTrait(node, Sprite.Trait);
        quad.sprite = glyph;
        quad.width = width;
        quad.height = height;
        quad.worldScale = worldScale;
        addChild(parent, node);
    });
}

// the sprite is a spawn argument, so flinging a character is one call, no declaration
system('glyph-demo', (ctx) => {
    onInit(ctx, () => {
        spellOut(ctx.node, 'HELLO');
        spawnParticle(ctx, {
            sprite: sprites.glyph('7'),
            update: particleUpdate.spark,
            position: [0, 2, 0],
            lifetime: 0.8,
            glow: 1,
        });
    });
});

Particles#

Particles are short-lived sprites for effects like smoke, sparks, and dust. There is no particle type to declare: spawnParticle(ctx, spawn) takes one object describing the whole particle, sprite, update, position and the rest, so the same sprite can be flung with different behaviour and the same behaviour can drive any sprite. The quickest path is a ready-made update: particleUpdate ships complete behaviours (smoke, dust, spark, snow, rain).

// a particle is a sprite plus a motion update, both chosen at the spawn call
const SmokeSprite = sprite('smoke', { texture: asset('./assets/smoke.png', import.meta.url) });

system('smoke-puffs', (ctx) => {
    onInit(ctx, () => {
        // emit one at a position; no-ops on the server
        spawnParticle(ctx, { sprite: SmokeSprite, update: particleUpdate.smoke, position: [0, 2, 0] });
    });
});

For anything past the presets, write your own update. It runs once per live particle each tick with (pool, i, dt, now, voxels), a structure-of-arrays pool where you mutate the i-th particle directly: velX/Y/Z for motion, posX/Y/Z for position, size, glow, and the tintR/G/B/A multiplier (A is alpha). Kill one early by setting pool.expiresAt[i] = 0. Build the body from the composable particleUpdate.* primitives, each taking a strength argument, rather than from scratch:

PrimitiveEffect
gravity(pool, i, dt, g)accelerate downward
drag(pool, i, dt, k)damp velocity toward zero
integrate(pool, i, dt)advance position by velocity
collideSlide / collideBounce / collideLandresolve against voxels
fadeAlpha(pool, i, dt, rate) / fadeRgb(...)fade alpha or colour out
lifeFraction(pool, i, now)0 at spawn, 1 at death

Variety comes from the spawn as much as the update. Beyond the three required fields, a ParticleOptions carries velocity, lifetime, size, tint, glow, playback, fps, and an explicit seed, so a single burst scatters instead of moving in lockstep. The per-particle seed is also readable inside the update for stable per-particle noise.

spawnParticle reads options in full before it returns, the position and velocity vectors included, and retains nothing. So one object can drive a whole burst: declare it once, overwrite what varies, call again. Occasional effects are fine written as a plain object literal; reuse is for the emitters that run every frame. The pool never refuses a spawn either: once it is full, a new particle evicts a live one rather than being dropped.

// for effects past the presets, write your own update: it runs per live particle each
// tick over a pooled buffer, composing the particleUpdate.* primitives and mutating
// the particle's velocity, size, and tint directly. hoist it, then hand it to any spawn.
const SparkSprite = sprite('spark', { texture: asset('./assets/spark.png', import.meta.url) });
const bouncySpark: ParticleUpdateFn = (pool, i, dt, _now, voxels) => {
    particleUpdate.gravity(pool, i, dt, -14); // pull down
    particleUpdate.drag(pool, i, dt, 0.98); // air resistance
    particleUpdate.integrate(pool, i, dt); // advance position by velocity
    particleUpdate.collideBounce(pool, i, dt, voxels, 0.3); // bounce off blocks
    particleUpdate.fadeAlpha(pool, i, dt, 1.2); // fade the alpha out over time
    pool.size[i]! *= 0.99; // shrink a little each tick
};

// emitting in volume? hoist the spawn and mutate it. every field is copied eagerly
// into the pool, so one object can drive the whole burst.
const sparkSpawn: ParticleOptions = {
    sprite: SparkSprite,
    update: bouncySpark,
    position: [0, 3, 0],
    velocity: [0, 0, 0],
    glow: 1, // self-lit, ignores world shadow
};

system('sparks', (ctx) => {
    onInit(ctx, () => {
        // a scattered burst: randomize each particle's velocity, lifetime, and size at
        // spawn so no two move alike.
        const velocity = sparkSpawn.velocity!;
        for (let n = 0; n < 24; n++) {
            velocity[0] = (Math.random() - 0.5) * 6;
            velocity[1] = Math.random() * 8;
            velocity[2] = (Math.random() - 0.5) * 6;
            sparkSpawn.lifetime = 0.6 + Math.random() * 0.6;
            sparkSpawn.size = 0.2 + Math.random() * 0.2;
            spawnParticle(ctx, sparkSpawn);
        }
    });
});

Physics#

Physics runs per room, colliding with the voxel world, simulating on the server, and replicating to clients (optionally with client-side prediction). Three things move under it:

what it isreach for it for
RigidBody.Traita body the solver moves: mass, friction, restitutionprops, crates, balls, anything knocked about
CharacterController.Traita precise walker that steps, crouches, climbs and swimsplayers, and the few NPCs that must move like one
DynamicCharacterController.Traita cheap walker that floats a rigid body above the groundmobs, animals, crowds: anything there are many of

Rigid bodies#

Rigid-body physics in bongle is crashcat, the engine's physics library, and these docs lean into it rather than hide it. crashcat runs the full solver: bodies with a shape, mass, friction, and restitution that collide and respond. Every rigid body in a room is a crashcat body living in the world at ctx.physics.rigid.world.

RigidBody.Trait is a convenience over that. It binds a crashcat body to a scene node, replicates it, and tears it down with the node, so you rarely touch crashcat for the common cases. It carries four things:

fieldwhat it is
shapethe collider geometry. Replacing it rebuilds the body
the config fieldsmotionType, prediction, friction, restitution, mass, sensor, gravityFactor, collisionGroups / collisionMask, damping, limits. Flat on the trait, replicated as one unit
bodythe live crashcat body
linearVelocity, angularVelocity, sleepingsolver output, written back each step

Change the shape and the config fields through RigidBody.update. It marks what changed, so the engine knows to rebuild the body or push the new settings onto the live one, and knows to replicate the slice. A bare write to rb.friction is seen by neither.

The config fields are flat rather than grouped so addTrait takes a partial, since trait props are a shallow Partial and a nested group would have to be supplied whole:

addTrait(node, RigidBody.Trait, { shape: { type: 'sphere', radius: 0.5 }, friction: 0.5 });
// a dynamic body is a node with a RigidBody.Trait. give it a shape to build one.
system('drop-ball', (ctx) => {
    if (!env.server) return; // spawn on the server; physics replicates to clients

    onInit(ctx, () => {
        const ball = createNode({ name: 'ball' });
        const transform = addTrait(ball, Transform.Trait);
        Transform.setPosition(transform, [0, 15, 0]);

        // `RigidBody.update` is how you set a body up and how you change it later; `shape` rebuilds
        // the body, everything else is pushed onto the live one.
        RigidBody.update(addTrait(ball, RigidBody.Trait), {
            shape: { type: 'sphere', radius: 0.5 },
            restitution: 0.4,
            friction: 0.5,
        });

        addChild(ctx.node, ball);
    });
});

shape is a box, sphere, capsule, hull, or mesh, or { type: 'auto', shape } to fit one of those to the meshes under the body. { type: 'voxels', structure, pivot } makes a body of a structure's blocks: each block collides at its own shape (a slab as a slab, a flower not at all), and the body's mass and inertia come from its blocks, so a raft, a cart or an airship can be dynamic or kinematic and carries what stands on it. An edit to the blocks reshapes the body, which keeps its velocity. Until the structure's node is found, the body has no collider. A body of several structures is a compound of voxels shapes. Changing shape is the only edit that costs you the body. Every other field is applied to the body you already have, so tuning friction mid-game keeps its velocity and its contacts.

adopt mode is the escape hatch. Leave shape null, build a crashcat body yourself against ctx.physics.rigid.world with the full crashcat API, and assign it to the trait's body. The engine still drives its config fields, its motion type and its transform, still resolves contacts and raycasts back to the node, and still removes it on dispose (null body first if you want to keep it alive). Reach for this when you need geometry the declarative shapes do not cover.

What adopt mode does not do is replicate the body. A crashcat shape is not serializable, so shape stays null on the wire and a client receives the trait with no body. Build it on both sides, from a system that runs on both.

// "adopt mode": leave `shape` null and hand the trait a crashcat body you built
// yourself, for geometry the declarative shapes do not cover. the engine still drives
// its config, motion type and transform, and still tears it down on dispose.
//
// what it does NOT do is replicate the body: a shape is not serializable, so `shape`
// stays null on the wire and a client gets the trait with no body. build it on both
// sides, from a system that runs on both.
system('custom-body', (ctx) => {
    if (!env.server) return;

    onInit(ctx, () => {
        const crate = createNode({ name: 'crate' });
        addTrait(crate, Transform.Trait); // the body's transform syncs onto this node

        const body = rigidBody.create(ctx.physics.rigid.world, {
            shape: box.create({ halfExtents: [0.5, 0.5, 0.5] }),
            objectLayer: OBJECT_LAYER_NODE_MOVING,
            motionType: RigidBody.MotionType.DYNAMIC,
            position: [0, 12, 0],
            restitution: 0.4,
        });

        const bodyTrait = addTrait(crate, RigidBody.Trait); // shape stays null
        bodyTrait.body = body; // adopt it; the engine owns it from here

        addChild(ctx.node, crate);
    });
});

Character controller#

CharacterController.Trait is a kinematic mover for players and NPCs: it walks, steps, and slides against the world without the wobble of a dynamic body. It pairs with Character.Trait for the visible body, covered under Characters. You move it in one of three ways: drive its input, tune its config, or write its state.velocity directly.

Driving with input#

input is how you steer the controller. input.move is a planar [strafe, forward] vector, input.look is the [_, yaw, pitch] look spherical, and input.jump, input.sprint, and input.crouch are held flags. The controller turns those into motion each tick.

For a player, a PlayerController.Trait fills input from device input for you. For an NPC you write it yourself: set input.move to steer, and aim with CharacterController.setLook(controller, yaw, pitch?) or CharacterController.setLookAt(controller, target), which points the character at a world position through its eyes, rather than writing the look angles by hand. The Pathfinding snippet drives an NPC exactly this way.

Tuning with config#

config holds the tunables that shape motion. The controller reads it live each tick, so you can change a field at runtime for a status effect or a per-block surface.

config fieldDefaultControls
walkSpeed5base ground speed, m/s
sprintSpeed6.5ground speed while input.sprint is held
crouchSpeed1.3ground speed while input.crouch is held
jumpSpeed7upward launch speed on a jump, m/s
sprintJumpImpulse4extra forward kick added to a sprinting jump
gravity20downward acceleration, m/s²
terminalVelocity40cap on fall speed, m/s
groundDragRate12how fast horizontal speed bleeds off on the ground
airDragRate0.85the same in the air (low, so momentum carries)
airAccel13how hard input.move steers you mid-air
stepHeight0.55tallest lip the controller auto-steps up

A double-jump power-up is config.jumpSpeed = 14. An ice patch is a low config.groundDragRate.

Writing velocity directly#

state.velocity is the controller's live motion vector and state.grounded is whether it is on the ground; you can write both. Where input is a request the sim interprets, these are the motion itself, so they are the escape hatch for anything the input knobs can't express: a launch pad, a dash, an explosion knockback. Add an impulse to velocity and clear grounded so ground friction doesn't eat it, and the controller integrates it next tick. See the launch pad recipes (block, node) for worked examples.

Moving platforms and bases#

A character standing on a moving body (a lift, a raft, a spinning disc) moves with it: it is carried by the body's motion where its feet are, so it rides a turntable round rather than sliding off it, and it keeps the body's speed when it steps or jumps off. It doesn't turn with the body, and it is let go the moment it stops standing on it. state.velocity is the character's own motion on top of that, so a character standing still on a raft reads as standing still; state.groundVelocity is what the body is carrying it by. Every player sees a character on a moving body exactly where it stands on their own copy of the body.

For something players spend time aboard, a ship or a train, make the body the character's base with CharacterController.setBase(controller, node). A base also turns the character with it, and carries it until CharacterController.clearBase(controller), through jumps, falls and climbs, whatever it stands on. Call both where the character is stepped, on its owner; a ship game sets the base when a player boards and clears it when they leave.

Respawning#

Respawning is the mirror image: zero the velocity so a long fall's downward speed doesn't carry into the new spot and immediately launch the player off it. Teleport the feet with Transform.setPosition, then vec3.set(controller.state.velocity, 0, 0, 0).

// respawn: teleport the feet and zero velocity so accumulated fall speed doesn't
// carry into the new position and immediately fling the player off it. A respawn
// also leaves any ship it was aboard, so it clears the base.
export function respawn(node: Node, feet: Vec3): void {
    const transform = getTrait(node, Transform.Trait);
    const controller = getTrait(node, CharacterController.Trait);
    if (transform) {
        Transform.setPosition(transform, feet);
        Transform.teleport(transform);
    }
    if (controller) {
        vec3.set(controller.state.velocity, 0, 0, 0);
        CharacterController.clearBase(controller);
    }
}

Dynamic character controller#

DynamicCharacterController.Trait walks a node around as a dynamic rigid body floating above the ground: one ray cast down from its middle holds it at config.rideHeight, and it is pushed toward the speed its input asks for. It costs a fraction of a CharacterController.Trait, and nothing at all while it stands still, since its body goes to sleep. Use it for the things a game has many of: sheep, zombies, villagers. What it gives up is precision: it does not crouch, climb, swim or guard ledges, and it jostles against others like the body it is.

Its surface is the character controller's. input.move is [strafe, forward], input.look the [_, yaw, pitch] spherical, input.jump and input.sprint held flags, and DynamicCharacterController.setLook / setLookAt aim it the same way. Its body turns to face the way it walks, at config.turnRate, so a model facing -Z walks forwards; standing still, it keeps the facing it was placed with. input.look is where it looks, for a head to follow. The node's origin is its feet.

Add it to the node that is the thing, in a prefab the model's clone, and a RigidBody.Trait comes with it (with interpolation). It runs in play rooms only: in an edit room it stays where it was put. A module steers every one of them from a query:

import {
    addChild,
    addTrait,
    asset,
    cloneModel,
    DynamicCharacterController,
    model,
    prefab,
    query,
    type ScriptContext,
    trait,
} from 'bongle';

const WALK_SECONDS = 3;
const REST_SECONDS = 5;

export const SheepModel = model('sheep', { src: asset('./sheep.glb', import.meta.url) });
export const GrazeTrait = trait('graze', { walking: false, switchIn: 0 });

export const SheepPrefab = prefab('sheep', (ctx) => {
    const sheep = cloneModel(SheepModel.scene);
    addTrait(sheep, DynamicCharacterController.Trait).config.walkSpeed = 1.2;
    addTrait(sheep, GrazeTrait);
    addChild(ctx.scene, sheep);
});

export type State = {
    grazing: ReturnType<typeof query<[typeof GrazeTrait, typeof DynamicCharacterController.Trait]>>;
};

export function init(ctx: ScriptContext): State {
    return { grazing: query(ctx, [GrazeTrait, DynamicCharacterController.Trait]) };
}

export function tick(s: State, step: number): void {
    for (const [graze, walker] of s.grazing) {
        graze.switchIn -= step;
        if (graze.switchIn <= 0 || (graze.walking && walker.state.blocked)) {
            graze.walking = !graze.walking || walker.state.blocked;
            graze.switchIn = graze.walking ? WALK_SECONDS : REST_SECONDS * Math.random();
            if (graze.walking) DynamicCharacterController.setLook(walker, Math.random() * Math.PI * 2);
        }
        walker.input.move[1] = graze.walking ? 1 : 0;
    }
}
config fieldDefaultControls
halfExtents[0.3, 0.45, 0.3]half-size of the body's box, which floats above the feet
rideHeight0.55gap under the box: lower steps are walked straight over
hopHeight1.05a rise up to this high that it walks into is hopped onto; 0 never hops
walkSpeed / sprintSpeed2.5 / 5ground speed, m/s
acceleration / airAcceleration30 / 6how fast it reaches that speed, and stops, m/s²
jumpSpeed6upward launch speed on a jump, m/s
turnRate8how fast it turns to face the way it walks, rad/s
eyeHeight1.2where setLookAt aims from
collisionGroups / collisionMasksee belowwhat it bumps into

state has what a game reads back: grounded, velocity, groundBlockState (the block it stands on), bodyYaw, bobPhase (a walk cycle phase advancing with ground speed, for swinging legs), and blocked, set while it pushes into something it can't hop. A wandering mob picks a new way when it is blocked.

Its body is a node body like any other, in COLLISION_GROUP_NODES, and collides with everything, so dynamic characters bump each other and players. Choose otherwise with its config:

tocollisionMask
bump everything (default)0xffffffff
pass through playersexceptGroups(COLLISION_GROUP_CHARACTERS)
only the worldonlyGroups(COLLISION_GROUP_VOXELS)

To tell your mobs apart, from each other or from crates, give them a group of your own (see Collision groups): collisionGroups: Groups.mobs with collisionMask: exceptGroups(Groups.mobs) makes them pass through each other, and a sensor matching Groups.mobs notices only them.

Contacts#

To run game logic when bodies touch, add a Contacts.Trait to a node. After each physics step it holds that node's contacts for the step, split into added (first seen this step), persisted (ongoing), and removed (gone this step). Each entry carries the contact point and normal, and a type that says what was touched: a rigidBody contact carries the other nodeId, which you match against your own nodes to tell what you touched; a voxel contact the terrain block's voxelX/voxelY/voxelZ and stateId; and a voxelRigidBody contact, against a body made of blocks, both: the nodeId, and the block it touched in that body's own grid rather than the world's. Read these in onPostPhysicsStep, which runs once the contacts are populated.

A coin pickup is the canonical example: give each coin a sensor body and a Contacts.Trait, then award and despawn it the moment a player's body shows up in its added list.

// CoinTrait marks a pickup; `value` is how much it is worth.
const CoinTrait = trait('coin', { value: 1 });

// a coin is a static sensor body carrying a Contacts.Trait, so players pass
// through it but still register a contact.
function spawnCoin(parent: Node, position: Vec3) {
    const coin = createNode({ name: 'coin' });
    Transform.setPosition(addTrait(coin, Transform.Trait), position);
    addTrait(coin, CoinTrait);
    addTrait(coin, Contacts.Trait);
    RigidBody.update(addTrait(coin, RigidBody.Trait), {
        shape: { type: 'sphere', radius: 0.5 },
        motionType: RigidBody.MotionType.STATIC,
        sensor: true,
    });
    addChild(parent, coin);
}

system('coins', (ctx) => {
    if (!env.server) return; // the server owns pickups

    // per-room running total. factory-scope state lives in this one script
    // instance (one per world node), never module scope, which every room shares.
    let coinsCollected = 0;

    const coins = query(ctx, [CoinTrait, Contacts.Trait]);
    const players = query(ctx, [Player.Trait]);

    onInit(ctx, () => {
        spawnCoin(ctx.node, [2, 1, 0]);
        spawnCoin(ctx.node, [4, 1, 0]);
    });

    // Contacts.Trait fills `added` after each physics step; award and despawn any
    // coin a player's body just touched.
    onPostPhysicsStep(ctx, () => {
        const playerNodeIds = new Set<number>();
        for (const [player] of players) playerNodeIds.add(player._node.id);

        for (const [coin, contacts] of coins) {
            const touchedByPlayer = contacts.added.some((c) => c.type === 'rigidBody' && playerNodeIds.has(c.nodeId));
            if (touchedByPlayer) {
                coinsCollected += coin.value;
                debug.log(ctx, `coin collected (total ${coinsCollected})`);
                destroyNode(coin._node);
            }
        }
    });
});

For lower-level control, onPhysicsContact(ctx, 'added' | 'persisted', fn) fires during the step with the raw crashcat bodies and manifold, and lets you tune the contact in place, such as zeroing friction for an ice patch or flagging it a sensor.

Sensors#

A sensor is a body that detects overlaps without colliding: other bodies pass straight through it, but the overlap still registers as a contact. Sensors are how you build triggers, pickups, and zones. Set sensor: true through RigidBody.update (or in the crashcat body settings in adopt mode), pair the node with a Contacts.Trait, and react to what enters in onPostPhysicsStep. The coin pickup above is a worked sensor: a static sensor body that awards and despawns the instant a player overlaps it.

The player controller#

PlayerController.Trait drives a player node from input: each frame it reads movement and look and moves the character controller and the camera, so you do not write that math by hand. Every player node already carries it (see Players), and it ships first-person by default with a built-in C key that cycles through the perspectives while playing.

Configure it through its config. The camera and field of view are per-client view concerns, so set them on the controlling client:

config fieldDefaultControls
perspective'first'the view: 'first', 'third-back', or 'third-front'
thirdPersonDistance4camera distance behind the player in third-person
cameraCollisionMargin0.2how far the camera stays off walls it would clip through
fov75°field of view, in radians
fovSprint85°field of view while sprinting
fovLerpSpeed10how fast the fov eases between the two
// view config is per-client, so configure it on the client, for our own player only.
system('view-setup', (ctx) => {
    const client = ctx.client;
    if (!client) return;

    onQueryEnter(ctx, query(ctx, [PlayerController.Trait]), (controller) => {
        if (controller._node !== client.player) return;
        controller.config.perspective = 'third-back';
        controller.config.thirdPersonDistance = 6;
        controller.config.fov = (80 * Math.PI) / 180; // radians
    });
});

Scene queries#

To ask "what is here" or "what does this ray hit", query the world. Blocks and bodies live in two separate systems, but a ray does not care: raycast casts through both at once and tags each hit with what it found.

Raycasting#

raycast(ctx, collector, origin, direction, maxDistance, options?) casts through the node rigid bodies, the characters, and the voxel terrain in one call. Results land in a collector you allocate once with createRaycastCollector() and reuse: it hands back pooled hits, so a cast in a hot loop allocates nothing. direction does not need to be normalized.

Read the results off collector.hits, nearest-first. Each hit carries point, normal, distance and fraction, plus a type that says which world it came from: a rigidBody hit adds the node that owns the body (a character included), while a voxel hit adds voxelX/voxelY/voxelZ, the stateId of the block, and hitIndex for the face. A voxelRigidBody hit, on a body made of blocks, adds both the node and the block it hit, its voxelX/voxelY/voxelZ in the body's own grid rather than the world's. Narrow on hit.type and TypeScript gives you the right fields.

The hits are recycled on the collector's next cast, so read what you need before casting again.

// one raycast covers both worlds: node rigid bodies, characters, and the voxel
// terrain. every hit is tagged, so narrow on `hit.type` to see what you hit.
system('look-at', (ctx) => {
    // one collector per script instance, reused by every cast it makes.
    const collector = createRaycastCollector();

    onInit(ctx, () => {
        // `closest` is the default, so `hits` holds 0 or 1.
        raycast(ctx, collector, [0, 10, 0], [0, -1, 0], 32);

        const hit = collector.hits[0];
        if (!hit) return;

        if (hit.type === 'rigidBody') {
            // hit.node is the node that owns the body, a character included
            debug.log(ctx, 'hit node', hit.node.name, 'at', hit.distance);
        } else if (hit.type === 'voxelRigidBody') {
            // a body made of blocks: hit.voxelX/Y/Z is the cell in the body's own grid, not the world's
            debug.log(ctx, 'hit block', hit.stateId, 'of node', hit.node.name);
        } else {
            // hit.voxelX/Y/Z is the world block cell, hit.stateId which block kind
            debug.log(ctx, 'hit block', hit.voxelX, hit.voxelY, hit.voxelZ);
        }
    });
});

Ray modes#

One collector serves all three modes, so switching between them changes nothing about how you read the results.

modehits holdsuse it for
closest (default)the nearest hit, 0 or 1a shot, a build cursor, a pick
allevery hit, nearest-firsta piercing shot, a laser through a crowd
anythe first hit found, 0 or 1line of sight, "is anything in the way"

any is the cheapest: it stops at the first hit, with no sorting and no nearest-hit search. Reach for it whenever the answer is a yes or no rather than a what.

One limit worth knowing on all: it reports every body along the ray, but only the first terrain block, because the voxel walk stops at the block it hits. A piercing shot through several bodies is exact; one through several walls is not.

// the same collector serves all three modes; `hits` is how you read every one.
system('shooting', (ctx) => {
    if (!env.server) return;

    const collector = createRaycastCollector();

    onPostPhysicsStep(ctx, () => {
        // a normal shot. exclude the shooter so the ray does not open inside its
        // own collider. `direction` does not need to be normalized.
        raycast(ctx, collector, muzzle, aim, 100, { exclude: ctx.node });
        const struck = collector.hits[0];

        // a piercing shot: every hit along the ray, nearest first.
        raycast(ctx, collector, muzzle, aim, 100, { mode: 'all', exclude: ctx.node });
        for (const hit of collector.hits) debug.log(ctx, 'pierced', hit.type, hit.distance);

        // a line-of-sight check. `any` stops at the first thing in the way, which
        // makes it the cheapest mode: no sorting, and no nearest-hit search.
        raycast(ctx, collector, muzzle, aim, 50, { mode: 'any', exclude: ctx.node });
        const canSee = collector.hits.length === 0;

        debug.log(ctx, struck?.type ?? 'miss', canSee);
    });
});

Filtering a cast#

options scopes what a ray may hit, and every field is optional:

optiondefaulteffect
nodestruetest node rigid bodies and characters
voxels'collision'test blocks by what collides, by what a player aims at ('selection'), or not at all (false)
collisionGroupsevery groupthe ray's own groups, matched against each body's mask
collisionMaskevery groupwhich groups the ray collides with
excludenonea node, or nodes, the ray passes through
sensorsfalsereport sensor bodies

exclude is the one you reach for most: pass the shooter so a projectile does not open inside its own collider. Groups and masks are the same bitfields the simulation uses, described under Collision groups, so a mask without COLLISION_GROUP_VOXELS skips the terrain exactly as voxels: false does.

// filter a cast by what it may hit. terrain and bodies opt out independently, and
// collision groups scope it the same way they scope the simulation.
system('filtered-casts', (ctx) => {
    const collector = createRaycastCollector();

    onInit(ctx, () => {
        // bodies only, straight past the terrain
        raycast(ctx, collector, [0, 10, 0], [0, -1, 0], 32, { voxels: false });

        // terrain only, ignoring every body
        raycast(ctx, collector, [0, 10, 0], [0, -1, 0], 32, { nodes: false });

        // characters only, by collision group
        raycast(ctx, collector, [0, 10, 0], [0, -1, 0], 32, {
            collisionMask: onlyGroups(COLLISION_GROUP_CHARACTERS),
        });

        // sensors are skipped unless you ask for them
        raycast(ctx, collector, [0, 10, 0], [0, -1, 0], 32, { sensors: true });
    });
});

Only nodes, or only blocks#

raycast is two casts put together, and each is there on its own. raycastNodes takes the same arguments, collector and modes, and tests only nodes.

raycastVoxels(voxels, out, origin, direction, maxDistance, options?) tests only blocks, and is the fast path for them: no collector, just the nearest block written into one VoxelRaycastHit you allocate once with createVoxelRaycastHit(). It returns whether it hit, and sets the hit's hit to the same, so code that reads the hit later knows whether the last cast found anything. Every hit has hit; one in a collector's hits is always true.

options.shape picks which of a block's shapes the ray meets: 'collision' (the default) for what physics collides with, or 'selection' for what a player aims at, so a build cursor reaches crops and flowers that collision would walk straight through. Each also picks which blocks stop the ray: blocks that collide, or blocks that can be selected. Liquids can't be selected, so aiming, mining and building reach through water, from under it too. options.flags replaces that choice with block flags, any of which stops the ray: BLOCK_FLAG_SELECTION | BLOCK_FLAG_LIQUID stops at water or the first block that can be selected, as a bucket does, and 0 stops at any block. A ray that starts inside a block it stops at hits that block.

// the block a player looks at, by what they see rather than what collides: a build
// cursor that reaches crops and flowers too.
system('block-pick', (ctx) => {
    const hit = createVoxelRaycastHit();
    const AIMED = { shape: 'selection' } as const;

    onInit(ctx, () => {
        if (raycastVoxels(ctx.voxels, hit, [0, 10, 0], [0, -1, 0], 32, AIMED)) {
            // hit.normal is the face's outward normal; the cell beyond it is where a placed block goes
            debug.log(ctx, 'looking at', hit.voxelX, hit.voxelY, hit.voxelZ, 'face', hit.normal);
        }
    });
});

Going below raycast#

raycast covers rays. For the queries it does not wrap, shape casts and overlap tests, the crashcat world is still right there at ctx.physics.rigid.world and you can call its API directly, the same escape hatch described under Rigid bodies. There you work in object layers: OBJECT_LAYER_VOXELS is the terrain body, and OBJECT_LAYER_NODE_MOVING / OBJECT_LAYER_NODE_NOT_MOVING are dynamic and static node bodies. Build a filter with filter.forWorld(world) (every layer on), then disableObjectLayer the ones to skip.

Collision groups#

Layers decide which broad category a query or the simulation considers; collision groups give finer, per-body control through a group and mask bitfield, set on a RigidBody.Trait def as collisionGroups and collisionMask. Two bodies collide only when each one's group is in the other's mask.

Your game owns bits 0 to 20, so 1 << 0 up to 1 << 20 are yours: at most 21 groups of your own. Bits 21 to 31 are reserved for the engine, so never use them. Its own bodies are tagged with COLLISION_GROUP_VOXELS, COLLISION_GROUP_NODES and COLLISION_GROUP_CHARACTERS from that range; refer to them by name, not by bit.

Characters use this by default: their mask excludes COLLISION_GROUP_CHARACTERS, so they pass through each other Minecraft-style while still colliding with the world and other bodies. Change it through a CharacterController.Trait's config.collisionGroups / config.collisionMask (applied live each tick); collisionMask: 0xffffffff re-enables character-vs-character collision.

Declare your own groups with defineCollisionGroups, which hands out a named bit per name starting at bit 0, and throws past 21 names. Assignment is positional, so it matches on every side; groups are not synced, so call it once with a fixed list. Build masks with onlyGroups(...) (collide with only these) and exceptGroups(...) (collide with all but these). Reach for groups when a layer is too coarse for the rule you want: projectiles that pass through their own team, entities that ignore each other but not the world, triggers only certain bodies activate.

// declare a game's groups once, in a fixed order. each name gets a bit from 0 up,
// clear of the engine's reserved bits; assignment is positional, so it is
// identical on every side (groups are not synced, so never build the list
// conditionally).
const Groups = defineCollisionGroups('enemies', 'pickups');

system('group-demo', (ctx) => {
    if (!env.server) return;

    onInit(ctx, () => {
        // an enemy ignores other enemies but still collides with the world and
        // everything else. `exceptGroups` = "collide with all but these".
        const enemy = createNode({ name: 'enemy' });
        Transform.setPosition(addTrait(enemy, Transform.Trait), [0, 5, 0]);
        RigidBody.update(addTrait(enemy, RigidBody.Trait), {
            shape: { type: 'sphere', radius: 0.4 },
            collisionGroups: Groups.enemies,
            collisionMask: exceptGroups(Groups.enemies),
        });
        addChild(ctx.node, enemy);

        // a pickup only reacts to characters (players / npcs), nothing else.
        // `onlyGroups` = "collide with only these".
        const pickup = createNode({ name: 'pickup' });
        Transform.setPosition(addTrait(pickup, Transform.Trait), [2, 1, 0]);
        RigidBody.update(addTrait(pickup, RigidBody.Trait), {
            shape: { type: 'sphere', radius: 0.5 },
            motionType: RigidBody.MotionType.STATIC,
            sensor: true,
            collisionGroups: Groups.pickups,
            collisionMask: onlyGroups(COLLISION_GROUP_CHARACTERS),
        });
        addChild(ctx.node, pickup);
    });
});

Pathfinding#

For NPCs that navigate the voxel world, the nav namespace provides grid pathfinding over the blocks. nav.findPath runs A-star from a start cell to a goal, and nav.smoothPath straightens the result into fewer waypoints. Finding the route is all they do; moving along it is your job.

Both write into a Path you own, made with nav.createPath(), rather than returning a fresh one. findPath returns whether it reached the goal, and either way fills the path: with the route to the goal, or, when the goal is cut off or past maxIterations (1024 cells by default), with the route to the cell that came closest to it, so an agent walks as far as it can. A goal an agent can't stand in, such as a player mid-jump, is reached from nearby with reach, the distance in cells that counts as arrived. Hold one per agent and reuse it, and repathing on a timer costs no allocation once each path has reached its longest. Read path.count rather than path.cells.length, since the pooled cells past count belong to the previous route and are left in place rather than truncated. smoothPath reads one path and writes another, so an agent that smooths keeps two.

Positions and cells are different things, and nav works in cells, so crossing between them has helpers. In: nav.ground(out, voxels, position, mover) gives the cell a walker stands in from its node's position, its feet. It allows for feet a hair under a block's top (0.9999...), which flooring would put inside the block below; puts an agent in the air on the ground beneath it; and stands one over a ledge on whichever corner of its body has ground, as Minecraft's pathfinding does. nav.cell(out, position) is the plain cell holding a point, for anything that is not standing on the ground. Out: nav.cellFloor(out, cell) is the middle of a cell's floor, where a walker steers to, and nav.cellCenter(out, cell) its middle, for a flyer.

How an agent gets about is its mover: nav.preset.ground({ size, radius, maxDrop }) builds one for walking on the blocks, its body size cells (a sheep or a person is [1, 2, 1]) and radius metres wide. It holds the moves findPath expands (actions: flat ground, a step up or down one block, and drops off ledges up to maxDrop), the line-of-sight test smoothPath merges straight runs with (shortcut), and where the body fits (walkable), all made from the one size so they agree. Pass its parts as you would your own.

The snippet below gives walking to a goal a trait of its own, so only the nodes that carry it navigate, and keeps each node's paths in the trait's state. A system runs the loop for every walker: into nav with ground, repath on a timer, drop waypoints as they're reached, and steer the node's DynamicCharacterController.Trait toward the next cellFloor by setting the controller's look yaw and forward input each tick.

// how it gets about, all from one body: the moves the search makes (`actions`), the
// line-of-sight test that merges straight runs (`shortcut`), and where it can stand
const WALKER = nav.preset.ground({ size: [1, 2, 1], maxDrop: 3 });

// a node that walks to `goal`, a cell; set it from game code (toward the nearest
// player, for a chaser). It steers the node's DynamicCharacterController.Trait.
// `state` holds two routes, allocated once and refilled on every repath:
// findPath writes `route`, smoothPath reads it and writes `path`. They grow to
// their high-water mark and then stop allocating, so the tick is allocation-free.
export const WalkToTrait = trait('walk-to', {
    goal: (): Vec3 => [0, 0, 0],
    state: () => ({ route: nav.createPath(), path: nav.createPath(), waypoint: 0, repathIn: 0 }),
});

system('walk-to-goal', (ctx) => {
    if (!env.server) return; // the server moves NPCs; the result replicates

    const walkers = query(ctx, [WalkToTrait, Transform.Trait, DynamicCharacterController.Trait]);
    const _start: Vec3 = [0, 0, 0];
    const _waypoint: Vec3 = [0, 0, 0];

    onTick(ctx, ({ step }) => {
        for (const [walk, transform, controller] of walkers) {
            const { path } = walk.state;
            const pos = Transform.getWorldPosition(transform);

            // repath a couple of times a second rather than every tick. When the goal can't
            // be reached, findPath still routes to the closest cell it found, so the node
            // walks as far as it can.
            walk.state.repathIn -= step;
            if (walk.state.repathIn <= 0) {
                walk.state.repathIn = 0.5;
                // into nav: the cell its feet stand in, from where they are
                nav.ground(_start, ctx.voxels, pos, WALKER);
                nav.findPath(walk.state.route, ctx.voxels, _start, walk.goal, WALKER.actions, { reach: 1 });
                nav.smoothPath(path, ctx.voxels, walk.state.route, WALKER.shortcut);
                walk.state.waypoint = 1; // skip the cell it's standing in
            }

            // drop waypoints already reached (horizontal distance only). `count` is the live
            // length, not `cells.length`: the pool keeps stale cells past it.
            while (walk.state.waypoint < path.count) {
                nav.cellFloor(_waypoint, path.cells[walk.state.waypoint]!);
                const dx = _waypoint[0] - pos[0];
                const dz = _waypoint[2] - pos[2];
                if (dx * dx + dz * dz > 0.25) break;
                walk.state.waypoint++;
            }

            if (walk.state.waypoint >= path.count) {
                controller.input.move[1] = 0; // arrived, or as close as it gets: stand still
                continue;
            }

            // out of nav: steer to the middle of the next waypoint's floor, face it, then walk
            // forward. The controller hops up single-block steps on its own.
            nav.cellFloor(_waypoint, path.cells[walk.state.waypoint]!);
            DynamicCharacterController.setLook(controller, Math.atan2(-(_waypoint[0] - pos[0]), -(_waypoint[2] - pos[2])));
            controller.input.move[1] = 1;
        }
    });
});

A CharacterController.Trait is steered the same way, by its look yaw and forward input, but it doesn't hop steps on its own: set input.jump while state.horizontalCollision is set.

The parts are there to use on their own, too: nav.groundActions (flat ground and one-block steps), nav.groundDropActions({ size, maxDrop, dropCost }) (those, plus drops off ledges), nav.groundWalkable(size) and nav.groundShortcut(walkable). A mover is plain data, { size, radius, walkable, actions, shortcut }, so build one from them for other moves. To add gap-jumps, spread nav.groundMoves with your own longer offsets and make the moves with nav.gridActions(moves, nav.groundWalkable(size)). For anything beyond a fixed offset set, such as ladders, doors, or context-dependent cost, write your own Actions: a function that, given a cell, calls step(x, y, z, cost) for each neighbour the agent can reach from it.

Players & input#

Players#

Each connected client has a player node that the engine creates on join, already carrying a default set of traits:

TraitGives the player
Transform.Traita position, rotation, and scale
Player.Traitidentity: its playerId, username, and owning client
Character.Traitthe humanoid rig and visuals (its avatar)
CharacterController.Traita kinematic controller for movement and collision
PlayerController.Traitreads input and drives the controller and the camera

The node is owned by its client, so its movement is owner-authoritative. The local player is ctx.client.player; a joining player arrives as the playerNode in onJoin, as the kit's spawn script uses. Add your own gameplay traits, health, score, an inventory, to it in onJoin, and you usually drive movement with the PlayerController.Trait rather than writing it from scratch.

Reading input#

Input is client only and polled once per frame. Read it in onInput, the hook that fires first each frame, ahead of every onUpdate and onTick: set your movement and action intent there and everything later in the frame, including the player controller and the tick simulation, sees it. (onUpdate also runs before the ticks, but reaching for it to read input is rarely what you want.) Reach input through ctx.client.input, which holds .mouseKeyboard for keyboard and mouse and .touch for touch. The predicates all take the input instance as their first argument and report this frame's state:

// onInput runs first each frame, so read input and set intent here
system('read-input', (ctx) => {
    onInput(ctx, () => {
        if (!ctx.client) return;
        const mouseKeyboard = ctx.client.input.mouseKeyboard;
        const forward = isKeyDown(mouseKeyboard, 'KeyW');
        const back = isKeyDown(mouseKeyboard, 'KeyS');
        if (forward !== back) {
            // drive movement, aim a weapon, etc.
        }
    });
});

These take the mouseKeyboard input. code is a KeyboardEvent.code such as 'KeyW' or 'Space', and button is 'left', 'middle', or 'right'.

PredicateReads
isKeyDown(mouseKeyboard, code)key is held this frame
isKeyJustDown(mouseKeyboard, code)key went down this frame (press edge)
isKeyJustUp(mouseKeyboard, code)key went up this frame (release edge)
isMouseDown(mouseKeyboard, button)mouse button is held
isMouseJustDown(mouseKeyboard, button)button went down this frame
isMouseJustUp(mouseKeyboard, button)button went up this frame
isMouseTap(mouseKeyboard, button)a quick press-and-release landed this frame
isMouseDragStart(mouseKeyboard, button)a drag began this frame
getCursor(mouseKeyboard)primary pointer position over the canvas: x/y in CSS px, ndcX/ndcY normalized (pinned to 0,0 under pointer lock)

The mouse predicates are driven by pointer events, so the first finger on a touchscreen reads as the left button and moves the cursor. A second finger never does; it belongs to the multi-touch gestures below.

Touch input (joysticks, buttons, pinch) is read with its own predicates, covered under Touch controls.

Touch controls#

A PlayerController.Trait handles the basics for you: on a touch device it auto-mounts a movement joystick and a jump button and reads them, so walking and jumping work on mobile with no extra code. It reads them at the well-known ids in PlayerController.TouchIds (move, jump, sprint, crouch), so to reposition or restyle one you mount your own joystick or button at that id and the controller still picks it up.

For game-specific actions, mount your own controls with createTouchJoystick and createTouchButton, then read them with getJoystick(touch, id) and isTouchButtonDown(touch, id), where touch is ctx.client.input.touch. Both factories mount under the room's touch overlay and return a handle whose dispose() you call from onDispose, or null where there is no touch overlay to mount on, such as on the server. A button with look: true doubles as an aim surface: dragging it rotates the camera while it is held.

Gate your controls on isTouchPrimary, a coarse-pointer check, rather than screen size, so tablets and touch laptops get them too. Keep isMobile, which is true only on a small touch screen, for laying out a compact HUD, not for deciding whether to show touch controls at all.

// a PlayerController.Trait already auto-mounts a move joystick and jump button on
// touch devices. mount game-specific controls yourself, gated on isTouchPrimary so
// tablets and touch laptops get them too, not just small phone screens.
system('touch-controls', (ctx) => {
    if (!ctx.client || !isTouchPrimary(ctx)) return;

    // createTouchButton mounts under the room's touch overlay and returns a
    // disposer (it no-ops and returns null on the server).
    const fireButton = createTouchButton(ctx, {
        id: 'fire',
        right: 24,
        bottom: 24,
        width: 96,
        height: 96,
        label: 'Fire',
        look: true, // dragging the button also rotates the camera, so it doubles as an aim surface
    });

    onInput(ctx, () => {
        const touch = ctx.client?.input.touch;
        if (touch && isTouchButtonDown(touch, 'fire')) {
            // set fire intent for this frame
        }
    });

    onDispose(ctx, () => fireButton?.dispose());
});

Audio#

Declaring sounds#

sound(id, { src }) declares a sound at module scope and returns the handle you play. Short effects are packed into one audio atlas that loads with the game, so every play is instant. Mark music and long ambience long: true to keep it out of the atlas: it loads on its own the first time it plays, so the game starts without waiting for it.

The kit ships a set of CC0 sounds as sounds from bongle/kit, and like any kit content a sound is only in the game once you use() it.

// short effects are packed into one atlas the game loads up front, so they play instantly
const ChimeSound = sound('chime', { src: asset('./assets/chime.ogg', import.meta.url) });

// long audio stays out of the atlas and loads on its first play
const ThemeMusic = sound('theme', { src: asset('./assets/theme.ogg', import.meta.url), long: true });

// kit sounds are a library like kit blocks: use() the ones the game plays
use(sounds.place1, sounds.dugNode1);

A block's sounds lists clips for walking on it (footstep), mining it (dig), breaking it (break) and placing it (place). The kit's blocks use the presets in blockSoundPresets, which your own blocks can share: block('marble', { ..., sounds: blockSoundPresets.stone }). Characters play the footsteps for you, a random clip each step. The other three are yours to play when your game breaks or places a block: read them for a block state from ctx.blocks.sounds[state], pick one, and play it at the cell.

Playing sounds#

Three functions play a sound, and each returns a handle:

FunctionPlays
playMono(ctx, sound, opts?)without position: UI, music, anything that shouldn't pan
playAt(ctx, sound, position, opts?)from a fixed point in the world, sampled once
playOnNode(ctx, sound, node, opts?)from a node, following it every frame and stopping when the node is removed

All three take volume (0 to 1), detune (in cents: 100 is a semitone up, -1200 an octave down) and loop. The two spatial ones also take falloff: full volume within ref metres, fading out to max (defaults 1 and 100), with rolloff and model ('inverse', 'linear' or 'exponential') shaping the curve between. They are heard from the AudioListener.Trait, which rides the active camera.

Browsers keep a page silent until the player first clicks, taps or presses a key, and the engine wakes audio on that first input. Anything played before then is held and starts when audio wakes, so music started on join begins at the first click.

The handle controls the sound while it plays: stop({ fade }) with an optional fade in seconds, setVolume, setDetune, and isPlaying. A one-shot needs nothing more, but a loop plays until you stop it, so keep its handle:

const EngineHumSound = sound('engine-hum', { src: asset('./assets/engine-hum.ogg', import.meta.url) });

const EngineTrait = trait('engine', {
    throttle: 0,
    // the playing hum, client-side only
    state: () => ({ hum: null as PlaybackHandle | null }),
});

system('engine-hums', (ctx) => {
    if (!env.client) return;

    const engines = query(ctx, [EngineTrait]);

    // a looping hum that follows each engine's node around
    onQueryEnter(ctx, engines, (engine) => {
        engine.state.hum = playOnNode(ctx, EngineHumSound, engine._node, {
            loop: true,
            volume: 0.6,
            falloff: { ref: 2, max: 40 }, // full volume within 2m, silent past 40m
        });
    });

    // a loop never ends on its own: stop it when the engine goes, or a hot reload
    // would start a second hum over the first
    onQueryExit(ctx, engines, (engine) => {
        engine.state.hum?.stop({ fade: 0.2 });
        engine.state.hum = null;
    });

    onFrame(ctx, () => {
        // pitch up to 7 semitones as the throttle opens
        for (const [engine] of engines) engine.state.hum?.setDetune(engine.throttle * 700);
    });
});

Music is a long, looping playMono, stopped with a fade when the system goes:

system('music', (ctx) => {
    if (!env.client) return;

    let music: PlaybackHandle | null = null;

    onInit(ctx, () => {
        music = playMono(ctx, ThemeMusic, { loop: true, volume: 0.4 });
    });

    onDispose(ctx, () => music?.stop({ fade: 1 }));
});

Sounds for gameplay events#

Sound is client-only: on the server every play function does nothing and returns null. So a sound played straight from server gameplay code, a door opening or an explosion, is silent. The server decides what happened, and each client plays it.

For a one-off event, the server broadcasts a command and each client plays the sound when it arrives:

const ExplosionSound = sound('explosion', { src: asset('./assets/explosion.ogg', import.meta.url) });
const ExplosionCommand = command('explosion', SERVER_TO_CLIENT, pack.object({ position: pack.position() }));

// server gameplay: apply the explosion, then tell every client where it happened
export function explode(ctx: ScriptContext, position: Vec3): void {
    // ... damage nearby players, break blocks ...
    broadcast(ctx, ExplosionCommand, { position });
}

system('explosion-sounds', (ctx) => {
    if (!env.client) return;

    listen(ctx, ExplosionCommand, ({ position }) => {
        playAt(ctx, ExplosionSound, position, {
            detune: (Math.random() - 0.5) * 200, // up to a semitone either way, so repeats don't sound identical
            falloff: { ref: 4, max: 80 },
        });
    });
});

For a sound that follows replicated state, a hurt sound when health drops or a click when a door's open flips, the client can watch the synced field instead: keep the last value it saw, and play when the new one differs.

UI#

Game UI is yours to build with the web platform: HTML, CSS, and JavaScript, with all the freedom that brings. Every room has a viewport wrapping its canvas, exposed to client scripts as ctx.client.viewport; append HTML to it for HUDs and menus. The viewport ignores pointer events by default, so set pointer-events: auto on anything interactive.

// append a screen-space overlay to the room's viewport (client only)
system('hud', (ctx) => {
    onInit(ctx, () => {
        if (!ctx.client) return;
        const hud = document.createElement('div');
        hud.textContent = 'Score: 0';
        hud.style.pointerEvents = 'none';
        ctx.client.viewport.appendChild(hud);
    });
});

For UI anchored to a scene node rather than the screen, use the Html.Trait, which positions an HTML element at a node's world position, and UILayer controls stacking order when overlays need to sit above or below one another. For a drawable surface inside the world, such as a sign or screen, the Canvas.Trait renders a 2D canvas onto a node. And because the world renders with gpucat, advanced UI that needs custom rendering can draw into the gpucat scene directly via ctx.client.scene.

Persistence#

bongle stores JSON values by key at two scopes, both server-only. userStorage belongs to one player of your project, for progress, inventory and settings. projectStorage is shared by every room and player, for leaderboards and world state that outlives a room. Nothing in either is saved for you: the game decides when to read and write.

Saving player progress#

Storage is slow next to a tick, a network round trip, so gameplay never touches it directly. Load a player's save into a trait when they join, play against the trait, and write it back when they leave. A server that crashes or is killed never reaches onLeave, so save on a timer as well:

const SAVE_KEY = 'save';
const SAVE_VERSION = 2;
const AUTOSAVE_SECONDS = 60;

// gameplay reads and writes progress on the player's node; storage is only touched
// when it loads and when it saves
export const ProgressTrait = trait('progress', {
    coins: 0,
    level: 1,
    state: () => ({ loaded: false }),
});

// version 1 called coins "gold" and had no levels
type SaveV1 = { version: 1; gold: number };
type Save = { version: 2; coins: number; level: number };

function readSave(stored: JsonValue | undefined): Save {
    const save = stored as SaveV1 | Save | undefined;
    if (!save) return { version: SAVE_VERSION, coins: 0, level: 1 };
    if (save.version === 1) return { version: SAVE_VERSION, coins: save.gold, level: 1 };
    return save;
}

async function loadProgress(ctx: ScriptContext, progress: TraitType<typeof ProgressTrait>, userId: string): Promise<void> {
    const entry = await userStorage.get(ctx, userId, SAVE_KEY);
    if (!progress._node.scene) return; // they left while it loaded
    const save = readSave(entry?.value);
    progress.coins = save.coins;
    progress.level = save.level;
    progress.state.loaded = true;
}

async function saveProgress(ctx: ScriptContext, progress: TraitType<typeof ProgressTrait>, userId: string): Promise<void> {
    if (!progress.state.loaded) return; // never overwrite a save that was never read
    const save: Save = { version: SAVE_VERSION, coins: progress.coins, level: progress.level };
    const result = await userStorage.set(ctx, userId, SAVE_KEY, save);
    if (!result.ok) debug.warn(ctx, 'saving progress for', userId, 'failed:', result.code);
}

system('progress', (ctx) => {
    if (!env.server) return;

    const players = query(ctx, [ProgressTrait, Player.Trait]);

    onJoin(ctx, ({ playerNode, user }) => {
        void loadProgress(ctx, addTrait(playerNode, ProgressTrait), user.id);
    });

    // fires on disconnect, on moving to another room, and when the room closes,
    // with the player's node still in the scene
    onLeave(ctx, ({ playerNode }) => {
        const progress = getTrait(playerNode, ProgressTrait);
        const player = getTrait(playerNode, Player.Trait);
        if (progress && player) void saveProgress(ctx, progress, player.userId);
    });

    // a crashed or killed server never reaches onLeave, so save everyone now and then too
    interval(ctx, AUTOSAVE_SECONDS, () => {
        for (const [progress, player] of players) void saveProgress(ctx, progress, player.userId);
    });
});

A few things in there matter:

  • Loading takes time. The player is in the game before their save arrives, so mark when it has (state.loaded) and never write back a save you didn't read, or a slow load followed by a quick leave wipes their progress.
  • They can leave mid-load. Check the node is still in the scene before writing to it.
  • Version your saves. Stamp a version in every value. When the shape changes, read the old version and fold it forward on load, as readSave does with version 1, so existing players keep their progress.
  • Check every write. set reports failure in its result rather than throwing.
  • Moving between rooms. The old room's save on leave and the new room's load on join run at the same time, so a player who earns something and moves straight away can arrive with the save from before it.

onJoin hands you the player's user, and a player node's Player.Trait carries its userId. From a bare client, clientToUser(ctx, client).id resolves it.

Shared data#

Every room in every region writes projectStorage, so two rooms can read the same value, change it, and write it back, the second quietly undoing the first. Each entry carries a storage version for this. Pass it back as ifVersion and the write only lands if nobody wrote since you read; otherwise it fails with version_conflict, and you read the new value and try again:

const LEADERBOARD_KEY = 'leaderboard';
const LEADERBOARD_SIZE = 10;
const LEADERBOARD_ATTEMPTS = 3;

type LeaderboardEntry = { userId: string; username: string; score: number };
type Leaderboard = { version: 1; entries: LeaderboardEntry[] };

// every room writes this one key. read it, change it, and write it back only if no
// other room wrote in between (ifVersion); on a conflict, read the new board and retry
export async function submitScore(ctx: ScriptContext, userId: string, username: string, score: number): Promise<void> {
    for (let attempt = 0; attempt < LEADERBOARD_ATTEMPTS; attempt++) {
        const entry = await projectStorage.get(ctx, LEADERBOARD_KEY);
        const board = (entry?.value as Leaderboard | undefined) ?? { version: 1, entries: [] };

        const previous = board.entries.find((e) => e.userId === userId);
        if (previous && previous.score >= score) return; // not a new best

        const entries = board.entries.filter((e) => e.userId !== userId);
        entries.push({ userId, username, score });
        entries.sort((a, b) => b.score - a.score);
        const top = entries.slice(0, LEADERBOARD_SIZE);
        if (!top.some((e) => e.userId === userId)) return; // didn't make the board

        const updated: Leaderboard = { version: 1, entries: top };
        const result = await projectStorage.set(ctx, LEADERBOARD_KEY, updated, entry ? { ifVersion: entry.version } : undefined);
        if (result.ok) return;
        if (result.code !== 'version_conflict') {
            debug.warn(ctx, 'submitting a score failed:', result.code);
            return;
        }
    }
    debug.warn(ctx, 'gave up submitting a score after', LEADERBOARD_ATTEMPTS, 'conflicts');
}

The storage version is a concurrency token the store changes on every write. It is not the version you stamp inside your values to migrate them.

Limits#

userStorage (per player)projectStorage
largest value16 KB64 KB
most keys256100,000
writes per second105, across every room
reads per second5050, across every room

Keys are 1 to 128 characters of letters, digits and _ / : . -. A write over a limit fails with too_large, cap_exceeded or rate_limited. The project's write budget is shared by every room at once, so write shared data on events (the end of a round, a new best), never per tick.

These limits are the deployed game's. In the editor and under bongle start, storage lives in the server's memory: it starts empty, has no limits, and is gone when that server restarts.

The API#

Both scopes have the same four async operations. They take ctx first, and the user store takes a userId after it:

projectStorage#

/** Project-scoped KV, shared across every room and player of this project. */
export const projectStorage: {
    get(ctx: ScriptContext, key: string): Promise<StorageEntry | null>;
    set(ctx: ScriptContext, key: string, value: JsonValue, opts?: {
        ifVersion?: string;
    }): Promise<StorageSetResult>;
    delete(ctx: ScriptContext, key: string, opts?: {
        ifVersion?: string;
    }): Promise<StorageDeleteResult>;
    list(ctx: ScriptContext, opts?: StorageListOpts): Promise<StorageListPage>;
};

userStorage#

/**
 * Per-(project, user) KV, private to one player within this project. `userId`
 * is the durable platform identity (`User.id`). Resolve it from a
 * `Client` via `clientToUser(ctx, client).id`.
 */
export const userStorage: {
    get(ctx: ScriptContext, userId: string, key: string): Promise<StorageEntry | null>;
    set(ctx: ScriptContext, userId: string, key: string, value: JsonValue, opts?: {
        ifVersion?: string;
    }): Promise<StorageSetResult>;
    delete(ctx: ScriptContext, userId: string, key: string, opts?: {
        ifVersion?: string;
    }): Promise<StorageDeleteResult>;
    list(ctx: ScriptContext, userId: string, opts?: StorageListOpts): Promise<StorageListPage>;
};

get returns the stored entry, its value with the storage version, or null:

StorageEntry#

export type StorageEntry = {
    value: JsonValue;
    version: string;
};

set and delete return a result:

StorageSetResult#

export type StorageSetResult =
    | { ok: true; version: string }
    | { ok: false; code: 'version_conflict' | 'too_large' | 'rate_limited' | 'cap_exceeded' };

list pages through a scope's keys, optionally filtered by prefix, and returns a nextCursor to pass back for the next page:

StorageListOpts#

export type StorageListOpts = {
    prefix?: string;
    cursor?: string;
    limit?: number;
};

StorageListPage#

export type StorageListPage = {
    items: Array<{ key: string; value: JsonValue; version: string }>;
    nextCursor: string | null;
};

Analytics#

analytics records what players do in your game, in your own terms, and your project's analytics show it: where players drop out, which levels they get stuck on, where their coins go. It is server-only, so the numbers come from the side that can be trusted with them, and each call is about one player, by client. Something only a client sees, like a menu being opened, reaches the server through your own RPC first.

There are three kinds your analytics understand without any setup, and a fourth for anything else. Each takes the thing it is about by name, then the details:

  • analytics.funnel(ctx, client, funnel, { step, stepName?, run?, fields? }): a step in an ordered flow, numbered from 1 in the order players should reach them. Onboarding, a purchase, a match. Reaching a step counts the steps before it as reached too. For a flow a player can go through again, pass the same run id on every step of one run, such as each visit to the shop.
  • analytics.progress(ctx, client, path, { status, level, levelName?, fields? }): a player starting, completing or failing a level, numbered from 1 along a path like main or a side quest.
  • analytics.economy(ctx, client, currency, { flow, amount, balance, transaction, sku?, fields? }): a currency flowing to a player (source) or away from them (sink), with their balance after it. transaction is the kind of flow: iap, timed_reward, onboarding, shop, gameplay, contextual_purchase or a name of your own.
  • analytics.track(ctx, client, event, { value?, fields? }): anything else worth counting, with a value to sum or average (1 by default).
system('analytics', (ctx) => {
    if (!ctx.server) return;
    // the first step of a flow every player goes through once
    onJoin(ctx, ({ client }) => analytics.funnel(ctx, client, 'onboarding', { step: 1, stepName: 'spawned' }));
});

// call these from wherever your game decides the moment has come
function classPicked(ctx: ScriptContext, client: ClientId, playerClass: string) {
    analytics.funnel(ctx, client, 'onboarding', { step: 2, stepName: 'picked_class', fields: { class: playerClass } });
}

function levelCleared(ctx: ScriptContext, client: ClientId, level: number, coins: number) {
    analytics.progress(ctx, client, 'main', { status: 'complete', level });
    analytics.economy(ctx, client, 'coins', { flow: 'source', amount: 50, balance: coins, transaction: 'gameplay' });
}

// a funnel a player can go through again and again: one run per visit to the shop
function swordBought(ctx: ScriptContext, client: ClientId, visit: string, coins: number) {
    analytics.funnel(ctx, client, 'shop', { step: 3, stepName: 'bought', run: visit });
    analytics.economy(ctx, client, 'coins', { flow: 'sink', amount: 120, balance: coins, transaction: 'shop', sku: 'sword' });
}

function bossDefeated(ctx: ScriptContext, client: ClientId, boss: string) {
    analytics.track(ctx, client, 'boss_defeated', { fields: { boss } });
}

Names are lowercase letters, digits and underscores, starting with a letter. fields are up to 3 short strings your analytics can break results down by, such as the class a player picked or the boss they beat. Put what varies in a field rather than in the name: track(ctx, client, 'boss_defeated', { fields: { boss } }), not a defeated_slime_king event per boss.

A call that breaks a rule is dropped with a warning in the console, once per mistake. In the editor nothing is recorded; each call is shown in the console instead, so you can watch your instrumentation fire while you build.

Performance#

Before optimizing anything, measure. Press ` (backtick) while playing to toggle the debug dashboard, a floating window of live metrics and readouts. Drag its title bar to move it, and drag a tab to split or reorder it. Do not guess at what is slow: open the dashboard and find the hot row first.

Everything on it is a read of one recording. The engine profiles each frame as a tree of timed scopes: the client's frame loop on your machine, the server's tick for the room you are in, mirrored to you a few times a second. Each frame keeps its whole span tree, so the charts plot real frames rather than a separate sampling of them, and every number on every tab comes out of that same recording. The tabs are:

  • overview: where you are, camera and foot position, chunk, facing, and the block under your feet (click a value to copy it), plus room info and world counts.
  • perf: the headline fps and client and server frame times, a stacked frame-time chart (one band per phase, summing to the frame, with the 60fps budget drawn as a dashed line), and a throughput glance.
  • cpu: the full frame breakdown, the client frame and the server tick and the work inside your room on it, each a stacked area you can hover to read per-band values.
  • gpu and physics: what the renderer pushed and holds, and the server's solver cost with live body and contact counts.
  • net: ping, inbound and outbound bandwidth in kb/s, broken down by message kind on the client side, for spotting a chatty sync or RPC.
  • scripts: what your own scripts cost, one band per script and hook, client and server, alongside any numbers they recorded themselves (see below).
  • frames: the whole capture at once, client or server. A heat strip along the top holds every retained frame, one column each, cold to hot against the 60fps budget, so a hitch is a red stripe you can see without hunting. Below it the flame graph runs those same frames end to end on one wall-clock axis, the space between two frames being idle time: wheel to zoom (down to microseconds), drag to pan, drag the strip to jump the view to a spike, hover a bar to read its time. It follows the newest frame until you move it, and reset returns it there. This is where a hitch stops being a spike on a chart and becomes the call that caused it.

pause capture on the panel chrome freezes the recording on both sides. The charts, the readouts and the flame all read the frozen frames, so everything holds still together while the game keeps running: pause the moment after a hitch, then drag back through the frames on the frames tab to find it. Recording only happens while the dashboard is open, on the client and on the server alike, so a closed panel costs nothing.

In the editor you also get an options tab (debug view toggles and a ws-latency simulator) and a logs tab (client and server log tails).

The dashboard is the starting point for every performance question: it turns "the game feels slow" into a specific row on a specific side.

Timing your own code#

Every hook call is already a scope, so the scripts tab breaks the frame down per script without you doing anything. To go finer, open your own scopes inside one: debug.begin(ctx, key) and debug.end(ctx, key) time the code between them, and debug.record(ctx, key, value, unit) files a number against the frame. Scopes nest inside the hook that is running, so they appear under that script on the scripts tab and as bars inside it on the frames flame graph, and recorded numbers show up beside them.

system('swarms', (ctx) => {
    const swarms = query(ctx, [SwarmTrait]);

    onTick(ctx, () => {
        debug.begin(ctx, 'steer');
        for (const [swarm] of swarms) steerBoids(swarm);
        debug.end(ctx, 'steer'); // returns the ms it took, if you want it

        // active() is false whenever nothing is recording, which is most of the
        // time. Gate anything you would only compute for the panel on it.
        if (debug.active(ctx)) {
            let crowded = 0;
            for (const [swarm] of swarms) crowded += countCrowded(swarm);
            debug.record(ctx, 'crowded', crowded, 'count');
        }
    });
});

This works the same on the server: a server-only script's scopes and numbers ride the profile the server pushes to the panel, so you read them from the client.

Nothing is recorded while the dashboard is closed. begin, end and record return immediately (and end reads 0), so leaving them in costs nothing. Use debug.active(ctx) to skip work that exists only to be measured.

Custom debug panels#

Your game can dock its own panels alongside the engine's. Call debug.panel(ctx) from a script to open a floating panel scoped to that script. It is closed automatically when the script disposes (room teardown, node removal, hot reload), so game debug UI never leaks. The returned panel takes the full control surface:

import { debug } from 'bongle';

const enemy = { speed: 4, aggro: true, mode: 'patrol' };

const panel = debug.panel(ctx, { title: 'enemy ai' });
panel.add(enemy, 'speed', { min: 0, max: 10 });               // slider, writes back to `enemy`
panel.add(enemy, 'aggro');                                     // toggle
panel.add(enemy, 'mode', { options: ['patrol', 'chase', 'flee'] }); // select
panel.monitor(() => enemy.distanceToPlayer, { unit: 'm' });    // read-only value
panel.graph(() => enemy.distanceToPlayer, { unit: 'm' });      // live line graph
panel.button('reset', () => resetEnemy(enemy));

add(target, key, opts) binds a control to an object property and writes edits straight back, so it doubles as a live tweak surface for tuning gameplay values. monitor, graph, lines (multi-series), series (multi-series over a history you own), flame (a span tree), and log are read-only views over getters you supply. Group rows with panel.folder('name'), or add a nested tab strip with panel.tabs(). Pass copy: true to a monitor to make its value click-to-copy.

title defaults to the same name debug.log tags with: the system's name, or the script's trait, name and node. On the server debug.panel returns null (there is no client dashboard), so guard with ctx.client if your system runs on both sides.

For full control, or to manage a panel's lifetime yourself, reach the shared dashboard directly at ctx.client.debug.dashboard and call .panel(...) on it. The panel and control types live on the debug namespace: debug.Panel, debug.Handle, debug.ControlOptions, and friends.

The dashboard ships with the engine, with a full control vocabulary (sliders, selects, colors, vectors, graphs, gauges, histograms, log views, and more); the exported option types above describe what each one takes.

Building & deploying#

bongle build compiles your project into dist/bundle.zip, a self-contained bundle of the client, server, and content. bongle start runs that built bundle locally, so you can play the production build before shipping it.

Deploying that bundle lands it as a draft. Promoting a draft to live is a separate, deliberate step, so a deploy never changes what players see until you publish it.

CLI reference#

The bongle CLI covers the local workflow, from running the editor to building the bundle you deploy. Run these in a project directory:

# run the editor + live dev server (http://localhost:5566)
bongle dev

# build the project into `dist/bundle.zip`
bongle build

# run a built bundle (a zip or a dir) locally
bongle start

# re-bake assets (textures, models, audio) on their own; dev and build do this for you
bongle bake

Recipes#

Worked solutions that combine pieces from the earlier chapters. Each is a snippet you can lift straight into a game.

Launch pad block#

A launch pad flings a character upward, and launching one is just writing its velocity: add an upward impulse and clear grounded so ground friction doesn't eat it. That launch helper is the whole trick; the rest is deciding when to call it.

// launching a character is just writing its velocity. this is THE knob for launch
// pads, dashes, explosion knockback, and grappling yanks: you add to
// state.velocity and the controller integrates it next tick. add (don't overwrite)
// so successive launches stack (chained rocket jumps), and clear `grounded` so
// ground friction doesn't eat a horizontal kick the same tick.
export function launch(node: Node, impulse: Vec3): void {
    const controller = getTrait(node, CharacterController.Trait);
    if (!controller) return;
    vec3.add(controller.state.velocity, controller.state.velocity, impulse);
    controller.state.grounded = false;
}

Make the pad a real block and every cell of it flings, with no per-pad wiring: drop the block anywhere in the world and it just works. The controller already samples the block under the feet each tick and hands it to you as state.groundBlockState (the standing block's state id while grounded). Resolve that id back to its block with stateToBlock(ctx.blocks, id) and compare by identity against LaunchPadBlock. Matching the block, not one exact defaultId(), means every state of the pad counts, rotations, variants, an on/off toggle, so the check stays correct the moment the pad grows block states.

// a launch pad block. place LaunchPadBlock anywhere in the voxel grid and every
// cell of it becomes a pad, no per-pad wiring. the controller already samples
// the block under the feet each tick and exposes it as `state.groundBlockState`
// (the standing block's state id when grounded).
const LaunchPadBlock = block('demo:launch_pad', {
    model: () => ({ type: 'cube', tiles: { all: tiles.slime } }),
    sounds: blockSoundPresets.grass,
});

system('launch-pad-block', (ctx) => {
    if (!env.server) return; // launch on the server; the result replicates

    const characters = query(ctx, [CharacterController.Trait]);

    onTick(ctx, () => {
        for (const [controller] of characters) {
            if (!controller.state.grounded) continue;
            // resolve the state id back to the block that owns it, then compare
            // by block identity. this matches EVERY state of the pad (rotations,
            // variants, on/off), not just one exact `defaultId()`, so it stays
            // correct the moment the pad grows block-states. this is the way.
            if (stateToBlock(ctx.blocks, controller.state.groundBlockState) === LaunchPadBlock) {
                launch(controller._node, [0, 14, 0]);
            }
        }
    });
});

Launch pad node#

When the pad is an object rather than terrain, floating off the grid or moving, give it a body and a model instead. A prefab bundles the launch-pad model, a static sensor box, and its own Contacts.Trait into one placeable template; each instance reads its own contacts and flings any player whose body shows up in them, matched by nodeId, reusing the same launch helper. This is the same contact-driven shape as a coin pickup.

// a launch pad node. use this when the pad is an object rather than terrain,
// floating off the grid, or moving. a prefab bundles the model, a static sensor
// box, and its traits into one placeable template. each instance reads its OWN
// contacts and flings any player body that enters, matched by nodeId.
const LaunchPadModel = model('launch-pad', { src: asset('./assets/launch-pad.glb', import.meta.url) });
const LaunchPadTrait = trait('launch-pad');

const LaunchPadPrefab = prefab('launch-pad', (ctx) => {
    const pad = cloneModel(LaunchPadModel.scene);
    RigidBody.update(addTrait(pad, RigidBody.Trait), {
        shape: { type: 'box', halfExtents: [1, 0.25, 1] },
        motionType: RigidBody.MotionType.STATIC,
        sensor: true,
    });
    addTrait(pad, LaunchPadTrait);
    addTrait(pad, Contacts.Trait);
    addChild(ctx.scene, pad);
});

system('launch-pad-node', (ctx) => {
    if (!env.server) return;

    const pads = query(ctx, [LaunchPadTrait, Contacts.Trait]);
    const players = query(ctx, [Player.Trait]);

    onInit(ctx, () => {
        // createPrefab returns a detached anchor with a transform; place it and attach it.
        const pad = createPrefab(LaunchPadPrefab);
        Transform.setPosition(getTrait(pad, Transform.Trait)!, [4, 1, 4]);
        addChild(ctx.node, pad);
    });

    // Contacts.Trait fills `added` after each physics step; fling any player whose
    // body just entered a pad's sensor.
    onPostPhysicsStep(ctx, () => {
        const playerByNodeId = new Map<number, Node>();
        for (const [player] of players) playerByNodeId.set(player._node.id, player._node);

        for (const [, contacts] of pads) {
            for (const c of contacts.added) {
                if (c.type !== 'rigidBody') continue;
                const player = playerByNodeId.get(c.nodeId);
                if (player) launch(player, [0, 14, 0]);
            }
        }
    });
});

API reference#

For the exhaustive signature list, see the API reference.