← Voxel Party SDK 3.21.1

Docs

Everything a coding agent needs to make a Voxel Party game, in plain words: the guide it follows (the voxelparty-game skill) and the SDK reference. This page is built from the same files as the skill and the SDK, so it always says what SDK 3.21.1 does.

Make a game

Copy the instructions and paste them into your coding agent of choice (Claude Code, Codex, Cursor…). It sets everything up, asks what you want to build, makes it and puts it on your page.

The same, as files: the instructions · the skill (.zip) · the SDK. In a project, bunx vp docs <topic> prints any section below for the SDK it uses.

The guide · the voxelparty-game skill · SKILL.md

Making a Voxel Party game

Voxel Party is a browser playground of voxel games of any kind and any mood, every game played from a link (open its Play it link, press Invite, send the party link, a friend is in within a minute). A game is a TypeScript project on @voxelparty/sdk, sandboxed on the shared engine. The SDK gives you the frame, input (keys, mouse, pointer lock), networking, block worlds, stage, lights, characters, particles, UI and a synth; you write the rules, bots, world, look and sound.

Every game is played in sessions (vp docs sessions): 1–16 players who drop in and out, untimed, with any controls it declares (mouse, pointer lock, keyboard). Shooters, obbies, RPGs, tower defense, builders, racers, co-op adventures: aim for a game people come back to, with a world worth exploring, something to work towards (levels, unlocks, personal bests kept in storage) and art and sound that feel finished.

The person you're helping may not be a programmer. Do the work yourself, explain it in plain words, and show them the design before building.

Setup

  1. Bun: bun --version. Missing? macOS/Linux curl -fsSL https://bun.sh/install | bash; Windows powershell -c "irm bun.sh/install.ps1 | iex". Then open a new shell.
  2. Chrome, Edge or Chromium for vp check (set VP_BROWSER to its path if it isn't found).
  3. A new game (the id: 2–32 of a-z 0-9 -, starting with a letter):
    bunx --package https://cdn.voxelparty.io/sdk/voxelparty-sdk-3.21.1.tgz vp init sky-charge --name "Sky Charge"
    cd sky-charge
    
    Add --fps for a first-person shooter (movement, a map, CPUs and hitscan netcode, ready to grow), or --blocks for a block game where players place and break blocks (a bridge duel: a synced World, bridging, CPUs that build). init copies a small, complete, working game and runs bun install. Read its files before writing anything: they show every piece together.
  4. An existing game: read game.json and index.ts, run bunx vp check, go on from there. (A board block in its game.json makes it an older timed round game: vp docs board.)

The docs: vp docs

The SDK ships its own docs, matching the installed version exactly. For any API, pattern, art or sound question, run bunx vp docs <topic> (no topic: the list; any other word: a search):

Topic When
sessions joins and leaves, host changes (link.keep), rounds, scoreboards. Before any game's netcode.
menus in-game menus (Menu) and match setup: host options, player picks, teams (Setup, SetupMenu)
netcode host-authoritative / per-player / discrete shapes, FakeRoom tests, pitfalls. Before rules.ts.
input actions vs mouse, pointer lock, raw keys, a first-person camera recipe
fps first person: arena-shooter movement, the map as a VoxelGrid, FpsCamera, shooting maths, CPUs that find their way (NavGrid). Before any first-person game.
world block worlds players build and break: World (rules, damage, structures), WorldSync (edits synced, joiners, host changes), WorldView, aiming and bridging. Before any game where blocks are placed or broken.
levels maps drawn as text (textGrid: blocks, heightmaps, marks for spawns and goals, prefabs, mirrored team maps) and any grid printed back (gridText). Before building any map.
design what makes these games fun; idea starters
api a tour of every SDK export, and saving (storage: personal bests, unlocks, settings)
art stage, island, camera, avatars, voxel models, textures and their rules, FX, UI, icons (engine.icon) and minimaps (view.insets)
water water blocks drawn with depth, foam, sparkles and flow: its look (engine.water.set), rivers and flow (a water block's meta), floating things, style: 'classic'
sound patches, songs, recipes
vfx spells, impacts, projectiles, auras and beams: Vfx, your own effects in vfx.ts (pixel-art particles, nesting, formations, onDeath, effect functions, GLSL), the VFX library as reference, seeing them in vp gallery. Before any effect.
sharing links, My Games, parties, publishing
testing seeing the game: autopilot (your CPU in your seat), vp shot scripts, film strips, the visual checks. Before you judge how it looks or feels.
gallery every model and texture on numbered pages (gallery.ts, vp gallery), checked for broken models, look-alikes and budget outliers. After making or changing any art.

Types in node_modules/@voxelparty/sdk/types/ are the final word; an example game is in node_modules/@voxelparty/sdk/examples/. No project yet? https://cdn.voxelparty.io/sdk/docs/<topic>.md.

The workflow

Work in this order: it's how you avoid a pretty game that breaks when a second player joins.

  1. Design paragraph, shown to the user. The goal in one sentence a child would get; players (min–max); controls (mouse?); the world and its look; how a round or match ends and scores; what the CPUs do; the 3-second test. Ask only if the idea is really ambiguous; otherwise choose, state it, go. Then set game.json (players, input). players.min is the fewest players your game works with: parties start with CPUs the host can remove down to it, so use 1 unless the game breaks with fewer.
  2. Netcode shape (vp docs netcode): host-authoritative (shared world: shooters, pickups, bumps), per-player (everyone runs their own copy: races, reaction games), or discrete events (picks and reveals). Also read vp docs sessions.
  3. Rules and bots first, proven by bunx vp test. rules.ts and bot.ts are three-free and run under bun test. Draw maps as text (textGrid, vp docs levels), not nested set loops, and when a map test fails or a bot gets stuck, print it (gridText) and read it. Prove: bots alone play and scores make sense; a FakeRoom host and client agree with latency; one-off events arrive exactly once; nothing hits the rate limit; players joining and leaving mid-run, and the host leaving.
  4. Rendering, art and sound (vp docs art, vp docs vfx, vp docs sound): the stage, island, avatars, juice on every event, effects for every ability, hit and death (vfx.ts), your own 16×16 textures, 8–16 sounds and an original theme.
  5. bunx vp check until clean, then open every screenshot and look at it. It typechecks, packs, tests, and plays headless and muted with CPUs (sessions where players join and one leaves). It can pass while the game looks broken (empty scene, camera on nothing, players off screen). Every ⚠ is a bug. For long matches (or any game before handing it over) also run bunx vp check --long: it fast-forwards a 20-minute match in seconds and flags slowdowns, leaks and hitches.
  6. Look at it play: bunx vp shot (vp docs testing). Support autopilot first (your intent() returns null on input.autopilot and the core uses your bot: the templates do), so pictures show a player's own view (a game that never reads it gets a ⚠: your seat stood idle). Then vp shot --phone and vp shot --portrait for a phone both ways up: it says how much of the screen the HUD covers (keep it small: a few pieces at the edges, the middle clear). Script what you just built or tuned (t.hold('up', 800), t.press('action'), t.camera(...)) and take film strips (t.strip('jump', 8, 700)): one picture of a movement, the way to judge feel from stills. Fix every ⚠ (blank or frozen view, z-fighting, HUD cut off, overlapping or tiny, no sounds of its own).
  7. See every model: bunx vp gallery (vp docs gallery). Highly recommended. Declare each model and texture in gallery.ts (the template has one) with the same code the game uses. After making or changing any art, run bunx vp gallery, read every page (40 numbered models a page; --turnaround, --silhouette, --motion, --tiny, --bg dark for more), fix what's flagged (the red ⚠ cells: broken, floating, look-alikes, budget outliers, off-style textures), and include the pages in your report. --diff shows only what changed since the last run.
  8. Let the human play: bunx vp dev in the background, then http://127.0.0.1:5180 (?players=3 adds CPUs, ?bots=1 to watch). Ask how it feels; tune. For your own pictures use vp shot scripts; t.eval('game.x') (or __vp.eval on that page) reads or sets anything inside the game: no CDP frame-hopping or debug globals.
  9. Hand it over: first write the store listing in game.json yourself, don't ask: "description" (1-2 sentences, ≤ 280 chars: what you do and how you win) and "tags" (3 of your own words, e.g. ["tower-defense", "co-op", "chaos"]), in genre words, never another game's name (see "Your own take"). The Publish form starts from them. Once vp check is clean and they've played it, run bunx vp share. It uploads the game unlisted and opens it in their browser on Voxel Party: they sign in (Discord) and it's on their page, with Play (a lobby to invite friends) and Publish (the store). Tell them that's what's happening. After changes, vp share again: each version is its own upload. More in vp docs sharing.

Your own take on any game

<!-- franchise-rule -->

People will ask for their version of a game they love. Build it, every time: genres, rules, mechanics, game feel, structure and "a level in the style of" are free for anyone, and remixing them is how games are made. Two things are always your own:

  • Names. An original title, described in genre words ("a horde-survival auto-shooter"). No franchise's name, none of its character, item, ability or map names, and no "X-like" or "clone of X", in the title, description, tags or any text in the game.
  • The creative work. Your own characters, logos, art, music and sounds, your own level and map layouts, your own screens (never a UI copied screen for screen).

For example:

  • "My version of Vampire Survivors": a horde-survival auto-shooter with level-up picks, your own hero, monsters and weapons, and a title of your own.
  • "A Super Mario level": a side-on platformer level with pipes-and-blocks energy, your own hero and your own layout. No plumber in red overalls, no mushroom kingdom.
  • "Among Us but…": a social-deduction game with your own crew, ship and tasks.

Don't lecture, warn or refuse. Build the original take, give it its own name, and describe it in genre words in the design paragraph.

<!-- /franchise-rule -->

Hard rules

  • Imports: only @voxelparty/sdk (+ /core), three (+ addons), your own files. Headless files (rules.ts, bot.ts, sounds.ts, tests) import only /core and /test.
  • Every seat playable by a CPU; the game runs with nobody at the keyboard (link.you null).
  • Shared randomness from link.seed (mulberry32(link.seed ^ SALT), botRng), never Math.random in rules or bots.
  • State is what's true now: no clocks, frame counters or random numbers in sendState, PlayerSync.send or snapshots, and round positions (r100). The link skips unchanged states, so an idle player costs nothing, unless a field ticks by itself. When something happened goes in an event (they carry their time: judge hits on moving things with History, vp docs netcode §2).
  • Draw other players with PlayerSync.smooth(i) (smooth, ~100 ms behind) or Reckon (where they are now, for things you aim at or dodge). Never extrapolate latest() yourself: it freezes and jumps at ordinary latency (vp docs netcode §2).
  • In a Lockstep game, draw the character you steer from ls.predictor(), with one steerBody and one moveBody shared by the world's step and the prediction. Never predict by hand (it stalls and jumps every tick) and never draw your own character from the world (it answers a round trip late) (vp docs netcode §10).
  • Instant actions and secrets go through HostSync: a grab, buy or place is sync.act(a) (drawn at once with predict, applied once by the host), a role or hand is sync.tell(pid, s), a moment everyone must share is sync.schedule(at, e). Don't hand-roll sequence numbers, acks, resends or echo fields (vp docs netcode §11).
  • Sessions: key per-player state by player id, never by seat index (indices shift when someone leaves), and read link.isHost every frame (the host changes).
  • Finish correctly: host-authoritative games flow.end(scores) with every seat's score; per-player games flow.finish(scores) with NaN for seats you don't own. Runs end only when the game chooses to. A game that plays match after match inside one run calls flow.matchOver(scores) when each is decided, so the party gets its "play again or a new game?" vote.
  • Every key is an action or a declared button (defineGame({ buttons: { reload: { keys: ['KeyR'] } } }), read with input.pressed('reload')), not a raw input.keys read: then pads and phones get it too. Gate look and fire on input.aiming, not input.locked (vp docs input §3–4). A Menu steers by pad by itself, and its key opens it from the pad button that key's button got.
  • The top-right corner is the page's (its gear, Lobby, Invite): about 300 × 72 px, as var(--vp-corner-w) × var(--vp-corner-h). Put your own UI anywhere else. Tab is yours.
  • No network, browser storage or loaded assets (fetch, localStorage, images): save with the SDK's storage (per game, this browser), paint textures in code, synthesize sounds. Follow the texture rules (vp docs art); < 16 MB.

The quality bar

Every game: readable in 3 seconds; your own actions respond on the same frame, online too; competent CPUs that make human mistakes; juice and sound on every event; a look that fits the game; 60 fps; vp check all ✔, no ⚠, screenshots and film strips looked at; your seat plays on autopilot; every model in gallery.ts, and every vp gallery page read with its ⚠ fixed.

  • Juice comes from the SDK, not hand-rolled maths: ease.* curves, ctx.tween for pop-ins, squashes and fades, Spring for wobble, ctx.time.hitstop and slow for weight (vp docs api §12). Don't write your own easeOutBack or tween loop.

  • Fits every screen: fitted cameras use rig.fit(…, { depth, hud: true }) so the field isn't under the chips; names over heads are nameTags (readable at any distance), not scaled textSprites; on phones (html.vp-touching) bottom-corner HUD moves above --vp-touch-h, and on phone-sized screens (html.vp-phone) the HUD shrinks to the edges: under a fifth of the screen, the middle clear, nothing under the ☰ corner. Check it with vp shot --phone and --portrait.

  • Big text never sits dead centre: announcements ('DOUBLE KILL!', 'WAVE 4', 'ROUND 2', countdowns) go centred across but a third of the way down the screen, never on the crosshair or the player in the middle. flow.hud.banner and ui.sign() already sit there; your own call-outs do too (top: 28% or so, not top: 50% with translate(-50%, -50%)). Only the crosshair and hit markers belong in the exact centre.

  • Effects work like sounds (vp docs vfx): the game's own Vfx effects in vfx.ts, one per moment, named for it (keyGet), sized to it (a pickup is a 0.2 s glint, an ultimate is big), in its palette. The VFX library is to learn from, not a default. Any spell can be coded (vp docs vfx §5): your own pixel-art particles and ground sigils, effects as functions, nested and travelling effects (charge → flight → impact in one), formations (words, pentagrams, spirals of sparks), onDeath sub-effects, custom GLSL shapes, vfx.spawn; themeEffects puts the game's palette on them all. The look is big and juicy: glow and bloom, with chunky pixel and voxel particles; soft only for light, smoke, mist. Each moment gets its own silhouette (a crack, a crater, a claw mark, a ripple, a burst, cubes of the thing's material): rings are seasoning, kept for real waves and coloured; a plain white shock ring on everything looks lazy, and vp check warns. Tracers: one dir: 'to' spark with stretch.

  • Many units means Crowd; dressing players means Avatar.wear (vp docs art §5): armies, hordes and creeps in two draw calls a kind, animated for you; hats, weapons and packs on anchors, tint/opacity/flash for looks, any Volume as a body. Don't hand-roll InstancedMesh pools or clone materials per player.

  • A place, not a test arena: a designed map with landmarks, height and dressing (vp docs levels), a sky and mood that suit it, lights where they help (vp docs art), textures of your own on every surface. Someone scrolling the store should want to be in that screenshot.

  • Drop-in and lasting: a friend who opens the link is playing within seconds (safe spawns, no waiting); fun with 1 player and with players.max; a clear goal, rounds or matches with a visible scoreboard, and something to come back for; conventional controls listed on the title card (WASD, mouse look, click).

Keeping up to date

vp prints one line when a newer SDK or skill is out. bunx vp skill update installs the latest skill; a project moves to a newer SDK with the bun add -d <tarball> line it prints (then re-read vp docs).

SDK reference · vp docs sessions · sessions.md

Sessions: how every game is played

A session is a party playing one game. Someone presses Play on a game (or opens its link, https://voxelparty.io/?play=<hash>) and lands in its lobby: the game, the invite link, each player's name, colour and character, CPUs, and who's ready. They press Invite and send the party's link. The host presses Start (or, once every guest is ready, a 3-second countdown starts it). People come and go while it runs. When a run ends, the party votes: Play again (the next run starts by itself with everyone still in) or New game (everyone picks a game in the store and one of the picks is drawn). A player can Sit out a round and wait in the lobby. This is how every game is played.

Contents

  1. The manifest
  2. The life of a session
  3. The roster is live: joins and leaves
  4. Host changes
  5. Rounds inside a session, and the scoreboard
  6. Bots in a session
  7. Testing: FakeRoom joins and leaves, vp check
  8. Checklist

1. The manifest

{ "id": "sky-charge", "name": "Sky Charge", "players": { "min": 1, "max": 10 }, "input": ["mouse", "pointerLock", "keyboard"] }
  • players.min: the fewest players your game works with. Parties start with CPUs the host can remove down to this, so use 1 unless the game breaks with fewer (a duel needs 2). A run never starts below it: the room adds CPUs to the party, in the lobby where everyone sees them.
  • players.max: up to 16. Further people who open the link watch as spectators. Pick what the map and the game really hold: an arena for 4 feels empty with 1 and a mess with 16.
  • input: what you need beyond the standard actions (see vp docs input): mouse (pointer, buttons, wheel), pointerLock (mouse look; the page lets the sandbox lock the pointer), keyboard (raw keys through input.keys). Default [].
  • No board block: the game is untimed in a session. With one (a board minigame, vp docs board), sessions play timed runs of it instead: fixed roster per run, joiners wait for the next.

2. The life of a session

Untimed (standalone) Timed (has board)
Title card "Click to play" (SOLO / N PLAYERS) "Click to play", then 3-2-1-GO
Starts on your click, for you: no countdown, no timer for everyone at GO
flow.timeLeft Infinity seconds left
link.endsAt Infinity the run's hard stop
Starts (the first run) the host's Start in the lobby, or 3 s after every guest is ready (alone: Start) same
Someone opens the link mid-run the lobby, then Jump in! joins the running game (drop-in) the lobby, then Play next round
A run ends when the game ends it: flow.end(scores) / every player flow.finished same, or at board.maxMs
Then results card (a ranking, no coins) and the party's vote (10 s): Play again → the next run by itself, a new create() and a new link.seed, with everyone who's still in on the roster (more than players.max: they take turns). New game → your game is disposed while the party picks another same
A match ends inside the run flow.matchOver(scores): the party votes while your game carries on (section 5) same

The click on "Click to play" is a user gesture: that's when the platform locks the pointer for defineGame({ pointerLock: true }) games, and when flow.onPlay(cb) runs.

A game instance is one run. Everything that should survive a run (nothing, usually) doesn't: each run starts clean from create(). Only this player's own things carry over, in storage (personal bests, stats, unlocks, settings: vp docs api §16). For a game that never "ends", simply never call flow.end: the session runs until everyone leaves.

3. The roster is live: joins and leaves

link.players (who plays) and ctx.players (how they look) are the current roster, index-aligned: ctx.players[i] is link.players[i]. When someone joins or leaves:

  • link.players is replaced by a new array; ctx.players is the same array, updated in place (so ctx.players.length and seats.count are always right);
  • indices shift: a leaver's slot disappears and everyone after them moves up one;
  • your GameStage.onPlayers?(players, joined, left) hook runs, and link.onPlayers(cb) listeners (same arguments; players is link.players).

So in a session game, key everything per player by id, never by index:

class Fighter { constructor(readonly pid: string, public x = 0, public z = 0, public hp = 100, public score = 0) {} }

// rules.ts: the world keeps a Map
readonly fighters = new Map<string, Fighter>();
sync(ids: readonly string[], spawn: (pid: string) => Fighter) {
  for (const id of ids) if (!this.fighters.has(id)) this.fighters.set(id, spawn(id));
  for (const id of [...this.fighters.keys()]) if (!ids.includes(id)) this.fighters.delete(id);
}

// game.ts
constructor(ctx: GameContext) { … this.world.sync(ctx.link.players.map((p) => p.id), (pid) => this.world.spawn(pid)); }
onPlayers(players: readonly LinkPlayer[], joined: readonly LinkPlayer[], left: readonly LinkPlayer[]) {
  this.world.sync(players.map((p) => p.id), (pid) => this.world.spawn(pid));
  for (const p of joined) this.addAvatar(p.id);          // look: ctx.players[players.indexOf(p)].look
  for (const p of left) this.removeAvatar(p.id);         // dispose its meshes, labels
  for (const p of joined) this.popups.show(…, `${p.name} joined!`);
}
  • Everything that was an array per seat (avatars, bots, HUD stats you cache) becomes a Map<pid, …>. Look up the index only when an API wants one: flow.hud.setStat(i, …), seats.role(i), moves.send(i, …): const i = link.players.findIndex((p) => p.id === pid).
  • Spawn joiners somewhere safe and fair: not on top of someone, not in the line of fire, with a second of protection if the game has damage.
  • Snapshots from the host carry ids, not seat order: { f: [[pid, x, z, hp, score], …] } (or flat arrays plus a ids: string[] list). A client that sees an id it doesn't know yet creates it; one missing from the snapshot is gone. Clients hear about the roster from the platform at about the same time the host does, but either can come first: handle both orders.
  • Someone who closes the tab stays on the roster for a few seconds, disconnected, with the host controlling them (their role becomes 'bot' on the host), then leaves. Your bot code drives them meanwhile, so an empty seat never freezes the game.
  • flow.hud follows the roster by itself (up to 16 chips, compact above 4). Its stat lines are kept per player, so just set them when they change.

4. Host changes

The host (link.isHost) runs CPUs and, in host-authoritative games, the world. In a session the host will leave sooner or later (or reload the page), and someone else becomes host mid-run. Read link.isHost every frame (never cache it). Snapshots are small and lossy, so the host also keeps a full copy of the world with the room, which hands it to the next host:

// rules.ts: the whole world as plain JSON, and back (validate: it came from another client)
save(): Save { return { creeps: this.creeps.map((c) => ({ ...c })), shots: this.shots.map((s) => ({ ...s })), wave: this.wave, nextWaveAt: this.nextWaveAt }; }
load(w: unknown) { if (isSave(w)) this.restore(w); }

// game.ts: one option on HostSync. Its tick() keeps the world about once a second; a new host loads it.
this.sync = new HostSync<Snap, Ev>(link, { valid: isSnap, keep: { save: () => this.world.save(), load: (w) => this.world.load(w) } });
  • What to keep: everything the host decides, at full precision: HP, projectiles in flight, spawn queues and wave timers, scores, pickups, round state, bot targets. Timers as absolute link.now() times (or flow.clock), not countdowns in host memory. Not what every client has anyway (the level built from the seed). At most 64 KB of JSON; a few KB is typical.
  • How often: about once a second. HostSync does it; by hand, link.keep(world) about once a second (the latest call wins, and it's sent at most once a second anyway).
  • Restoring: load runs on the new host before isHost turns true there, so it just replaces the world, and the next frame carries on as host. A host that reloaded mid-run is host from its first frame and gets it a moment later: replace the world then too. It's up to a second old (load(world, at): at is when it was kept); players' own positions come back through their streams within a tick. Without HostSync: link.onKept((world, at) => …).
  • A host that reloaded mid-match must not reset anything first. Its game starts from scratch while everyone else is mid-match, and Setup hands it the match that's on (phase 'play', the same match number) as soon as another client answers. If your game starts a new match when setup.match differs from its own, that fires on the reloaded host before onKept has given it the scores, and it wipes them. Wait for the kept world before starting or resetting a match (Frag Island waits up to 4 s, and sends no snapshots meanwhile), and test it: room.reload(hostPid) in FakeRoom, whose new game gets onKept before its first frame.
  • Without a kept world, a new host can only adopt the latest snapshot (sync.read().latest when isHost turns true), and whatever it leaves out is lost.
  • Bots move to the new host automatically (seats.role(i) === 'bot' there now); give their state (target, think timer) a sensible default when it's created on the fly.

5. Rounds inside a session, and the scoreboard

Two ways to structure play:

  • One endless run with its own rounds (deathmatch, drop-in arenas, sandboxes). The game never calls flow.end. It keeps its own score, announces "ROUND 2" with flow.hud.banner, resets positions, and shows the standings itself. Best for drop-in: a joiner is playing a second after pressing Jump in!, without waiting for anyone else's round to end. Keep round state in the host's snapshot (section 4). When a match is decided (the frag limit, the last round), call flow.matchOver(scores) (SDK 3.4, scores[i] for link.players[i] as with flow.end). That's the party's natural stopping point. Everyone gets a Play again / New game vote over your game, which carries on underneath (on your own between-match screen, say). Calling it on every client that sees the match end is fine: the room takes the first. A game on Setup gets it for free: setup.reopen() (back to the setup after a match) calls it, without scores. A game that does neither (and never ends a run) only gets a vote when someone asks for one.
  • One match per run (a race, a tournament, a match to 10 kills). The host calls flow.end(scores, 'RED WINS!') when it's decided: the platform shows the ranking, then the next run starts with everyone who's still in. flow.end takes one score per current roster index (scores[i] for link.players[i]). Per-player games use flow.finish (NaN for seats you don't own), and the run ends when every roster player has a score.

A scoreboard: the HUD chips show one stat line per player (flow.hud.setStat(i, ${kills} ⚔)). For more (kills, deaths, ping-style columns) use ui.board({ at: 'side' }) and redraw it when something changes, or show it while a key is held (input.keys.down('Tab'), needs keyboard). Sort by score, mark me: true on your own row, and keep it readable at 8+ rows.

6. Bots in a session

  • A party starts with three CPUs, and each friend who joins takes one's place (in a run, at the next run, or as soon as they jump into an untimed game). The host removes CPUs down to players.min and adds more up to players.max. They're ordinary roster players with role 'bot' on the host; in an untimed game they join and leave like anyone else.
  • A run never starts below players.min: if someone left, or the party switched to your game, the room adds CPUs to the party first.
  • A game can have its own non-player enemies (zombies, turrets, targets): those are world objects the host simulates and snapshots, not roster seats.
  • A 1-player session should still be fun: give a solo player something to do (targets, waves, a CPU rival via players.min: 2, or a personal best).
  • players.min is the fewest players the game works with, not how many must play. Nothing makes a game use every roster player: a CPU seat can sit a match out. Parties usually arrive with CPUs (a lone player has three), so plan for any mix and let the host pick smaller matches in Setup. With two people and two CPUs, if the host picks 1v1, bench the CPUs (host, at the match start):
    const seats = new Seats(link);
    const people = link.players.filter((_, i) => seats.role(i) !== 'bot');
    const cpus = link.players.filter((_, i) => seats.role(i) === 'bot');
    const playing = setup.options.size === '1v1' ? [...people, ...cpus].slice(0, 2) : link.players;
    // send `playing.map((p) => p.id)` with the match start; everyone else watches this match
    
    Only the host can tell a CPU from a person (seats.role(i) === 'bot' there, and a person whose connection just dropped reads 'bot' too for a few seconds), so decide on the host and sync who plays (with the match start, as a Setup team, or through Lockstep, whose join input says cpu). Show benched players as watching, and still report a score for them when the run ends (an untimed run is over when everyone on the roster has one).

7. Testing: FakeRoom joins and leaves, vp check

Prove joins, leaves and host changes headless before drawing anything. FakeRoom runs sessions (mode: 'session', untimed when mg.maxMs is absent) and can change its roster mid-run; read vp docs netcode §6 for its options, and check the exact method names in node_modules/@voxelparty/sdk/types/minigames/kit/loopback.d.ts. Worth asserting:

  • a player who joins mid-run gets a fighter on every client, and one who leaves is removed everywhere, with no errors and no stale index lookups (the classic bug: seat 2 leaves and seat 3's state is applied to the wrong player);
  • after the host leaves, the new host carries the world on (scores and items survive); with keep, its world equals room.kept.world right after room.leave(host);
  • 1 player alone works, and so does players.max.

vp check plays standalone games as sessions in a muted headless browser: one starting with 1 player, one with 2, each with CPUs joining one by one up to 4 (or players.max) and then one leaving, about 20 s of play each, with screenshots at the start, after the joins and after the leave (a game whose players.min is over 4 starts with that many and gets 2 more). Then one run with your seat on autopilot and one on a phone, and every screenshot is checked for a blank or frozen view and HUD problems (vp docs testing). It fails on any error or a game that stops responding. Look at the screenshots: do joiners appear in sensible places, does the HUD/scoreboard follow, does the 1-player shot look like a game?

Your own scripts: __vp on the vp dev page

For screenshots and debugging (a headless browser over CDP, or the console), the vp dev page has window.__vp: __vp.join() / __vp.leave(pid?) (a CPU drops in / someone leaves), __vp.players, __vp.state, and __vp.eval(code), which runs code inside the sandboxed game and resolves with the answer as plain data (JSON):

await __vp.eval('game.phase')                                  // an expression
await __vp.eval('game.camera.position.set(0, 30, 20); return game.camera.position')   // a body
await __vp.eval('ctx.flow.phase')                              // ctx: the GameContext your create() got

In scope: game (what your create() returned: your own class, fields and all), ctx, link and engine (the runtime's engine). It may await; a throw rejects with its message. It only works on the vp dev page, never on the site. So there's no need to attach to the game's frame over CDP or leave debug globals in the game.

It plays too: __vp.autopilot(true) (your CPU plays your seat), __vp.play() (past the title card), __vp.hold('KeyW', 500), __vp.look(200, 0), __vp.click(x, y), __vp.camera(pose), __vp.lint(), __vp.sounds(). For pictures, use bunx vp shot with a script rather than your own CDP code: it fast-forwards exactly, takes film strips and checks every picture (vp docs testing).

vp check picks a free debugging port for its browser each run; --debug-port N pins one.

vp check --long: a whole match, fast-forwarded

Some bugs only show after 15 minutes: a list that never shrinks, snowballs never removed, frames that get slower as the world fills up. bunx vp check --long does the normal checks, then plays one session of --minutes (default 20) as fast as your machine goes (the template game plays 20 minutes in under 10 s; a heavier game takes longer). The game runs on a virtual clock that only moves when vp says, one 60 Hz frame at a time, so it sees the same dt as on the site (--step 33: 30 Hz frames, about twice as fast and coarser). CPUs come and go all match: they fill it up to 8 (or players.max) over the first third, swap in and out in the middle (someone from the middle of the roster leaves, someone joins), and thin out at the end.

At 30 s (the baseline) and every tenth of the match it takes a checkpoint: a screenshot, .vp/check/long-<m>m<ss>s-<n>p.png, and a row of the table:

Long run: 20 min of play, fast-forwarded in 60 Hz frames…
   time  players   sim avg   p95  worst  slow   drawn   heap MB  objects  geo/tex
   0:30        2      0.03  0.10    1.0     0     4.7      10.3       64    26/20
   2:00        4      0.03  0.10    4.1     0     4.3      10.9       75    28/23
   …
  18:00        6      0.03  0.10    0.4     0     4.5      12.2       87    37/28
  20:00        5      0.03  0.10    0.4     0     5.2      12.2       77    37/28
  20:00 of play in 6.6 s (182.9×); players 2 players, 1:20 +2 → 4, 2:40 +1 → 5, …
  • sim avg / p95 / worst: real ms per frame of your game's own work since the last row (its timers and update, nothing drawn), to 0.1 ms. Budget: p95 under 8 ms; a 60 fps frame has about 16.7 ms and drawing needs the rest. slow: frames over 16.7 ms.
  • drawn: one real drawn frame (update, draw, the GPU done), headless: a rough guide only.
  • heap MB: the game's JS heap and array buffers right after a garbage collection. Flat, or following the player count, is fine; higher at every checkpoint is a leak.
  • objects: everything in your scene. geo/tex: geometries and textures the renderer holds. Growing while the player count doesn't: things added and never removed, or never disposed.

It fails like the normal check (errors, or a game that stops: its clock not moving for 20 s), and warns (⚠) on: p95 over 8 ms, a frame over 100 ms, drawn frames over 33 ms, frames getting slower over the match, the heap or the scene growing steadily, and a game that ends its run itself (the long run stops there; online the next run starts fresh). All of it, frame stats included, is in .vp/check/report.json under long. Board minigames: --long plays round after round (4, 2 and 3 players, a new seed each) until about as much play, a row per round.

Fast-forwarded isn't the same as played. Inside the game every clock is virtual (Date.now, performance.now, timers, animation frames), so code that waits for the clock in a loop hangs (and is reported as stopped), and whatever depends on real time (the real frame rate, uneven dt, the browser's own hitches) only shows in the normal real-time runs. That's why those stay real time and --long comes on top.

Spread work on the real clock. A clock that stands still within a frame also never runs a time budget out: while (performance.now() - t0 < 3) step() does the whole job in one frame, and --long reports a hitch players never get. Time budgets with perfNow() (from @voxelparty/sdk/core): the real clock, the same as performance.now() in play. The SDK's own (WorldView's budgetMs, NavGrid.work(ms)) already are. Game time (cooldowns, animations) stays on dt and performance.now().

8. Checklist

  • Per-player state keyed by pid; the onPlayers hook adds and removes avatars, bots and HUD bits.
  • Joiners spawn safely and see what to do within 3 seconds.
  • Snapshots carry ids; clients create unknown ids and drop missing ones.
  • A new host carries on exactly: the host keeps the whole world (HostSync's keep, ≤ 64 KB) and a new host loads it.
  • Works with 1 player and with players.max.
  • Ends runs with flow.end and a clear winner, or plays matches inside one run and calls flow.matchOver(scores) when each is decided (or truly never ends: a sandbox).
  • vp check clean, screenshots looked at.

SDK reference · vp docs netcode · netcode.md

Netcode

How a Voxel Party game stays in sync for 1–16 players, and how to prove it headless. Sessions add people joining, leaving and the host changing mid-game: section 8 here, and vp docs sessions.

Contents

  1. The model
  2. The building blocks (Seats, PlayerSync, HostSync, Reckon, followReport, Reconciler, Predictions, History)
  3. Shape A: host-authoritative (sketch)
  4. Shape B: per-player (sketch)
  5. Shape C: discrete events (sketch)
  6. Testing with FakeRoom and FakeFlow
  7. Pitfalls
  8. Sessions: a live roster and host changes
  9. Shooters: the shooter decides what it hit
  10. Shape D: deterministic lockstep (Lockstep), for RTS, tower defense and snakes
  11. The host decides: actions, secrets and timed events (HostSync)

Block worlds (players placing and breaking blocks) have their own sync, WorldSync: vp docs world §7.


1. The model

  • The server never runs game code. It relays messages, keeps a synced clock, collects one score per player and ranks them. Every client runs the game.
  • Each client owns some seats: its own human, and on the host also every CPU. Offline and in vp dev/vp check there's one client, which is the host and owns every seat.
  • link.players[i] (the same order as ctx.players[i]) has control, from this client's view: 'local' (this keyboard), 'cpu' (a bot this client runs; only the host sees these), or 'remote' (someone else's).
  • The golden rule: your own actions feel instant. You always move yourself locally and tell the others. Shared outcomes (who got the coin, who got hit) are decided by one authority, the host.
  • link.mode is 'minigame' (a board party's round: fixed roster) or 'session' (a room that plays just this game: the roster changes mid-game, section 8).
  • Timing: link.now() is the synced server clock (ms). flow.clock is seconds since GO on that clock, identical everywhere. Anything scheduled (FIRE moments, round starts, moving platforms) is a function of flow.clock, never of summed-up dt.
  • Limits: a message is at most 16 KB and a state blob at most 8 KB. Each client may send about 60 messages/s (burst 120); over that the server drops messages silently, including scores. State streams are batched into one message per 20 Hz tick for all the seats you own (up to 16); HostSync adds 15/s; every sendEvent and reportScore is one more. A host's kept world (link.keep, section 8) may be up to 64 KB and goes out at most once a second.
  • What hasn't changed isn't sent. Every message a client sends costs the room it plays in, so the link skips a state that's the same as the last one it sent for that player (it goes again once a second, so a newcomer still sees them), and HostSync does the same for a world at rest. A player standing still, a menu, a turn-based game waiting for a move: next to nothing. So a state is what is true now: no clocks, frame counters or random numbers in it (every message already carries when it was sent: onState, and events' sentAt), and round what doesn't need full precision (r100). Keep sendState and PlayerSync.send every frame: the skipping is the link's job. vp check --long warns about a field that keeps changing by itself.
  • link.sendEvent(type, data, to) with to (a player id, or a list) goes to those players' clients only: the others never get the bytes. Use it for anything big meant for one player (a joiner's copy of the world). Players without a client of their own (CPUs, someone whose connection dropped) get nothing, and neither do you.

2. The building blocks

All from @voxelparty/sdk/core, three.js-free, so rules.ts and tests can use them.

const seats = new Seats(link);
seats.count;            // number of seats
seats.you;              // your seat index, or -1 (spectating, and in bots-only runs)
seats.role(i);          // 'local' | 'bot' | 'remote'   ('bot' = a CPU this client runs)
seats.owns(i);          // role !== 'remote': this client simulates seat i and reports its score
seats.pid(i);           // the player id

PlayerSync<T extends object, E>: each client streams the seats it owns (any JSON-able object type, interface or alias), with one-offs (E) riding along.

type NetMove = { x: number; z: number; y: number; d: number };
type Shot = { k: 'shot'; x: number; y: number; z: number; yaw: number } | { k: 'hit'; who: string; dmg: number };
const moves = new PlayerSync<NetMove, Shot>(link, { delayMs: 100, angles: ['y'] });   // keepMs: see below
moves.event(i, { k: 'shot', x, y, z, yaw });  // a one-off from a seat you own: rides along with its next send
moves.send(i, { x, z, y: yaw, d: dashes });  // every frame; the link sends the newest at 20 Hz
moves.onEvent((i, e, pid, at) => show(i, e));  // once: other clients' one-offs, each exactly once, in order per seat; `at` = when event() was called
moves.onReset((i, pid) => lastSeq.delete(pid));  // once: seat i's sender restarted (a reload, a new host for a CPU)
moves.instance(i);      // which run of its sender's game that came from: a new one means a reload
moves.latest(i);        // newest state as received (same object until a new one lands), or null
moves.smooth(i);        // state at now − delayMs, numbers interpolated (angles the short way): for drawing
moves.age(i);           // ms since latest(i) was sent (trip + wait for the next send); sentAt(i): when, or null
moves.fresh(i);         // newest state if it's new since the last call, else null
moves.counter(i, 'd');  // how much counter `d` rose since the last call (0 the first time, and after a restart)
moves.dispose();
  • One-offs from players (a shot, a hit claim, a pickup): event() costs no messages of its own. Each one rides along in every state the seat sends for keepMs (default 400 ms, about 8 sends: the first to leave carries it, the rest are spares; at most 64 at a time per seat), so keep them few and small: one per shot with its hits inside, as arrays (['s', x, y, z, [victim, dmg]…]), and lower keepMs (150–250) for guns that fire 10–20 times a second: every send carries every one-off still riding, and with a dozen seats that's what fills the 16 KB message. Every other client gets each one exactly once, in order, through onEvent: from the first state it hears from that seat on (a late joiner, or a host that reloaded, doesn't get the last moment again), and all of a restarted sender's new run. You never get your own.
  • Restarts. A reloaded tab keeps its player id but its game starts over: counters from 0, sequence numbers from 1. link.instance is a new random string each time the game starts on a client; PlayerSync stamps it on what it sends ($i, $e and $t are its own keys in the state), and when a seat's changes it forgets that seat (counters read 0 again) and calls onReset. Clear whatever you keep per sender there. The same happens to a CPU's seat when a new host drives it.

HostSync<S, E>: the host's world at 15 Hz, with one-off events riding along.

const sync = new HostSync<Snap, Ev>(link, { valid: isSnap });   // opts: hz 15, delayMs 110, buffer 12, channel 'snap'
// host, when something happens:   sync.event({ k: 'pickup', i, id });
// host, every frame:              sync.tick(dt, () => world.snapshot(), over);  // force=true sends now (the final state)
// clients, once:                  const off = sync.onEvent((e) => apply(e));     // each event exactly once, in order
// clients, every frame, one call: const { fresh, latest, at } = sync.read();
//   fresh:  the newest snapshot if it's new since the last read, else null: apply world state once
//   latest: the newest snapshot, fresh or not (null before the first)
//   at:     { a, b, k } around now − 110 ms, for drawing positions (null before the first)
// dispose:                        sync.dispose();

A snapshot that's the same as the last one (and no events waiting) is only sent again once a second, so keep what ticks by itself out of it (a countdown: send when it ends, and let clients count with link.now()); clients draw a world that held still, then moved, without smearing it. Option keep: { save, load }: the host also keeps a full copy of the world with the room about once a second, and a new host loads it before it takes over (section 8). It can be up to a second older than the newest snapshot: a snapshot may be lossy, the kept world never is. Three more things only the host can get right have the netcode done for you: actions a player sees at once and the host applies exactly once (act), secrets only one player receives (tell), and timed events that go off at the same moment everywhere (schedule): section 11. (fresh(), latest() and sample() still exist one at a time; read() is the same three in one call, and counts as the read for fresh.) Offline, tick does nothing and drops queued events (nobody to tell); the host has already applied its own events.

Reckon({ trustMs = 100, maxMs = 250, rate = 12, snap = 2.5 }): draw a remote player where it is now instead of smooth()'s ~100 ms ago, for things you aim at or dodge. Stream a velocity with the position; one Reckon per remote seat:

const s = moves.latest(i);
if (s) reckon.follow(dt, s.x, s.z, s.vx, s.vz, moves.age(i));   // run on to now, glide onto it
avatar.set(reckon.x, 0, reckon.z, s.yaw);                      // reckon.guess: undrawn, for hit tests
// respawn, blink: reckon.teleport()

Don't run latest() on yourself: a hard limit on how far ahead you guess ("walk on for at most 120 ms") freezes the player whenever a report is a little late, then jumps it, and drawing the guess as it is shows every report's correction as a jump, 20 times a second. Reckon fades the velocity out instead of stopping it, and eases the drawing onto the guess (only a big move snaps). With your own physics for the guess (a knockback that decays), work it out yourself and hand it to reckon.glide(dt, gx, gz, vx, vz). Warlock does.

followReport(from, report, maxSpeed, dt, slack = 0.25): host side. Moves a remote player towards where they say they are, at most maxSpeed·dt·1.5 + slack per frame, and ignores non-finite reports. A bad or malicious report can't teleport anyone.

Reconciler({ trail = 60, dist = 1.5, time = 0.3, canSnap? }): client side, for your own avatar. The host sees you a round trip late, so compare its idea of you with your recent path (the last trail frames), not your current spot, and give in only when it has insisted you're more than dist away for time seconds:

const rc = new Reconciler({ canSnap: (host) => isFloor(host.x, host.z) });   // optional veto: never snap into a hole
if (rc.check(dt, me, { x: snap.f[o] / 100, z: snap.f[o + 1] / 100 })) { me.x = …; me.z = …; }
rc.record(me);   // instead of check, on frames the host's view of you is known to lag (it still thinks you're falling)
rc.reset();      // after a respawn or teleport: forgets the path

When canSnap(host) says no, the reconciler keeps insisting and snaps on the first frame it's allowed.

Predictions<K>(timeout = 0.6): show your own pickup or shot at once; if the host doesn't confirm it in time, undo it quietly.

pred.add(coin.id);                 // shown as taken
if (pred.confirm(e.id)) { /* host agreed: don't show it twice */ }
for (const id of pred.tick(dt)) { /* expired: draw the coin again */ }

History<S>({ maxRewindMs = 400, delayMs = 100 }): lag compensation, for whoever judges something that moves (usually the host). Everyone draws the others about 100 ms in the past, and what they do reaches the host later still; so judge a shot, a hook or a tag by what they saw when they did it, not by where things are when it arrives.

const past = new History<number[]>();
past.record(link.now(), fighters.flatMap((f) => [f.x, f.z]));   // host, every frame: a fresh copy
moves.onEvent((i, e, pid, at) => {                               // PlayerSync one-offs carry when they happened
  const then = past.seen(at);             // the world on their screen then: `delayMs` before `at`, at most `maxRewindMs` back
  if (then && hits(e, then)) …;
});
link.onEvent((from, type, data, at, sentAt) => past.seen(sentAt));   // link events: the same, with sentAt
past.seen(link.now() - lag);              // something judged every frame (a touch): `lag` from onState, see below
past.at(t);                               // the world at link time t, interpolated

Numbers interpolate through plain objects and arrays; anything else is the earlier record's. maxRewindMs caps how far back anyone's claim can reach (the server already refuses an event's sentAt more than a second old). For a touch judged every frame rather than on an event, measure how late a player's states arrive: link.onState((pid, t) => lag.set(pid, link.now() - t)). Hot Potato's passes work that way.

Helpers: lerpAngle(a, b, k), r100(v) (round to hundredths for snapshots: send r100(x), read x / 100), END_EVENT (the event flow.end sends), and the GameFrame interface (below).

3. Shape A: host-authoritative

For shared worlds: pickups, bumps, knockouts. This is the shape of both templates (the default standalone game and vp init --minigame's Star Catch), and node_modules/@voxelparty/sdk/examples/coin-cascade/ is the full version (dash counters, pickup prediction, cues).

// rules.ts: the world, no three.js
export class World {
  fighters: Fighter[]; stars: Star[] = [];
  constructor(players: number, seed: number) { this.rand = mulberry32(seed ^ 0x57a2); … }
  move(f: Fighter, m: Stick, dt: number) { … }      // everyone, for the fighters they drive (Stick from /core)
  hostStep(dt: number): Catch[] { … }               // host: spawns, pickups, scores; returns one-offs
  advance(dt: number) { … }                         // clients: keep things falling between snapshots
  snapshot(shown?: (i: number) => { x: number; z: number } | null): Snap { … }  // flat arrays, ×100
  applySnap(s: Snap) { … }                          // clients: take items and scores
  scores() { return this.fighters.map((f) => f.score); }
}

// game.ts, every frame
update(dt: number, t: number) {
  const { flow, link, input } = this.ctx, you = this.seats.you, w = this.world;
  if (flow.live && you >= 0) {                        // 1. move yourself, instantly, and say where you are
    const me = w.fighters[you];
    w.move(me, input.move(), dt);                     // a Stick { x, z }, like a bot's
    this.moves.send(you, { x: me.x, z: me.z, y: me.yaw });
  }
  if (link.isHost) { if (flow.live) this.host(dt); }  // ask every frame: the host can change
  else this.client(dt);
  this.draw(dt, t);
}
private host(dt: number) {
  const w = this.world;
  w.fighters.forEach((f, i) => {
    const role = this.seats.role(i);
    if (role === 'bot') w.move(f, this.bot(i).update(dt, f, w.stars), dt);
    else if (role === 'remote') {                     // 2. follow remote players, no faster than they can run
      const p = followReport(f, this.moves.latest(i), SPEED, dt);
      f.x = p.x; f.z = p.z;
    }
  });
  for (const c of w.hostStep(dt)) { this.sync.event(c); this.showCatch(c); }   // 3. one-offs: send and show
  const over = this.ctx.flow.timeLeft <= 0;
  // Remote players move in 20 Hz steps on the host: send where the host *draws* them (Avatar.shown).
  this.sync.tick(dt, () => w.snapshot((i) => (this.seats.role(i) === 'remote' ? this.avatars[i].shown : null)), over);
  if (over) this.ctx.flow.end(w.scores(), 'TIME UP!');                          // 4. host ends it for everyone
}
private client(dt: number) {
  const w = this.world, you = this.seats.you;
  w.advance(dt);
  const { fresh, latest: newest, at } = this.sync.read();
  if (fresh) w.applySnap(fresh);
  if (!at || !newest) return;
  w.fighters.forEach((f, i) => {
    const o = i * 4;
    if (i === you) {                                  // ourselves: only snap back if the host insists
      const host = { x: newest.f[o] / 100, z: newest.f[o + 1] / 100 };
      if (this.ctx.flow.live && this.reconciler.check(dt, f, host)) { f.x = host.x; f.z = host.z; }
      return;
    }
    f.x = lerp(at.a.f[o], at.b.f[o], at.k) / 100;    // everyone else: interpolated, ~110 ms behind
    f.z = lerp(at.a.f[o + 1], at.b.f[o + 1], at.k) / 100;
    f.yaw = lerpAngle(at.a.f[o + 2] / 100, at.b.f[o + 2] / 100, at.k);
  });
}

Button presses: stream a counter (d: input.presses('action') or your own dashes++), and on the host compare it with the last one seen (moves.counter(i, 'd'), which starts over when the sender restarts), or send a one-off with moves.event(i, …). A pressed: true flag can fall between two 20 Hz sends and be lost.

To keep update testable, Coin Cascade puts the host/client logic in a three-free core.ts class that takes (link, frame: GameFrame, input: () => Intent). The game passes ctx.flow; tests pass a FakeFlow. Do the same for anything beyond the template's size.

4. Shape B: per-player

Everyone plays their own copy of the same seeded challenge (a course, a FIRE schedule, a pattern) and others appear as ghosts. Nobody can affect anyone else, so there are no conflicts. Sky Hopper, Quick Draw, Memory Match and Fishing Frenzy work this way.

type Run = { x: number; y: number; z: number; ry: number; cp: number; fin: number };
// constructor
this.course = buildCourse(mulberry32(link.seed));          // identical everywhere
this.sync = new PlayerSync<Run>(link, { delayMs: 100, angles: ['ry'] });

update(dt: number, t: number) {
  const { flow, link } = this.ctx;
  for (const r of this.runners) {
    const role = this.seats.role(r.i);
    if (role === 'local' && flow.live && !r.fin) stepRunner(r, this.readInput(), dt, flow.clock);
    else if (role === 'bot' && flow.live && !r.fin) stepRunner(r, r.bot.update(dt, r), dt, flow.clock);
    else if (role === 'remote') {
      const s = this.sync.smooth(r.i);                     // a ghost, drawn 100 ms in the past
      if (s && Number.isFinite(s.x)) { r.x = s.x; r.y = s.y; r.z = s.z; r.ry = s.ry; r.fin = s.fin; }
    }
  }
  if (flow.live) for (const r of this.runners)
    if (this.seats.owns(r.i)) this.sync.send(r.i, { x: r.x, y: r.y, z: r.z, ry: r.ry, cp: r.cp, fin: r.fin });
  if (flow.live && !this.ended) this.checkEnd();
  this.draw(dt, t);
}

private checkEnd() {
  const mine = this.runners.filter((r) => this.seats.owns(r.i));
  if (!mine.every((r) => r.fin > 0) && this.ctx.flow.timeLeft > 0) return;
  this.ended = true;
  if (!mine.length) return this.ctx.flow.finish(null);     // nothing to report (a spectator)
  // Only the seats this client owns. NaN for the rest, or the host would overwrite their result.
  const scores = this.runners.map((r) => (this.seats.owns(r.i) ? (r.fin > 0 ? -r.fin : -1e6 + r.progress) : NaN));
  this.ctx.flow.finish(scores, 'GOAL!');
}
  • Finishing early is normal: that client shows FINISH and waits (a WaitCard saying "Waiting for others…" is nice). The results arrive when every seat has a score or at the hard stop.
  • Races score -finishMs; unfinished runners rank by progress, below every finisher.
  • Moving obstacles are a pure function of flow.clock, so they match on every screen.
  • Quick Draw's trick for a one-off result (a shot) is to stream the whole history (a: [ms, ms, -1]) in every state, so a dropped update can never lose one.

5. Shape C: discrete events

For choices on a schedule (Treasure Doors): pick, bid, vote, reveal.

  • Rounds run on a fixed schedule of flow.clock (pick window, grace, reveal), identical everywhere.
  • You pick locally and see it at once; send link.sendEvent('pick', { round, choice }). Also stream your current choice with sendState, so a timeout still counts what you were on.
  • The host (whoever link.isHost is at resolve time) collects picks from link.onEvent, makes the CPU picks, resolves with the seeded rng, and broadcasts link.sendEvent('reveal', {...}). It applies the reveal itself too.
  • Everyone animates the reveal from that one event. The host calls flow.end(totals, headline) after the last round.
  • Unsubscribe link.onEvent in dispose() (it returns the unsubscribe function).

6. Testing with FakeRoom and FakeFlow

From @voxelparty/sdk/test, runs under bun test with no DOM.

import { describe, expect, test } from 'bun:test';
import { FakeFlow, FakeRoom } from '@voxelparty/sdk/test';

test('host and client agree, and everyone is scored', () => {
  const room = new FakeRoom({
    mg: { id: 'my-game', maxMs: 50_000 }, seed: 7,
    players: [{ id: 'p0' }, { id: 'p1' }, { id: 'p2', cpu: true }, { id: 'p3', cpu: true }],
    clients: ['p0', 'p1'],       // clients[0] is the host; p2 and p3 run on it
    latency: 100,                // one-way ms, plus up to jitter (0.5) × latency
    strictScores: true,          // a non-host reporting a seat it doesn't own throws
  });
  const frames = room.links.map((l) => new FakeFlow(l, ROUND));
  const cores = room.links.map((l, k) => new Core(l, frames[k], botDriver(k)));   // drive the humans with bots too
  room.run((dt) => cores.forEach((c) => c.update(dt)), { done: () => !!room.results });
  // The server can have every score before the host's end event reaches a client: let it arrive.
  room.run((dt) => cores.forEach((c) => c.update(dt)), { until: room.now + 1000 });
  expect(frames.every((f) => f.over)).toBe(true);
  expect(room.results!.ranking.flat().sort()).toEqual(['p0', 'p1', 'p2', 'p3']);
  expect(cores[1].world.scores()).toEqual(cores[0].world.scores());
  expect(room.stats.dropped).toBe(0);
});

Sessions: leave maxMs out of mg for an untimed session (mode 'session', endsAt Infinity, no automatic results: your game ends it, or you stop the run with until), and change the roster mid-run:

const room = new FakeRoom({ mg: { id: 'my-game' }, seed: 7, players: [{ id: 'p0' }], clients: ['p0'] });
const cores = [new Core(room.links[0])];
room.run(step, { until: room.now + 3000 });
const late = room.join({ id: 'p1' });          // a new client playing p1 (its link, which you drive too)
cores.push(new Core(late!));
room.join({ id: 'p2', cpu: true });            // a CPU: the host runs it
room.run(step, { until: room.now + 3000 });
room.leave('p0');                              // the host leaves: the next client becomes host
room.run(step, { until: room.now + 3000 });
expect(cores[1].world.fighters.has('p2')).toBe(true);   // the new host carried the world on

Every link's players and onPlayers follow a latency later, as online. A left client's link has gone: true and stops sending: skip it in your step (cores.filter((c) => !c.link.gone)).

Dropped connections and reloads (any mode, board minigames too). The player stays on the roster while their client is away, and the host drives them. The host is the earliest-joined player whose client is here, as on the server, so a host that reloads is host again once it's back (with the kept world). Test both: they're where "stuck dead", "invisible" and "the match restarted" bugs live.

room.drop('p1');                               // p1's connection is lost: the host drives p1 meanwhile
const back = room.rejoin('p1');                // p1 is back: a new client, a new `instance`, a fresh game
cores.push(new Core(back));                    // make a new core on it, as a reloaded page would
const fresh = room.reload('p0');               // drop + rejoin at once: the host reloads its tab

Check the exact signatures in node_modules/@voxelparty/sdk/types/minigames/kit/loopback.d.ts.

  • FakeRoom options: mg ({ id, maxMs? , players?, input?, mode? }), seed, players (seat order; cpu: true = run by the host), clients (the pid each client plays, null = spectator), latency, jitter, reorder, countdownMs (default 0: live at once), maxMessage, strictScores, rateLimit ('throw' default, or 'drop').
  • room.tick(ms), room.run(step, { until?, fps? = 60, done? }), room.now, room.links, room.scores (a Map of pid → accepted score, first per player), room.results (set once everyone is scored, or at endsAt + 2.5 s), room.stats { states, events, bytes, maxMessage, dropped, keeps, maxKeep }.
  • Kept worlds (link.keep, section 8): the host's reach the room a latency later, at most one a second (over 64 KB throws); room.kept is what the room holds ({ world, at }, null once the run is over). When the host leaves, the new host's onKept gets it inside room.leave, before its next frame as host.
  • Each FakeLink also has reported (the scores it sent) and sent { states, events, keeps, bytes, dropped }.
  • new FakeFlow(link, round?) is a GameFrame (live, clock, timeLeft, end, finish, plus over, headline, setRound, dispose). end relays the finish to the other FakeFlows exactly as Flow.end does.
  • Promises don't settle inside room.run: the loop is synchronous, so link.results.then(...) (and FakeFlow's own "TIME UP!" from it) only runs after it returns. Check room.results in the loop, and end the round from your game's timer (timeLeft <= 0), as the real game should anyway.
  • Worth asserting: a non-host's own movement changes on the first frame; host and client agree on scores and items; every one-off event arrives exactly once (count them on both sides); the round ends on both sides with the same headline; client.reported.size === 0 in host-authoritative games; nothing dropped; room.stats.maxMessage well under 16 KB.

7. Pitfalls

  • Counters, not flags, for anything pressed: input.presses('action'), dashes++. Flags get lost to the 20 Hz throttle.
  • State is what's true now. No t: link.now(), frame numbers or Math.random() in a state or snapshot, and round positions (r100): an unchanged state isn't sent, and one field that always changes keeps an idle player at 20 messages a second. When something happened belongs in an event (PlayerSync.event and link.sendEvent carry their time).
  • The NaN score rule (per-player): flow.finish gets a finite score only for owned seats. strictScores only catches a non-host breaking it; on the host a stray finite score silently wins, so compute scores with seats.owns(i) ? … : NaN. Host-authoritative games use flow.end with every score instead.
  • One finish. flow.end/flow.finish only count the first time; guard with an ended flag so you don't spend work every frame, and stop sending state after it.
  • Rate limits. Never sendEvent every frame. One-offs ride on HostSync.event (the host's) and PlayerSync.event (a player's); state goes through sendState/PlayerSync (batched). A burst of 10 sendEvents in one frame is fine; 60 a second is not.
  • Seeded randomness must be consumed identically. Every client must make the same rand() calls in the same order. Keep separate streams for separate jobs (layout, host-only spawns, bots, scenery) so a host-only call never shifts a shared one: mulberry32(link.seed ^ SALT), botRng(link.seed). Island and arenaStage's pollen consume their own rng in a fixed order.
  • Clocks. Schedule shared moments by flow.clock, not by adding up dt (frame rates differ). The platform already caps dt at MAX_DT (0.1 s), so a hitch doesn't teleport things through walls; headless cores driven by FakeRoom get its fixed step. Don't add your own clamp.
  • Untrusted input. Validate everything from the network: HostSync's valid, Number.isFinite on streamed numbers, bounds on indices. read()'s latest and at (and PlayerSync's latest/smooth) are null until something arrives.
  • Spectators and bots-only runs. seats.you === -1: no input, no sending for yourself, and the camera must frame the whole field (or a bot) instead of "me".
  • Any number of seats. Board minigames: vp check plays 4-, 2- and 3-player rounds. Sessions: 1 up to players.max, changing mid-run. Size arrays by players.length / seats.count (or key by pid), never a hard-coded 4, and make sure the game is still a game with the fewest players (spawn points, an arena that isn't empty, a win condition that can trigger).
  • Last one standing: keep the knockout order as groups of seats (players out on the same frame share a group) and end with flow.end(scoresFromKnockouts(n, groups)): survivors first, ties shared.
  • The host can change mid-game when a host drops (in sessions, sooner or later it will): read link.isHost each frame rather than caching it, and have the host keep the whole world (HostSync's keep) so a new host carries on exactly (section 8).
  • Show each event once, everywhere. Put effects and sounds where the event is applied (the host's hostStep result, the clients' onEvent), not where it's detected, so every client sees and hears it exactly once. Coin Cascade pushes cues from both paths and draws only from those.
  • Other players: smooth() or Reckon, never your own guess from latest() (section 2).
  • Snapshots small: flat number arrays ×100 (r100), about 1 KB. Send Avatar.shown for remote players so clients get the host's smooth path.

8. Sessions: a live roster and host changes

In a session (link.mode === 'session') people join and leave while the game runs. The full guide is vp docs sessions; the netcode rules:

  • Seat indices shift. link.players is replaced and ctx.players updated in place when the roster changes; a leaver's index disappears and later seats move up. Key per-player state by pid (seats.pid(i), link.players[i].id) in Maps, and look indices up when an API needs one. PlayerSync and HostSync are index-based at the call (send(i, …), latest(i)) but keyed by pid inside, so they stay right as long as i is the current index.

  • GameStage.onPlayers(players, joined, left) (or link.onPlayers) runs on every change: create and remove fighters, avatars, bots. A joiner's state appears on each client when that client hears of the join; the host's snapshot may arrive a moment before or after: create unknown pids from the snapshot too, and ignore snapshot entries for pids no longer on the roster.

  • Snapshots carry ids, not seat order: { ids: ['p0', 'p3'], f: [x, z, …, x, z, …] } or { f: [['p0', x, z, hp], …] }. Seat order differs between a snapshot's send and its arrival whenever someone joined or left in between.

  • Host changes. When the host drops, the next client becomes host (link.isHost turns true there) and must carry the world on. Snapshots are built to be small, so they're lossy (HP rounded, only the newest shots, no bot plans), and a new host that rebuilds from one loses what they leave out. So the host also keeps a full copy with the room, and the room hands it to the next host (only to them; it's never broadcast):

    const sync = new HostSync<Snap, Ev>(link, {
      valid: isSnap,
      keep: { save: () => world.save(), load: (w) => world.load(w) },   // load validates
    });
    // host, every frame, as before: sync.tick(dt, () => world.snapshot());   (it also keeps, about 1/s)
    
    • save() returns the whole world as plain JSON, at full precision: everything the host decides (HP, projectiles in flight, spawn queues, round state, scores, bot targets), timers as link.now() times. At most 64 KB; aim for a few KB. No three.js objects, Maps or class instances: they don't survive JSON.
    • load(world, at) replaces the world with it. It runs on the new host before isHost reads true there, and on a host that reloaded mid-run soon after its game starts (it's host from its first frame). It came from another client: validate it and ignore junk. at is when it was kept (server ms), up to a second ago.
    • Without HostSync: link.keep(world) on the host (the latest call wins and it's sent at most once a second, so call it about that often) and link.onKept((world, at) => …).
    • Too big? link.keep returns the world's size in characters of JSON; over KEEP_MAX (64 KB) it isn't kept, and the room keeps the last one that fitted (warned once in the console). Check it in a test at your biggest world (16 players, the late game), and shrink: numbers as whole numbers (r100), lists of numbers with packInts ({ delta: true } for sorted or slowly changing ones: ids, tiles, paths), grids with packBytes, and leave out what the new host can rebuild (paths, caches, effects). Unpacking throws on junk: it's inside your load, which validates anyway.
      save: () => ({ n: w.tick, hp: packInts(w.hp), tiles: packBytes(w.tiles) }),
      load: (d) => { try { w.hp = unpackInts(d.hp); w.tiles = unpackBytes(d.tiles); } catch { /* junk: ignore */ } },
      
      Worlds that are big by nature (an RTS late game) use Lockstep (section 10): every client has the whole world, so a new host needs no kept copy, and one that reloads gets it from the others. A world of blocks players build and break uses WorldSync (vp docs world): the host puts everyone's edits in order, your own show at once, and joiners, reloads and new hosts get the world in pieces the same way.
    • Without a kept world, adopt the latest snapshot when isHost turns true (sync.read().latest); anything it leaves out is lost at a handover.

    CPUs (role 'bot') move to the new host with it. Offline (vp dev) onKept never fires; FakeRoom hands the kept world over in room.leave, room.drop and room.reload of the host (section 6).

  • Reloads. A player who reloads keeps their id and seat, but their game starts from scratch, and a host that reloads is host again. So: take the kept world when it comes (load above), never assume a counter or sequence number from a player only goes up (PlayerSync.onReset, link.instance), and for match setup use Setup, which asks the others before a returning host takes charge, so the match that's on carries on.

  • Disconnected players stay on the roster for a few seconds with role 'bot' on the host before they leave: your bot drives them, so nobody freezes in place.

  • Untimed runs have flow.timeLeft === Infinity and link.endsAt === Infinity: never compute "time left" fractions from them. Schedule by flow.clock or link.now().

  • Budgets with 16 players: one state blob per owned seat per 20 Hz tick, batched; keep each under ~200 bytes, and host snapshots under ~4 KB (flat arrays ×100). Events stay rare.

9. Shooters: the shooter decides what it hit

Fast shooters (first person, twin-stick) can't wait a round trip to find out whether a shot hit: by the time the host has checked it, the target has moved on your screen. So the one who fires decides, from what they see, and the host keeps score. This is how Frag Island plays, and what vp init --fps starts from (vp docs fps for the movement, camera and CPUs).

  • Everyone moves their own body and streams it with PlayerSync ({ x, y, z, yaw, pitch, l }, l = which life it is). The host moves the CPUs. Nobody follows anyone else's position: in a shooter, where you are is yours.
  • Firing is local. Trace the shot at once against the map (grid.ray) and the other players as you draw them (sync.smooth(i) for remote seats: the same positions that are on your screen), show the tracer, play the sound, and send it with PlayerSync.event: the shot (from, to: everyone draws the tracer) and, if it hit, the claim (victim, damage, the victim's life number). The template sends { k: 'shot', o, e } and { k: 'hit', v, d, l }; a fast gun should send one event per shot with its hits inside, as compact arrays, and a lower keepMs (section 2): one-offs cost no messages, but every state carries every one still riding. What cut Frag Island's biggest message in half (9.8 KB to 4.3 KB with 12 players): hits always go, but only every other missed hitscan tracer is sent to the others; nobody counts the misses.
  • Who sent it: sync.instance(i) is the run of the game seat i's state came from. A new one means a reload, so the host can tell a reloaded player's life 1 from the old life 1.
  • The host keeps score. It gets every claim once (sync.onEvent), checks it's plausible (the shooter is alive; the victim is alive and on that life, so a hit on a corpse or a respawned player doesn't count; no faster than the gun fires; within its range), applies it to HP, frags and deaths, and broadcasts the result: HP and scores in HostSync snapshots, and dmg, frag, spawn and win as HostSync events. The host's own shots and its CPUs' go the same way, without the network.
  • Deaths and respawns come from the host. It picks the spawn (the one farthest from enemies) and sends { k: 'spawn', pid, s, l }; the body's owner teleports there and streams the new life.
  • Knock-back (a rocket's blast) goes to whoever moves the victim: the host sends it with the dmg event and the victim's owner applies it with push. Your own rocket's push on yourself you apply at once (that's what makes rocket jumps feel right).
  • Projectiles (rockets) fly on a fixed path from (origin, direction, time fired), so every screen draws the same flight without syncing it. Only the shooter's client decides the explosion; others just draw it and never apply it to themselves.
  • Restarts. A player who reloads starts from life 0 with fresh numbers: PlayerSync handles its own (onReset), clear your per-sender state there (fire-rate timers). Keep the match with HostSync's keep so a new or reloaded host carries on.
  • Trust. The shooter can lie about what it hit. Friends' games accept that for the feel; the checks above stop the accidental cases (lag, a reload, a respawn).
  • When the host decides instead (a hook, a melee swing, a tag, anything the shooter can't settle alone), judge it by what the actor saw: keep a History of positions on the host and look at past.seen(at), at being when the one-off happened (section 2). Without it, a player with a slow connection has to lead every target by their ping.

10. Shape D: deterministic lockstep (Lockstep)

For games whose world is too big to snapshot but where players only give orders: an RTS (build, send, sell), tower defense, snakes (turn), card and board games. Every client runs the same simulation on the same orders at the same ticks, so only the orders travel (a few bytes), whatever the size of the world. Castle Fight, Line Tower Wars and Worm War each built this by hand; Lockstep is the shared version.

import { Lockstep, type LockstepInput } from '@voxelparty/sdk/core';
type Order = { k: 'build'; x: number; z: number; kind: number } | { k: 'start'; match: number; seed: number };

const ls = new Lockstep<World, Order>(link, {
  create: () => new World(),                        // the first host's world (lobby, no match yet)
  step: (w, inputs, tick) => w.step(inputs, tick),  // one tick, deterministic (below)
  hash: (w) => w.hash(), save: (w) => w.save(), load: (d) => World.load(d),   // load: null on junk
  valid: isOrder,                                   // every order from the network is checked
  onTick: (w) => cues.push(...w.events.splice(0)),  // the world's events, after each tick
  mayResume: () => setup.phase === 'play',          // no match on: a host with no world starts at once
});
// every frame:
ls.update();                                        // host: seal + simulate; clients: follow the seals
if (link.isHost && setup.match > (ls.world?.match ?? 0)) ls.system({ k: 'start', match: setup.match, seed: link.seed ^ setup.match });
for (const p of ls.pending) drawGhost(p.o);         // your orders the world hasn't applied yet
draw(ls.world, ls.alpha);                           // glide between the last two ticks (alpha 0..1)
// on a click:
ls.order({ k: 'build', x, z, kind });

step gets the tick's inputs in the order the host sealed them:

  • { k: 'order', pid, o }: a player's order (or a CPU's: the host's ls.orderFor(pid, o)).
  • { k: 'system', o }: the host's ls.system(o) (a match starting, a vote closing). A host change can lose one that wasn't sealed yet, so send them from a check you make every frame (as above: the setup is on match 3, the world is still on 2), and the next host sends it again.
  • { k: 'join', pid, cpu }, { k: 'leave', pid }, { k: 'away', pid, away }: the roster, sealed into the world (roster: false turns them off). away: a person whose connection dropped; the host's CPU plays for them (orderFor) until away: false. ls.members is who the world has.

Determinism is the whole game. Everything step reads must be in the world or the inputs:

  • Whole numbers or fixed point (milli-cells, integer HP). Floats only where every browser computes them identically (+ − × ÷, Math.sqrt, Math.floor); never Math.sin/cos/atan2/pow/exp on anything that feeds back into the world (use a lookup table, or integer steps).
  • Randomness from a seeded stream kept in the world (a mulberry32-style state as a number, advanced inside step), never Math.random, Date.now or link.now().
  • Iterate in a fixed order: arrays, or Maps filled in the same order on every client. Sort anything you build from a Set or object keys.
  • CPUs are best inside the simulation (Line Tower Wars: their choices come from the world's rng, so they need no network and survive any host change), or give orders from the host (orderFor).
  • hash covers everything that matters (a 32-bit FNV over the numbers); save/load round-trip exactly. Test both: step two worlds with the same orders and compare, and load(save(w)) then step both.

What it does for you:

  • The host seals ticks on the shared clock (tickMs, default 50: 20 a second) and sends them about ten times a second, with its world's hash every hashEvery ticks (20).
  • Orders are stamped with the tick they're for (the next one; ls.order(o, { tick }) for a snake's turn on its next cell), and the host runs delayMs behind the clock, learned from how late orders arrive (between delay.min and delay.max, default 0–250 ms), so they land on their tick. A late one takes the next open tick; none is lost, and none applies twice (the world remembers each player's last order number, per run of their game).
  • Clients simulate only sealed ticks, a little behind: no rollback. Your own orders show at once in ls.pending: draw them as ghosts (a building going up, gold spent). The character you steer (a runner, a snake's head, a hero you walk with the keys) is the exception: draw it from ls.predictor() (below), never from the world, and never with a prediction of your own.
  • A client whose hash differs asks for the whole world and carries on from it (onLoad); so do joiners, reloaded tabs, a client that missed a seal, and one far behind (a hidden tab). Whole worlds go in chunks, only to whoever asked (sendEvent's to), paced under the rate limit, so size isn't a problem (up to 256 chunks of 12 KB).
  • Host changes: every client has the world, so the new host simulates what was sealed and carries on in a new epoch, sending everyone its world. Orders the old host never sealed are sent again by their players. The host keeps the world with the room once a second when it fits in 64 KB (stats.keepsSkipped counts the ones that didn't); a host that reloads takes the newest of the room's copy and the others' worlds.
  • Offline (vp dev): you're the host; it starts at once and simulates on the clock.

Your own character: ls.predictor(). An order lands a round trip after you give it, too late for dodging, so what you steer is drawn predicted: the world's copy of your body stepped on to now with your orders still on their way. Give it three small functions and it does the rest:

// sim.ts: ONE function for what an order does to a body, and ONE for a tick of its movement.
// The world's step calls them for every runner; the prediction calls them for yours.
export function steerBody(b: Body, o: Order) { if (o[0] === 'm') { b.dx = o[1]; b.dz = o[2]; } }
export function moveBody(w: World, b: Body) { /* speed, walls: read w, write only b */ }

// core.ts
this.me = ls.predictor<Body>({
  from: (w) => {                                   // a COPY of your body, or null: nothing to predict
    const r = w.runnerOf(link.you);
    return r && r.alive && !w.cpuDrives(r) ? { x: r.x, z: r.z, dx: r.dx, dz: r.dz } : null;
  },
  order: (b, o) => steerBody(b, o),
  step: (w, b) => moveBody(w, b),
});
// game.ts, every frame after ls.update():
const p = this.core.me.update();                   // { x, z, y?, body } or null
if (p) avatar.set(p.x / FP, 0, p.z / FP, yawOf(p.body.face));
else drawFromWorld(r, ls.alpha);                   // spectating, or your CPU is playing

What it gets right that hand-rolled prediction gets wrong (Stampede Tag's runner stood still for half of every tick and then jumped, 20 times a second, on the host and solo):

  • It draws the moment now on the shared clock, always past the world's last tick, gliding between the two predicted ticks around it: no stall-then-jump however far behind the host runs.
  • Your orders apply on the tick they're stamped for, late ones on the next (where the host puts them), so the prediction agrees with the world; a disagreement (an order that reached the host late) is eased in over ~150 ms (rate), never a jump back, and a big one (a respawn) snaps (snap, or me.teleport()).
  • from must return a new object and step must only read the world: it checks the world's hash around its first predictions and says so on the console if they changed it.

Use the same steerBody and moveBody in the world's step, or the prediction and the world disagree every time you turn. vp check holds a direction with your seat and warns when anything moves in stops and starts (testing §4).

Test it with FakeRoom: one Lockstep per link, update() every frame, record hash(world) per tick in onTick, and assert that every pair of clients agrees at every tick both simulated, through join, leave of the host, reload, drop/rejoin and latency up to 250 ms, and that every order a player gave was applied exactly once (let the world count them). ls.stats has seals, fulls, desyncs, resends and keeps for your asserts.


11. The host decides: actions, secrets and timed events (HostSync)

Three things every host-authoritative game ends up needing, each easy to get subtly wrong (lost actions, doubled buys, a role that never arrives after a reload). They're all on HostSync, and they all survive the host leaving, dropping or reloading, as long as the world comes back through the keep option (not link.keep by hand alongside).

const sync = new HostSync<Snap, Fx, Act, Secret>(link, {
  valid: isSnap,
  keep: { save: () => world.snapshot(), load: (w) => void (world = World.from(w)) },
  act: (a, pid, at) => world.apply(pid, a, at),   // host: each player's action once, in order; false = turned down
  validAct: isAct,                                // anything from the network is untrusted
});

Actions: instant on your screen, exactly once on the host

For what players do that the host must decide (grab, place, buy, open a door): the player sees it at once, the host settles who got the tomato.

// Anyone, when the player does it (the host too, and the host for its CPUs: act(a, cpuPid)):
sync.act({ k: 'grab', cell });

// Clients, every frame: what to draw. The host's newest world with your pending actions on top.
// Made again only when a snapshot arrives or `pending` changes, so call it every frame.
const view = link.isHost ? world : sync.predict((s) => World.from(s), (v, a, at) => v.apply(me, a, at));

// Your action the host turned down (it's gone from the prediction): undo its effect, play a "nope".
sync.onReject((a) => sfx.play('nope'));
  • The act option runs only on the host, with every player's actions exactly once and in the order each player did them; the host's own at once, inside act(). at is when they did it (link time, clamped to at most a second back): judge "was it still there then?" with it. Return false to turn it down.
  • Every action is numbered. Each send carries all your unconfirmed ones, and the host applies each player's in order, so a host change never makes one jump the queue. Unconfirmed ones go again after 1.5 s; everyone keeps others' actions for a few seconds, and a new host applies what the old one never got to. If a new host restores an older kept world, your client sees the host's record go back and sends what it lost again. Nothing is applied twice.
  • sync.pending is your unconfirmed actions, oldest first; predict replays them. Make each action say what it expects to find ({ k: 'grab', cell, was: 'tomato' }) so it never does something else by surprise on the host.
  • About 60 messages a second per client: actions are for what players do, not for every frame (stream movement with PlayerSync).
  • A kept world and the record of actions go together. If keep.load doesn't take the kept world (you already have a newer one), return false from it, so the record follows your world.
  • A host that reloads should wait for its kept world before acting as host (Setup's fresh says whether one is coming).

Secrets: one player's eyes only

Roles, hands, a hidden target. The host tells; only that player's client receives the bytes.

// Host, whenever it changes (a new round, the gun changing hands):
sync.tell(pid, { role: 'murderer', round });
sync.secretOf(pid);                          // host: what pid was told (CPUs' too: they have no client)

// Everyone: your own.
sync.onSecret((s) => showRole(s.role));      // once per change; again after a reload; now if you already have one
sync.secret;                                 // the newest, or null before the host has told you

Delivered once (a new version each time you tell), resent until the player's client says it has it, told again when they reload, and kept with the room, so the next host knows everyone's without telling anyone twice. Keep it plain JSON and small. Everyone else only ever sees what the snapshot carries, so never put a secret there.

Timed events: the same moment on every screen

// Host: a bomb that goes off at a set time, on every client and the host.
sync.schedule(link.now() + 250, { k: 'boom', x, z });

// Everyone, every frame:
for (const { e, late } of sync.due()) boom(e, late);   // late: ms past its time; catch up by that much

Schedule at least a latency ahead (150–250 ms) and it lands everywhere together; a client that got it late still gets it, once, and knows by how much. It goes out at once (no waiting for a snapshot). Late joiners don't get events scheduled before they came.

sync.flush() sends the queued event()s now instead of with the next snapshot, for a moment that must land fast (a starting gun). The world itself follows with the next snapshot.

Testing it

FakeRoom has everything these need: latency and jitter, leave, drop, rejoin and reload (the host's too). A test that runs a shop through random host leaves, drops and reloads and checks every client's buys land exactly once takes a few lines, and the same lines work for your game's invariant (coins, items, scores add up; nothing pending at the end).

PlayerSync.onReport((i, state, sentAt, pid) => …): every state from another client as it arrives, even two in one frame (fresh() only sees the newest by the next frame).

SDK reference · vp docs input · input.md

Input: actions, buttons, gamepads, touch screens, the mouse and raw keys

ctx.input is a Controls: this player's input, polled once a frame. A game plays on a keyboard and mouse, a gamepad and a phone's touch screen, with no device code, when it reads:

  • actions (move, action, up/down/left/right): what every game gets, and all a board minigame may use;
  • its own buttons, declared in defineGame({ buttons }): a key, a pad button and a touch button each;
  • the mouse (games with mouse / pointerLock in game.json's input). A pad's right stick and triggers drive it too, and so do the touch screen's drag and its FIRE / AIM buttons.

Raw keys (input.keys) are there for what nothing else covers. A raw key has no pad button and no touch button, so prefer a declared button.

Contents

  1. Choosing controls
  2. Actions
  3. Your own buttons (defineGame({ buttons }))
  4. Gamepads and touch screens
  5. The mouse ("mouse")
  6. Pointer lock and mouse look ("pointerLock")
  7. Raw keys ("keyboard")
  8. Recipe: a first-person camera
  9. Recipe: aim and shoot at the pointer (top-down)
  10. Networking input
  11. Title-card labels

1. Choosing controls

Game input Controls
Board minigame [] (required) move + action, at most 3 controls
Top-down arena, twin-stick shooter, RTS-ish ["mouse"] WASD to move, the pointer to aim, click to shoot
First-person shooter, flight, anything with mouse look ["mouse", "pointerLock", "keyboard"] WASD + mouse look, click to fire, R to reload, Shift to sprint
A builder, a card game ["mouse"] point and click

Ask for what the game really uses: players see the manifest ("Mouse"). Keep controls few and conventional: WASD, Space to jump, Shift to sprint, R to reload, E to use, the mouse wheel or 1–4 to switch. Declare each extra key as a button (§3), so it gets a pad button and a touch button. A phone has two thumbs, so a stick and three or four buttons is about as much as it can play.

2. Actions

type Action = 'action' | 'up' | 'down' | 'left' | 'right';
input.move();               // Stick { x, z }, each -1..1: x right, z towards the camera (analog on a stick)
input.dir();                // grid games: the most recently pressed direction still held, or null
input.down('action');       // held (SPACE, X, ENTER, a left click or tap, a pad's A, the big touch button)
input.pressed('action');    // went down this frame
input.presses('action');    // presses since the game began: stream this counter, not a flag
input.pressedAt('action');  // the last press on link.now()'s clock, accurate to the event
input.pressTimes('action'); // every press since the last frame: readonly { at, local }[] (reaction games)

A left click (or a tap) anywhere that isn't a button also counts as action, except in mouse-look games (pointerLock), where a click shoots. On a pad or the touch stick move() is analog, so a half push is a slow walk. down('up') and the other directions count once the stick is past halfway.

3. Your own buttons (defineGame({ buttons }))

Each key a game uses beyond the actions is a named button. It reads like an action, and gets a pad button and a touch button with no more code.

export default defineGame({
  …,
  controls: [[KEYS.move, 'Run'], [KEYS.action, 'Jump'], ['R', 'Reload'], ['SHIFT', 'Dash']],
  buttons: {
    reload: { keys: ['KeyR'] },                                   // pad: the next free button (X); touch: "RELOAD"
    dash: { keys: ['ShiftLeft', 'ShiftRight'], pad: 'rb', label: 'DASH' },
    map: { keys: ['KeyM'], touch: false },                        // no touch button (a phone doesn't need it)
    action: { label: 'JUMP' },                                    // relabel (or add keys or pad buttons to) the built-in one
  },
  create: (ctx) => new MyGame(ctx),
});
// in update:
if (input.pressed('reload')) reload();
if (input.down('dash')) dash();
send({ ..., r: input.presses('reload') });    // counters, like actions
  • A ButtonSpec has these fields:
    • keys: key codes.
    • pad: one pad button or several: 'a' | 'b' | 'x' | 'y' | 'lb' | 'rb' | 'lt' | 'rt' | 'select' | 'start' | 'l3' | 'r3' | 'up' | …. The default is the next free one of X, Y, B, RB, LB, R3, L3 and VIEW.
    • label: the touch button's text. The default is the title card's words for its key, else the button's name.
    • icon: an image URL for the touch button, e.g. ctx.engine.icon(model).
    • touch: false: no touch button.
  • down, pressed, presses, pressedAt and pressTimes all take button names. An unknown name throws, so a typo shows up on the first frame.
  • The names click, rightClick, middleClick and wheel are taken: they're the mouse's, on touch screens.

4. Gamepads and touch screens

There's nothing to do: the platform maps both onto the same actions, buttons and mouse.

Keyboard and mouse Gamepad Touch screen
move(), directions WASD / arrows left stick (analog), d-pad a floating stick under the left thumb
action SPACE, X, ENTER, a click A the big button, or a tap on the game
your buttons their keys their pad button a button each, around the big one
look() the mouse right stick (eased, dt-scaled, in mouse pixels) drag on the right half (mouse-look games)
mouseDown(0) left button RT FIRE (mouse-look games), or a finger on the game
mouseDown(2) right button LT AIM, when the title card mentions RIGHT CLICK
wheel() the wheel LB / RB (unless a button has them) SWAP, when the title card mentions WHEEL
title card: ready, results SPACE / ENTER / the button A or MENU tap the button
a Menu: move, press, close arrows / WASD, ENTER / SPACE, ESC d-pad or left stick, A, B (LB / RB a row) tap
open a keyed Menu its key the pad button buttons gave that key, else VIEW (Menu({ pad })) its own button, if you give it one
input.device;                    // 'keyboard' | 'touch' | 'pad': the last thing they used (changes mid-game)
input.aiming;                    // look is live: the pointer is locked, or they're on a pad or a touch screen
input.label(KEYS.action);        // "SPACE", "A" or "TAP"; label('reload') → "R", "X" or "RELOAD"
input.rumble(0.6, 120);          // shake the pad (a phone buzzes where it can); harmless elsewhere
input.pad;                       // the pad itself: down('lt'), pressed('y'), stick('right'), trigger('rt') (0..1), connected
  • Gate look, fire and "Click to aim" hints on input.aiming, not input.locked: pads and touch screens look without a lock.
  • Use input.label in your own HUD hints ("Press ${input.label('reload')} to reload") so they read right on every device. The title card already does.

The touch controls show on touch screens while the game is live and no Menu is open. They're worked out from game.json's input and the title card:

  • A stick. In mouse games it only works in the bottom-left corner, so a tap anywhere else stays a click.
  • The big button: FIRE in mouse-look games, else action. Its label comes from its title-card row ("Jump" → JUMP).
  • Then action, then your buttons.

Change the layout with defineGame({ touch }):

touch: { buttons: ['action', 'dash', { name: 'reload', label: '⟳' }], stick: true, look: false },
touch: false,                    // none: draw your own

At run time, input.touch has:

  • enabled: false hides the controls, e.g. for a phase with its own touch UI.
  • shown: whether they're on screen now.
  • setButtons([...]): a new set per phase, e.g. BUILD in the build phase and FIRE in the fight.
  • root: the layer's element.

The controls are plain DOM with stable class names, so your CSS can move or restyle any of them: .vp-touch, .vp-touch-stick, .vp-touch-btns, .vp-touch-btn[data-button="dash"] (.big, and .on while held). Each piece that takes up screen space has data-vp-touch.

Keep your HUD clear of thumbs:

  • --vp-touch-h on :root is how far up the buttons reach (0 while hidden). bottom: calc(var(--vp-touch-h, 0px) + 12px) keeps a HUD bar above them.
  • html.vp-touching is set while they play by touch, for CSS that moves or hides desktop-only HUD. The screen's size is html.vp-phone (a phone-sized screen either way up, whatever the input: vp docs api §13).

Your own touch UI: input.bind(element, name) makes any element a button (an action, one of yours, or 'click' / 'rightClick') for both held and pressed, and returns the unbind. A game that draws everything itself (touch: false) can also listen to pointer events with e.pointerType === 'touch'. A finger on the game counts as the left button and moves pointer().

To test on a phone-sized touch screen: vp shot --phone (pictures on a phone held sideways, and how much of the screen your HUD covers: vp docs testing), or by hand Chrome's device toolbar (Ctrl+Shift+M) with a phone in landscape.

5. The mouse ("mouse")

input.pointer();            // { x, y } in normalized device coordinates: -1..1, x right, y up, (0,0) the centre
input.mouseDown(0);         // a button is held: 0 left (default), 1 middle, 2 right
input.mousePressed(2);      // a button went down this frame (right click: the browser menu is suppressed)
input.wheel();              // wheel turn this frame in px: + = scrolled down / towards you, 0 if it didn't turn
input.look();               // { dx, dy } mouse movement this frame in px (dx right, dy down)
input.ray(camera, out?);    // a three.js Ray from the camera through the pointer (the centre when locked)

pointer() is Raycaster.setFromCamera's input. ray() does it for you and writes into out, so aiming every frame allocates nothing:

private readonly caster = new Raycaster();
private readonly ground = new Plane(new Vector3(0, 1, 0), 0);
private readonly aim = new Vector3();
…
this.ctx.input.ray(this.view.camera, this.caster.ray);
if (this.caster.ray.intersectPlane(this.ground, this.aim)) { /* aim.x, aim.z is where the pointer is on the floor */ }
const hit = this.caster.intersectObjects(this.targets, false)[0];   // or what it's over

These all exist in every game (they return 0 / false / the centre when nothing happens), so shared code can call them; only games with mouse in input should depend on them.

6. Pointer lock and mouse look ("pointerLock")

Mouse look needs the pointer locked: the cursor hides, and look() keeps reporting movement past the edge of the screen. Browsers only lock on a user gesture (a click), and the sandbox only allows it when the manifest lists pointerLock.

Let the platform do it: defineGame({ pointerLock: true, … }) locks on "Click to play", locks again on a click while playing (after Escape gave the pointer back), and unlocks at the finish. To do it yourself, call input.lockPointer() on a frame where input.mousePressed() is true, or from flow.onPlay(cb) (it runs inside the "Click to play" gesture).

For a stretch where the player points at things (a shop, a map, a pointer-driven mode), set input.pointerLock = false. The pointer is freed and the platform stops re-locking on clicks. Set input.pointerLock = true again and the next click locks it. Pads and touch screens look without a lock (input.aiming).

Mouse look runs at the player's own sensitivity: the site's settings (the gear) have a slider for it, and look() already scales a locked pointer's movement by it (a free cursor's never). A game needn't offer its own; one that does multiplies on top.

input.locked;               // true while locked
input.lockPointer();        // on a click only; harmless if refused (and on touch screens)
input.unlockPointer();
input.pointerLock = false;  // the platform's automatic lock: off for now
  • Escape always unlocks (the browser does it) and opens the page's menu. While unlocked the game keeps running: show a small "Click to aim" hint (ui.hint()) when flow.live && !input.aiming.
  • look() also works unlocked (it's the mouse's movement), but then the cursor hits the screen edge: only use it for look when input.aiming.
  • Sensitivity: about 0.0022 radians per pixel feels standard; clamp pitch to ±1.45 rad.

7. Raw keys ("keyboard")

input.keys is the raw keyboard (Keys, by KeyboardEvent.code):

input.keys.down('ShiftLeft', 'ShiftRight');   // held
input.keys.down('Shift');                     // either side: 'Shift', 'Control', 'Alt', 'Meta'
input.keys.shift; input.keys.ctrl; input.keys.alt; input.keys.meta;   // held (a Shift held before the game had focus counts once they click)
input.keys.pressed('KeyR');                   // went down this frame
input.keys.latest(['Digit1', 'Digit2', 'Digit3']);   // the most recently pressed of these still held, or null

Codes are physical positions (KeyW is W on QWERTY and Z on AZERTY), which is what you want for WASD. Keys is exported from @voxelparty/sdk for types. A raw key has no pad or touch button, so declare a button (§3) for anything a player needs. Don't bind Escape (the page owns it) or browser shortcuts (Ctrl+W…). Tab is yours: the runtime stops the browser from moving focus with it (onto the page's buttons), so it's fine for a held scoreboard. Alt is yours too: letting go of it doesn't open the browser's menu.

8. Recipe: a first-person camera

Use the kit (vp docs fps; vp init <id> --fps starts from a working shooter). FpsCamera puts the camera at a body's eyes with the feel done: eased stairs, a landing dip, head bob, recoil, shake, zoom, and a held gun that can't poke into walls. readIntent reads WASD, action (jump), mouse look, clicks, 1–9 and the wheel into one intent, the same shape a CPU returns, and reads a pad and a touch screen the same way. fire / alt are held; firePressed / altPressed went down this frame (semi-automatic guns, a scope toggle).

game.json: "input": ["mouse", "pointerLock", "keyboard"]. index.ts: pointerLock: true.

import { FpsCamera, arenaStage, readIntent } from '@voxelparty/sdk';
import { fpsStep, turn } from '@voxelparty/sdk/core';

this.view = arenaStage(ctx.engine, { fov: 95, tilt: 0, near: 0.04, pollen: false });
this.view.scene.add(this.view.camera);
this.fp = new FpsCamera(this.view.camera);
// every frame, while you play:
const it = readIntent(ctx.input, { scale: this.fp.lookScale });   // mouse right turns right
turn(this.body, it.dyaw, it.dpitch);
const moved = fpsStep(this.grid, this.body, it, dt);            // collides with your VoxelGrid
this.fp.update(dt, this.body, moved, it);
  • Don't call view.rig.update in first person (the rig would move the camera). Hide your own avatar.
  • Spectators and CPU-only runs have no body of their own: give the camera something to look at (orbit the map, follow the leader), or vp check's screenshots show the sky.
  • On autopilot (input.autopilot, in vp shot and vp check) your own CPU plays your seat: return null from your intent and let the core use your bot (vp docs testing §1).
  • Keep the sun's shadow box around the player: view.sunAt(position) (a Vector3) when you move far.

9. Recipe: aim and shoot at the pointer (top-down)

"input": ["mouse"]. The arena camera stays as usual (view.rig.fit), WASD moves, and the avatar faces the pointer:

this.ctx.input.ray(this.view.camera, this.caster.ray);
if (this.caster.ray.intersectPlane(this.ground, this.aim)) me.yaw = Math.atan2(this.aim.x - me.x, this.aim.z - me.z);
if (input.mouseDown()) this.tryFire(me);   // held = automatic fire, with a cooldown

A pad has no pointer: when input.device === 'pad', aim with input.pad.stick('right') and fire on mouseDown(), which RT drives. On a touch screen a tap aims and fires where it lands. Bots aim with the same numbers (a yaw towards a target plus a little error), so the rules code takes an aim angle, not the mouse.

10. Networking input

  • Stream what you do, not what you press: your position, yaw/pitch (angles: add them to PlayerSync's angles so they interpolate the short way), and counters for one-off actions (shots: 12), never fired: true flags.
  • Shots that matter (damage, kills) are decided by one authority, the host. Send the shot (origin, direction, the time on link.now()) with link.sendEvent; the host checks it against where it had everyone and broadcasts the hit with HostSync.event. Show your own muzzle flash and tracer at once; show damage when the host confirms.
  • At 20 Hz a player moves ~0.3 units between updates: be generous with hit radii (or have the host test against where the target was ~100 ms ago), so hits that looked right count.

11. Title-card labels

The title card takes controls: [[keys, what]] rows. KEYS has the standard labels:

  • KEYS.move ('WASD / ARROWS'), KEYS.action ('SPACE'), KEYS.upDown, KEYS.leftRight;
  • KEYS.mouse ('MOUSE'), KEYS.click ('CLICK'), KEYS.rightClick, KEYS.wheel.

Anything else is a plain string: ['R', 'Reload'], ['SHIFT', 'Sprint'].

The title card speaks the player's device. On a pad, KEYS.move reads LEFT STICK, and ['R', 'Reload'] reads X when a button's key is R. On a phone they read STICK and RELOAD. The rows' words also label the touch buttons, so write them as short verbs ("Jump", "Dash").

SDK reference · vp docs fps · fps.md

First person: movement, the map, the camera, shooting and CPUs

The SDK's first-person kit is Frag Island's controller, made reusable: arena-shooter movement that feels right (air strafing, bunny hops, stairs, jump pads, rocket jumps), collision and line of sight against a voxel map, the camera and the gun in your hands, the mouse and keys read into one intent, the maths of shooting, and CPUs that find their way round any map. Start from the template:

bunx --package https://cdn.voxelparty.io/sdk/voxelparty-sdk-latest.tgz vp init my-shooter --fps

It's a small arena deathmatch with every piece below wired together: a map, CPUs, hitscan netcode, a HUD and sounds. Grow it into what you want: teams and rounds (vp docs menus for Setup), bomb sites, more guns, a bigger map.

Contents

  1. The pieces
  2. The map: VoxelGrid
  3. Moving: FpsBody, fpsStep, FpsTuning
  4. The view: FpsCamera and readIntent
  5. Shooting: rays, hit boxes, spread, splash
  6. Netcode
  7. CPUs: NavGrid and PathFollower
  8. Testing
  9. Pitfalls

1. The pieces

Piece Where What
VoxelGrid /core The solid grid: fill, addVolume, boxHits, ray, sees, groundBelow
FpsBody, newFpsBody, fpsStep, turn, stickFor /core A body and how it moves one frame
FpsTuning, FPS_ARENA /core How it moves (Frag Island's tuning, to copy and change)
launch, push, JumpPad, onPad /core Jump pads and launchers, knock-back and rocket jumps
rayBox, boxDist, spreadDir, HIT_BOX, forwardOf /core Shooting maths
NavGrid, PathFollower, NavLink /core CPUs' paths: A* over standing spots, walked like a player
FpsCamera @voxelparty/sdk The eyes and the gun: eased steps, landing dip, bob, recoil, shake, zoom
readIntent, FpsIntent @voxelparty/sdk WASD, action (Space), the mouse, 1–9 and the wheel, as one intent; a pad and a touch screen too
blockIsSolid @voxelparty/sdk For addVolume: blocks you bump into (not plants or water)

Everything in /core runs headless, so rules, bots and netcode test under bun test.

Conventions (the same everywhere in the kit; mixing others in is the classic bug):

  • A body's (x, y, z) is its feet. yaw 0 looks along +z, and turning left is +.
  • Forward is (sin yaw, cos yaw); right is (−cos yaw, sin yaw). forwardOf(yaw, pitch) is the 3D aim; pitch + looks up.
  • Moving the mouse right turns right: yaw goes down (readIntent does it).
  • The camera turns by (pitch, yaw + π, 0, 'YXZ') (FpsCamera does it).
  • A stick is { fwd, side } (forward +, right +); input.move() has forward as −z (readIntent converts). stickFor(yaw, x, z) turns a world direction into a stick (bots).

2. The map: VoxelGrid

Collision, shots and line of sight all ask one grid. Cells default to half a unit (stairs of 0.5, walls of 0.5), and hold a number: 0 is air, anything else solid, so you can keep your own materials in them and mesh the visuals from the same grid.

import { VoxelGrid } from '@voxelparty/sdk/core';

const grid = new VoxelGrid({ size: [120, 32, 120], origin: [-30, -2, -30] });  // 60 × 16 × 60 units
grid.fill(-30, -2, -30, 30, 0, 30, FLOOR);   // world units, low corner to high; top of the floor at y 0
grid.fill(-5, 0, -5, 5, 3, 5, WALL);          // a 10 × 3 × 10 block
grid.fill(-1.5, 0, -5, 1.5, 2.5, 5, 0);       // 0 carves a tunnel through it
for (let k = 1; k <= 6; k++) grid.fill(8 - k, 0, -8, 9 - k, k * 0.5, -5, STAIRS);   // stairs: 1 unit per 0.5 step
  • One source of truth. Build the grid, then draw from it (a Volume of the solid cells, meshVolume(vol, { voxel: 0.5 }) at the grid's origin; Frag Island's buildStructures), or stamp what you draw into it: grid.addVolume(island.top, [island.origin[0], island.y - 2, island.origin[1]], blockIsSolid) puts an Island's top slab in (floor top at island.y), leaving its plants out. Share the land shape function between the Island and your grid when you fill the floor yourself.
  • fill covers the cells from the low corner's up to (not including) the high corner's; keep boxes on half units and it's exactly the box.
  • grid.ray(ox, oy, oz, dx, dy, dz, max, normal?): distance to the first solid cell (max if none), and the face's normal. grid.sees(a…, b…): line of sight, stopped by grid.opaque cells (every value but 0 unless you clear one: grid.opaque[GLASS] = 0 sees through a window you still bump into; grid.solid[BUSH] = 0 walks through a bush that still hides you). ray's last argument picks what stops it (grid.opaque for how far you can see, or a 256-byte table of your own). grid.groundBelow(x, y, z): the floor under a point (-Infinity: nothing, you'd fall). Outside the grid is air.
  • Size: a 150 × 32 × 100 grid is 480 KB and meshes in a moment. Big open maps: raise cell to 1.
  • A map players build and break (walls to blow open, bridges, cover that gets shot away) is a World: a VoxelGrid of blocks with rules, synced edits and chunked drawing, and fpsStep and NavGrid run on it as they are (vp docs world).
  • grid.solid says which cell values you bump into (every one but 0 by default): clear a material there for something drawn that you walk through (a bush, a curtain of vines).

3. Moving: FpsBody, fpsStep, FpsTuning

import { FPS_ARENA, fpsStep, launch, newFpsBody, onPad, push, turn } from '@voxelparty/sdk/core';

const body = newFpsBody(spawn.x, spawn.y, spawn.z, spawn.yaw);
// every frame, for a body you own:
turn(body, it.dyaw, it.dpitch);                 // pitch kept within ±1.5
const r = fpsStep(grid, body, it, dt);          // it: { fwd, side, jump } (an FpsIntent is one)
if (r.jumped) play(SOUNDS.jump);
if (r.landed > 8) play(SOUNDS.land);            // landing speed, units/s
for (const p of pads) if (onPad(body, p)) launch(body, p.to, p.arc);
// a rocket went off near you (you own the body):
push(body, kx, ky, kz);                         // lifts you off the ground if it's upwards
  • fpsStep moves in slices so nothing tunnels through a wall, even at 40 units/s on a 0.1 s frame; it climbs step-high ledges by itself and glues you to stairs going down. The same inputs give the same result.
  • r.stepped (how far it climbed this frame) and r.landed feed the camera (section 4).
  • launch(body, to, arc) throws the body to to (its feet) along an arc arc above the higher end, and turns air control off for launchGrace so steering doesn't eat the arc: jump pads, launchers, a knock-up. onPad(body, pad): standing on a JumpPad ({ x, y, z, half, to, arc }).
  • body.ground, body.vx/vy/vz are yours to read (footsteps, a speed meter, fall damage).

FpsTuning is how a body moves; FPS_ARENA is Frag Island's (fast, floaty, strafe-jumping). Copy it and change what you need, and pass the same tuning to fpsStep, NavGrid and FpsCamera:

// A slower, grounded feel (untested here: tune it by playing). No bunny hops, little air control.
const TACTICAL: FpsTuning = { ...FPS_ARENA, run: 6, accel: 8, airCap: 0.3, airAccel: 4, jump: 7, autoHop: false };

Fields: radius, height, eye, step (the body); run, accel, friction, stopSpeed (ground); airAccel, airCap (air control; airCap caps what a strafe adds at once: small is what makes strafe-jumping work); jump, gravity (a jump peaks at jump² / 2·gravity: 1.42 for the arena tuning); maxSpeed; launchGrace; autoHop (holding jump hops again on landing, keeping your speed). fpsStep(grid, b, it, dt, tuning, gravity?) takes a gravity for a low-gravity mode.

Crouching is opt-in (SDK 3.10): give the tuning a crouch and declare a crouch button.

const MOVE: FpsTuning = { ...FPS_ARENA, crouch: FPS_CROUCH };   // or your own { height, eye, run }
// defineGame({ buttons: { crouch: { keys: ['KeyC', 'ControlLeft', 'ControlRight'], pad: 'r3', label: 'CROUCH' } } })

readIntent then fills it.crouch, fpsStep ducks the body while it's held (a lower box that fits under things, run × crouch.run) and stands it up once there's room over its head, and FpsCamera eases the eyes down and up. body.crouch says whether it's down: shoot from eyeHeight(body, tuning), stream it (a flag bit) so others draw it lower and trace their shots against a lower box (bodyHeight(body, tuning)), and set it on remote bodies from the stream. canStand(grid, body, tuning) says whether there's room to stand. Bind Ctrl as well as C: players reach for it, and a Ctrl the game ignores turns Ctrl+W into closing the tab.

4. The view: FpsCamera and readIntent

game.json: "input": ["mouse", "pointerLock", "keyboard"]; index.ts: pointerLock: true (the platform locks the pointer on "Click to play" and on clicks after Escape).

import { FpsCamera, arenaStage, readIntent } from '@voxelparty/sdk';

this.view = arenaStage(ctx.engine, { fov: 95, tilt: 0, near: 0.04, pollen: false });
this.view.scene.add(this.view.camera);          // the held gun is the camera's child
this.fp = new FpsCamera(this.view.camera, { fov: settings.fov });
this.fp.hold(gunGroup);                          // in front of the eyes; null holds nothing

// every frame, first person:
const it = readIntent(ctx.input, { sens: settings.sens, invert: settings.invert, scale: this.fp.lookScale });
turn(body, it.dyaw, it.dpitch);
const r = fpsStep(grid, body, it, dt);
this.fp.zoom = it.alt && scoped ? 30 : null;    // a zoomed field of view, eased
this.fp.update(dt, body, r, it);                 // after moving: eyes, bob, sway, kick, fov
// events:
this.fp.recoil(0.12, 0.16);                      // on your shot: push the gun back, tip the view up
this.fp.shake(0.5);                              // a blast nearby (0..1, fades)
this.fp.lower = switching ? 1 : 0;               // the gun dips (weapon switch)
this.fp.reset();                                 // on respawn: nothing left to ease
  • update's moved is any { stepped, landed }: fpsStep's result, or those two numbers passed on from a headless core that moves your body.
  • readIntent gives { fwd, side, jump, dyaw, dpitch, fire, alt, firePressed, altPressed, slot, wheel }: fire / alt held, firePressed / altPressed gone down this frame (semi-automatic guns, a scope toggle); fire only while input.aiming (the pointer locked, or a pad or a touch screen: so the click that locks doesn't shoot), jump is action (Space, a pad's A, the touch JUMP button), slot 0–8 for keys 1–9, wheel −1/0/+1. While a menu holds the input it's all idle. scale: fp.lookScale makes a zoomed view turn slower for the same mouse move.
  • The gun can't poke into walls. hold() puts it at [0.105, −0.1, −0.17] at scale 0.31 in camera space: inside the body's radius (0.35), so however close you stand to a wall, the gun is in front of it. Keep the near plane at about 0.04. Model the gun about 0.5 long along −z (the barrel pointing away), then hold(gun); hold(gun, { at, scale }) to place it yourself. Keep the whole gun within the radius from the eye.
  • The camera must be in the scene (scene.add(camera)) or the held gun isn't drawn. Put the gun on its own layer (mesh.layers.set(2), camera.layers.enable(2)) to keep it off minimaps.
  • Not in first person (dead, spectating, the title card): place the camera yourself and call fp.easeFov(dt) instead of update. Hide your own avatar in first person. vp check's screenshots come from these moments too: give the camera something to look at.
  • Settings: offer field of view and invert in a Menu and keep them with storage (Gun Game's O menu). Mouse sensitivity is the site's (the gear), for every game at once: look() is already scaled by it, so don't add a slider of your own.

5. Shooting: rays, hit boxes, spread, splash

import { HIT_BOX, forwardOf, rayBox, spreadDir } from '@voxelparty/sdk/core';

const [ox, oy, oz] = [body.x, body.y + FPS_ARENA.eye, body.z];
const d = spreadDir(body.yaw, body.pitch, 0.02, rand);        // a unit direction in a 0.02 rad cone
const wall = grid.ray(ox, oy, oz, d[0], d[1], d[2], RANGE);  // the map stops it here
let hit: string | null = null, best = wall;
for (const p of others) {                                     // as you draw them
  const t = rayBox(ox, oy, oz, d[0], d[1], d[2], p.x, p.y, p.z, best);
  if (t >= 0 && t < best) [hit, best] = [p.pid, t];
}
  • rayBox tests a standing player's box (HIT_BOX: 0.5 wide either side, 1.8 tall, a touch bigger than the body so shots that look right count). Pass a radius and height for crouching (bodyHeight) or a head box (y + 1.45, 0.25 tall) for headshots.
  • boxDist(x, y, z, p.x, p.y, p.z) is the distance from a point to a body's box (0 inside): splash damage and knock-back fall off with it.
  • spreadDir with a seeded rng is repeatable; shotguns call it once per pellet.
  • Show the shot at once (tracer, muzzle flash, fp.recoil), sounds from the camera (new Sfx({ you, ears: () => camera }), sfx.at3d(def, x, y, z)).

6. Netcode

Read vp docs netcode, section 9 (Shooters: the shooter decides what it hit): everyone moves their own body; the shooter traces its shots against what it sees and sends claims with PlayerSync.event; the host checks them, keeps HP and scores, and broadcasts with HostSync (with keep, so a host change or a reload carries the match on). The template is that recipe.

7. CPUs: NavGrid and PathFollower

Every seat must be playable by a CPU. The kit gives them the map; aiming and tactics are your game's (the template's bot.ts: a reaction time, an aim error that settles as it tracks, and leading shots for slow projectiles).

import { NavGrid, PathFollower, stickFor } from '@voxelparty/sdk/core';

const nav = new NavGrid(grid, { tuning: FPS_ARENA, links: pads.map((p) => ({ ...p, forced: true })) });
const follow = new PathFollower(nav);
// on a think (a few times a second), or when idle:
if (!follow.flying(body) && follow.idle) follow.goTo(body, goal.x, goal.y, goal.z);
// every frame:
const s = follow.steer(body);                        // a world direction, and whether to jump
const { fwd, side } = stickFor(body.yaw, s.x, s.z);
fpsStep(grid, body, { fwd, side, jump: s.jump }, dt);
  • NavGrid makes a standing spot on every 1-unit column with headroom, and joins neighbours you can walk to (within step), jump up to (0.9 × the tuning's jump peak, since a perfect jump is rare) or drop down (up to 8). Both, where a spot sits under an overhang whose top is within a jump (under a shelf, a bed, a bridge); a climb needs headroom over where it starts. Build it once per map (a 150 × 100 map: a few thousand spots, well under a second).
  • Walls and doorways that don't sit on whole units: spacing: 0.5 on a grid of half-unit cells. A half-unit column is narrower than a body, so it needs free cells beside it (the tuning's radius, or pass radius): a 1-unit doorway anywhere is a way through, a half-unit slit isn't. Four times the spots, so give the follower a smaller reach (0.35).
  • A big map (a forest, a city): this.nav = await NavGrid.build(grid, o) builds it a few ms at a time between frames, with no hitch; CPUs idle until it's there (nav.ready; before that nearest finds nothing). To drive it yourself, the same frames on every run (the host, tests): const nav = NavGrid.start(grid, o) and nav.work(3) in each update until it returns true.
  • links are extra ways across: jump pads (forced: standing there always throws you, so it's the only way out), teleporters, ladders ({ x, y, z, half, to, cost? }).
  • PathFollower walks a path like a player: it doesn't steer while launched (air control would brake the arc), drops its path in the air and plans again from where it lands, and jumps only at ledges higher than the graph's step (stairs it walks up). goTo only plans when the goal changes or the path ran out, so call it on every think; it returns false when there's no way there (asking again from the same spot is free: pick another goal). reset() when stuck (hop and plan again) or respawned.
  • Hide the held gun with fp.held.visible = false (dead, scoped in).
  • nav.nearest(x, y, z) / nav.path(a, b) / nav.nodes for your own choices (the nearest item, cover, a bomb site). nearest(x, y, z, { above, below, radius }) keeps to a height: the ground by a tree, not the top of it ({ above: 1.2, below: 1.2 }), or at or below you ({ above: 0 }).
  • Many CPUs on a big map: a search that finds no way looks at every spot it can reach (a whole forest, ~20 ms). path(a, b, { budget: 8000, partial: true }), or the same options on the follower (new PathFollower(nav, { budget, partial })), stops after that many spots and heads as close as it got (nav.partial says so); it plans again from there when the path runs out.
  • stickFor(yaw, x, z, { unit: true }) pushes the stick all the way whatever the direction's length (a short push under ground friction is a crawl).

8. Testing

  • Movement and maps are headless: build the grid in a test, step a body with fpsStep and check it gets where it should (up the stairs, onto the ledge, not through the wall). Check every spawn and item is reachable: nav.path(nav.nearest(spawn), nav.nearest(item)) !== null.
  • Matches: drive humans with your bot in a FakeRoom (the template's rules.test.ts), with people joining and leaving, the host leaving, and room.reload(pid) (a reloaded tab: same player, a fresh game). Check nobody is stuck dead or invisible and the scores agree.
  • See your first-person view headless: vp check's autopilot run and bunx vp shot put your own CPU in your seat (input.autopilot: intent() returns null, the core uses your bot), so the camera, gun and HUD are a player's. Scripts can also play by hand (t.hold('up', 800), t.look(300, 0), t.mouse(0, 400)): the pointer lock is granted as a browser would. t.strip('jump', 8, 700) shows a jump, recoil or a strafe in one picture (vp docs testing). Give the camera something to show when you're not in first person (spectators, bots-only rounds).

9. Pitfalls

  • Conventions. A yaw of 0 looking along −z, or right as (cos yaw, −sin yaw), gives mirrored strafing or bots that run backwards. Use forwardOf and stickFor, and the camera's yaw + π.
  • The gun clips into walls when it's bigger or further out than the body's radius. See section 4.
  • Tunnelling: don't move bodies yourself (body.x += vx * dt); fpsStep does it in slices. Rockets and pellets: trace them with grid.ray from the last position to the next.
  • Feet, not eyes. Rays start at y + eye; hit boxes stand on y.
  • Knock-back belongs to the body's owner: a host that pushes someone else's body gets overwritten by their next state. Send the push to them (section 6).
  • Falling off the map: check body.y against a floor (Frag Island: −14) and count it as a death; groundBelow = -Infinity means there's nothing under you.
  • Don't call view.rig.update in first person: the rig would move the camera.

SDK reference · vp docs world · world.md

Block worlds: a World players build and break

The SDK's editable voxel world: blocks placed and broken by everyone, synced, drawn in chunks that re-mesh only where something changed. Everything a block game needs underneath (team bed-defence on sky islands, last-one-standing island battles, build-to-a-theme contests, copy-the-model races, bridge duels, a drilling or mining game): block rules (hardness, drops, unbreakable, "only what players placed"), team colours, damage and cracks, aiming with bridging (placing out over an edge while backing along it), joiners and host changes, structures to stamp and compare. Start from the template:

bunx --package https://cdn.voxelparty.io/sdk/voxelparty-sdk-latest.tgz vp init my-blocks --blocks

It's a bridge duel: two islands over the void, a goal hole on each, build across and drop into theirs, with CPUs that bridge and break, and every piece below wired together. Grow it into what you want: combat (vp docs fps), a shop (vp docs menus), bigger maps, more teams.

Contents

  1. The pieces
  2. The world: World
  3. Blocks and their rules
  4. Edits: apply, allow, damage, onEdit
  5. Drawing: WorldView, BlockCursor, BlockFx
  6. Building: aimBlock, reach, bridging
  7. Netcode: WorldSync
  8. Inside Lockstep
  9. Structures, maps and saves
  10. Performance
  11. Testing
  12. Pitfalls

1. The pieces

Piece Where What
World /core The blocks: a VoxelGrid (collision, rays, sees, groundBelow, fpsStep, NavGrid all work on it) with rules, edits, damage, a hash, structures and compact forms
WorldSync /core Everyone's edits in the host's order: your own shown at once, joiners and host changes handled
aimBlock, cellReach, cellHitsBody, canFill /core What you point at, where a block would go, reach, bridging, "not inside a player"
blockIds, B, BlockRule /core Your blocks' ids headless (the same as useGameAssets gives), the built-in blocks, their rules
packStructure, unpackStructure, compareStructures, readVox /core Structures as strings, how alike two are, and MagicaVoxel models
WorldView @voxelparty/sdk The world on screen in three draw calls: chunks re-meshed within a few ms a frame, nearest first; block light
BlockCursor @voxelparty/sdk The outline on the block you aim at, a ghost where yours would go, cracks on blocks being broken
BlockFx @voxelparty/sdk Bits of the block when it's placed, hit or broken (in its own colour), and the sounds

Everything in /core runs headless: rules, bots and netcode test under bun test.

2. The world: World

import { B, World, blockIds } from '@voxelparty/sdk/core';
import { BLOCKS } from './textures';     // your GameBlockDefs (section 3)

const ids = blockIds(BLOCKS);            // { WOOL: 128, BED: 129, … }: what useGameAssets(TEXTURES, BLOCKS) returns
const world = new World({ size: [64, 32, 64], blocks: BLOCKS, rules: { [B.STONE]: { hp: 3 } }, palette: ['#e23b3b', '#2f6fed'] });
world.fill(0, 0, 0, 64, 4, 64, B.STONE);  // cells here (cell 1, origin 0): from the low corner up to, not including, the high one
world.set(10, 4, 10, ids.BED, 1);         // one cell: a block and its meta
world.get(10, 4, 10);                     // the block (0 is air, and outside the world)
world.metaAt(10, 4, 10);                  // its meta
world.markBase();                         // the map is done: what players change is measured from here
  • Cells. A cell holds a block id (0 air) and a meta byte (a tint block's colour, a turning top's direction). size is in cells; cell is a cell's size in world units (default 1: a block a unit, like an Island) and origin where cell (0, 0, 0)'s low corner sits (default the world's origin). With the defaults, cell (x, y, z) is the unit box from (x, y, z).
  • It's a VoxelGrid. fpsStep(world, body, …), new NavGrid(world), world.ray, world.sees, world.groundBelow, world.boxHits work as they do on a grid, and bump into solid blocks only: water and plants (and blocks with solid: false) aren't in the way. sees looks past blocks that aren't opaque (glass) and not past ones that are (a bush you walk through).
  • Build the same map on every client: from link.seed, or from a packed map you ship (section 9). That's the base: markBase() remembers it (WorldSync calls it when it starts), and everything after is what players changed: placed(x, y, z), reset() (back to the map, for a new round) and saveDiff() (what a joiner is sent) all measure from it.
  • world.hash() fingerprints every block and meta, kept up to date as cells change, so it costs nothing: compare clients in tests, and it's a Lockstep world's hash.
  • Storage is one array in VoxelGrid order (x fastest, then z, then y): any cell is one index away (world.cells[world.idx(x, y, z)]). Chunks of 16³ are the unit of meshing and packing; world.version[chunk] goes up whenever a chunk (or a cell next to it) changes.
  • Memory: 2 bytes a cell (4 with a base): a 256 × 64 × 256 world is 16 MB.

3. Blocks and their rules

A block's look is its GameBlockDef (vp docs art §7); its behaviour in a world is its rule, in the same long form:

export const BLOCKS = {
  WOOL: { top: 'bb_wool', tint: true, hp: 2 },                    // a team's colour from the meta
  BED: { top: 'bb_bed_top', side: 'bb_bed_side', hp: 4, placedOnly: true, drop: 0 },
  END: { top: 'bb_end', hp: 6 },
  BEDROCK: { top: 'bb_bedrock', unbreakable: true },
  GLASS: { top: 'bb_glass', opaque: false },                      // you bump into it, and see through it
  BUSH: { top: 'bb_bush', solid: false, opaque: true },          // you walk through it, and hide in it
} satisfies Record<string, GameBlockDef>;
// game.ts: const ids = useGameAssets(TEXTURES, BLOCKS);   rules.ts: const ids = blockIds(BLOCKS);
// both: new World({ size, blocks: BLOCKS, rules: { [B.STONE]: { hp: 3 }, [B.GRASS]: { hp: 1 } }, palette })
Rule Default Meaning
hp 1 Damage it takes to break: a hit does 1 (or its dmg). 1 breaks in one hit.
drop itself The block it drops (WorldEdit.drop on a break): give it to the breaker's inventory. 0 for nothing.
unbreakable false Nothing breaks it: bedrock, the map's frame.
placedOnly false Only a block a player placed breaks: the map's own stay (the teams' home islands), what players build doesn't.
tint false Its colour comes from the cell's meta: meta 1 is palette[0], 2 palette[1]… (0: untinted). Paint the texture light (near white): the colour multiplies it.
solid true (water, plants: false) You bump into it and rays stop at it.
opaque the same as solid It blocks sight (sees): glass is opaque: false, a bush you hide in solid: false, opaque: true.
  • rules in WorldOptions sets any block's rule by id, the built-in ones too; it wins over blocks.
  • world.rule(id) is the rule as the world has it.
  • Team colours: one WOOL block with tint: true and palette: teams.map((t) => t.color) beats a block per team (blocks are limited to 128 a game).
  • Glowing blocks (light in the GameBlockDef) light the world when WorldView has light (section 5).
  • Your own plants: WEED: { top: 'mg_weed', plant: true, tint: true } (a cutout texture) is drawn as two crossed quads that sway, like the built-in tall grass, and you walk and see through it; plant: 'hanging' hangs from the block above (vines, roots). Paint the picture growing from the bottom edge of the tile.

4. Edits: apply, allow, damage, onEdit

A player's change is a BlockEdit:

{ k: 'place', x, y, z, id, meta? }   // into an empty cell (air, water or a plant)
{ k: 'hit', x, y, z, dmg? }          // damage; it breaks when the damage reaches its hp
{ k: 'break', x, y, z }              // at once, whatever its hp (still by the rules)

Online, players edit through WorldSync (section 7). Underneath, and in a Lockstep world:

world.check(e, by);            // why not: '' if it can happen, or 'outside' 'taken' 'empty' 'unbreakable' 'placedOnly' 'tint' 'bad' 'denied'
world.apply(e, by, now);       // do it by the rules: the WorldEdit, or null. `now`: the clock damage heals by
world.allow = (e, by, w) => inReach(by, e) && !blocksAPlayer(e) && myTurn(by);   // your rules on top of the blocks'
  • allow is where the game's rules go: reach, teams, a build phase, cooldowns, an inventory, "not inside a player". WorldSync asks it on the host (the host's answer counts) and on your own client before it shows your edit, so write it from what every client knows.
  • Damage adds up across hits and players: world.damage(x, y, z) is 0..1 (for cracks), and it heals when nobody hits the block for a while (WorldSync's healMs, default 5 s; world.heal(now, ms) yourself otherwise). Replacing or breaking a block clears it.
  • world.onEdit((e) => …) hears every edit: yours as you make it, everyone else's as the host sends it, and undo when the host turned yours down. e.kind is place, break, hit (damaged, not broken), blast or undo; e.id / e.was the block now and before, e.meta (a tint block's team), e.by (a player id or null), e.drop, e.damage. Effects, sounds and game logic ("red's bed is gone") go here, so they happen exactly once on every client.
  • world.onChange((x, y, z, id, was) => …) hears every cell that changes, however: edits, and cells set directly (set, fill, stamp, reset, loads), which aren't edits and are quiet in onEdit on every client. For your own caches.
  • world.blast(x, y, z, r, by) breaks everything breakable within r cells (by the rules: unbreakable and placedOnly hold): TNT, a meteor, a sinkhole. One change, every cell in it an onEdit with kind: 'blast'.

5. Drawing: WorldView, BlockCursor, BlockFx

import { BlockCursor, BlockFx, WorldView, useGameAssets } from '@voxelparty/sdk';

const ids = useGameAssets(TEXTURES, BLOCKS);              // first: the blocks' textures
this.worldView = new WorldView(world, engine.mats, { light: true });   // options below
this.view.scene.add(this.worldView.group);
this.cursor = new BlockCursor(world);
this.view.scene.add(this.cursor.group);
this.bits = new BlockFx(world, engine.mats.actor, { sfx: this.sfx, sounds: { place: SOUNDS.place, break: SOUNDS.crunch, hit: SOUNDS.tap } });
this.view.scene.add(this.bits.mesh);
world.onEdit((e) => this.bits.burst(e));

// every frame, after the camera moved:
this.worldView.update(this.view.camera);   // re-mesh what changed, nearest first, within the budget
this.cursor.show(aim, holdingABlock);      // section 6
this.cursor.update();                      // cracks follow the damage (everyone's)
this.bits.update(dt);
  • WorldView meshes the whole world on its first update (or build()), then only the chunks that changed: budgetMs a frame (default 2; at least one chunk; real milliseconds, so vp check --long sees the frames players do), nearest the camera first. It looks exactly like meshVolume (ambient occlusion, textures tiling across blocks, swaying plants, animated water; seams match), on the shared materials, so x-ray, moods and shadows work as on an Island. However big the world, it's three draw calls: the chunks are batched (a BatchedMesh each for blocks, water and plants), each chunk culled by the camera on its own, and a re-meshed chunk rewrites and re-sends only its own part. far hides chunks further away. shadows: false for a world that doesn't need them. stats has the counts and timings. dispose() when it's done: the batches and the world's water field go with it, and nothing of the SDK keeps the World's blocks alive after, so a game that builds a new World every run (a dungeon, an expedition) frees the last one's by disposing its view.
  • Big worlds: stream. A world too big to mesh whole (a 256 × 448-unit moor of half-unit cells would be 200 MB of vertices) streams round the player: new WorldView(world, mats, { stream: 44 }) meshes only the chunks whose centre is within 44 units (across, not up) of the focus you pass each frame, view.update(camera, player.position) (default: the camera), nearest first within the budget, and frees chunks that fall a quarter further behind; they're meshed again, the same, when you come back. Size it to what the camera sees plus a margin. Its water's field (depths, edges, flow) is baked the same way, a column of chunks at a time as they come in, never the whole world's at once (that alone is a third of a second for the moor).
  • Block light (light: true): glowing blocks and lights you worldView.light.add({ x, y, z, color, reach }) light the world (players walking past too), and when a block is placed or broken only the lights that reach it are lit again, only over their reach, and only that box goes to the GPU. 20 bytes a cell: keep lit worlds under ~4 million cells. It replaces the stage's blockLight (one at a time).
  • Water: water blocks are drawn as the SDK's water (vp docs water): depth, foam where they meet blocks, glints, caustics on the bed. The view bakes the world's water field on its first build and, when blocks change, again only under the chunks that changed, in the same budget, so a lake players dig or fill keeps its banks' foam. A water cell's meta is its flow: bits 0–2 the direction (0 N = −z, clockwise), 3–5 the speed (0.3 units a second a level), bit 6 "falling water lands here": world.set(x, y, z, B.WATER, flowMeta(2, 4)) flows east (flowMeta, flowOf in /core). Colours and the rest: ctx.engine.water.set({ depthTint: 'tropical' }).
  • BlockCursor: an outline on the block you aim at, a translucent ghost where yours would go (show(aim, placing); ghost: false for none), and five stages of cracks on every damaged block, drawn as 16×16 pixel art in five draw calls.
  • BlockFx: bits of the block flying (its texture's colour, a tint block's team colour): a puff when placed, chips when hit, chunks when broken, two bits a cell for a blast. One draw call. With sfx and sounds it plays them at the block (at3d); a blast plays one sound, not a hundred.

6. Building: aimBlock, reach, bridging

import { aimBlock, cellHitsBody } from '@voxelparty/sdk/core';

const aim = aimBlock(world, eye, forward, feet, { reach: 5 }, this.aim);   // reuse one object a frame
if (aim.hit && breaking) ws.hit(aim.hit.x, aim.hit.y, aim.hit.z);        // the block you look at
if (aim.place && placing) ws.place(aim.place.x, aim.place.y, aim.place.z, ids.WOOL, team);
  • eye and forward are { x, y, z }s: the camera's (for a first-person body, (x, y + eye, z) and forwardOf(yaw, pitch)'s three numbers); feet is the body (for reach, bridging and not placing inside yourself), or null. A mouse game casts from the pointer: input.ray(camera) gives the origin and direction.
  • aim.hit is the block (plants too, never water) with the face you look at (nx, ny, nz) and how far; aim.place is the empty cell in front of that face, within reach (eye to the block's nearest point, default 5), and never inside your body (radius, height: the FPS kit's by default). Looking at a plant, you'd replace it.
  • Bridging (bridge, default on): look out past the edge you stand on into the air, or straight down at the block under you, and place is the cell beside the block you stand on, the way you face (aim.bridged is true). Walk backwards clicking and you've built a bridge, the way players of every block game do.
  • Not inside a player: aimBlock keeps you out of your own way; for everyone else, check in world.allow with cellHitsBody(world, e.x, e.y, e.z, body) for each body (as the host sees them). cellReach(world, eyeX, eyeY, eyeZ, x, y, z) for a reach check there too.
  • CPUs aim with the same function: from their eye along their facing.

7. Netcode: WorldSync

import { WorldSync } from '@voxelparty/sdk/core';

const world = buildMap(link.seed);            // the same base on every client
world.allow = (e, by) => …;                    // section 4
const ws = new WorldSync(link, world);         // options: hz, healMs, keep, mayResume, keepWait, maxRate, channel
// your edits (false: not allowed, or not ready yet):
ws.place(x, y, z, ids.WOOL, team);   ws.hit(x, y, z);   ws.break(x, y, z);   ws.edit(e);
// the host, for a CPU:
ws.place(x, y, z, ids.WOOL, team, cpuPid);
// every frame:
ws.update();
// host: back to the map for everyone (a new round)
ws.reset();

How it plays out:

  • Your edits show at once (and onEdit fires, so the effects are instant), and go to the host in one message every 50 ms. The host checks each against the rules and allow with its own view, applies what passes and answers. One it turns down is put back on your screen (onEdit with kind: 'undo'), the same moment you'd have seen it from anyone else.
  • The host sends results, not requests: the new block in each changed cell, who did it, damage, and which of each player's edits it has answered, in numbered batches (at most hz, default 15, a second, and only while something changes), with its world's hash. Every client applies the same batches in the same order, so every world is the same. A client whose hash differs (it can't happen, but) asks for the whole world and carries on.
  • On the host, anything that changes the world goes out: players' edits, the CPUs', and your own world.set, fill, stamp and blast (a bed coming back, a disaster). Clients never change the world themselves: only through WorldSync.
  • Joiners and reloads build the map, then get what changed since (saveDiff: only the cells that differ, packed) sent to them alone in pieces of 12 KB, paced under the rate limit, while the host carries on; batches that come meanwhile wait and then apply. ws.ready is true once they have it (and on the host once it has started): don't let a player edit before, edit returns false anyway. A heavily edited 256 × 64 × 256 world (46 000 changed cells) is 117 KB: 11 pieces, about a second.
  • Host changes. Everyone has the world, so the new host carries on from its own, in a new epoch that names the batch it follows on from. Edits the old host never answered are sent to it again; a batch the old host sent just before it went is taken in, not lost. A host that reloads asks the others (and the room's kept copy) for the world before it starts: that's mayResume and keepWait, as in Lockstep.
  • Kept with the room. The host keeps the world with the room (link.keep) about once a second while it fits in 64 KB, for a host that reloads into an empty room. There is one kept world per room: if you keep your own with HostSync's keep, pass keep: false here and put the world in yours:
    const ws = new WorldSync(link, world, { keep: false });
    const sync = new HostSync<Snap, Ev>(link, {
      valid: isSnap,
      keep: { save: () => ({ m: match.save(), w: ws.save() }), load: (d) => { match.load(d.m); ws.load(d.w); } },
    });
    
  • Budget. A client sends at most 20 edit messages a second (while it edits), the host at most hz batches plus whole-world pieces at 20 a second while someone joins. Next to a PlayerSync (20) and a HostSync (15) that stays under the 60 a second a client may send. With 16 players placing 10 blocks a second each, a batch is under 300 bytes: about 14 bytes a change, and 30 an edit to send yours.
  • ws.pending counts your edits the host hasn't answered; ws.stats has batches, changes, undone, rejected, whole worlds, hash checks and desyncs for your tests.
  • A player can't flood the world: the host takes at most maxRate edits a second from each (default 40) and turns down the rest.

8. Inside Lockstep

In a Lockstep game (vp docs netcode §10) the world is part of the lockstep world and edits are orders; don't use WorldSync. World is deterministic for it:

type Order = BlockEdit | { k: 'start'; round: number };
const ls = new Lockstep<Game, Order>(link, {
  create: () => new Game(buildMap(link.seed)),
  step: (g, inputs, tick) => {
    for (const i of inputs) if (i.k === 'order' && 'x' in i.o) g.world.apply(i.o, i.pid, tick * 50);
    g.world.heal(tick * 50, 5000);
  },
  hash: (g) => g.world.hash() ^ g.hashRest(),
  save: (g) => ({ w: g.world.saveDiff(), rest: g.saveRest() }),
  load: (d) => Game.load(d, buildMap(link.seed)),   // new world from the map, then world.loadDiff(d.w) (it throws on junk)
});
ls.order({ k: 'place', x, y, z, id: ids.WOOL, meta: 1 });

Draw ls.pending edits as ghosts with BlockCursor if you want them to feel instant. Whole worlds already travel in pieces, so a big world is fine.

9. Structures, maps and saves

const house = world.capture(x, y, z, 7, 6, 7);          // a box of blocks (low corner, size)
world.stamp(house, x, y, z, turns);                      // back in, turned quarter turns (1: +x faces +z); { air: false } keeps what's there
world.match(house, x, y, z, turns);                      // { match, total, score }: how good a copy is (a copy-the-model race)
compareStructures(a, b);                                 // the same for two structures
const s = packStructure(house); unpackStructure(s);      // a string: ship targets and prefabs with the game
const map = world.pack(); other.unpack(map);             // a whole world as a string (runs of blocks): a map, a save
const d = world.saveDiff(); other.loadDiff(d);           // what changed since the base (same size and base)
  • match counts cells that have a block in either (the build or the target): score is the share where they're the same block and meta. Air in both doesn't count, so a small target in a big plot scores fairly.
  • Turning blocks (turns: true in the GameBlockDef) turn with the structure.
  • MagicaVoxel: readVox(bytes, (i, r, g, b, a) => blockId) reads a .vox model into a structure (its z is up; each palette colour becomes the block you pick, 0 leaves it out). Build a map or a prefab there, then stamp it, or packStructure it once and ship the string.
  • pack suits maps: runs of blocks, so a 256 × 64 × 256 terrain is ~330 KB and a small arena a few KB. Build maps in code from the seed where you can; ship a packed string when they're hand-made.
  • stamp and set don't go through the rules or onEdit: they're the map's, or the host's.

10. Performance

Measured (under bun) on a 256 × 64 × 256 world of hills, water, trees and grass (1024 chunks, 600 000 triangles):

  • The first full mesh: ~150 ms. A busy surface chunk re-meshes in 0.08 ms; the worst possible chunk (a 3D checkerboard, 12 288 faces) in ~0.55 ms.
  • 16 players placing and breaking 10 blocks a second each: WorldView.update p99 ~1.6 ms a frame, hardly a chunk ever waiting for the next frame.
  • Three draw calls for the whole world: ~520 chunk parts in a first-person view, all 1 330 with the whole map in view, culled chunk by chunk.
  • A ray (target) ~0.3 µs; an fpsStep ~0.6 µs.
  • A late joiner to the same world with 46 000 cells changed: 117 KB, ready ~270 ms after joining at 80–120 ms latency.
  • Block light: an edit next to a lantern ~1 ms; the first bake of 300 lanterns ~50 ms.

So: re-mesh as much as you like, but keep the world's size sensible (a few million cells), and use far for big open worlds (a far chunk is still triangles to draw).

11. Testing

  • Rules, bots and netcode are headless: build the world in a test, apply edits, check the rules (check says why not). world.hash() must equal world.rehash() (worked out from scratch).
  • Netcode: a FakeRoom with one WorldSync per link, every client editing, then assert every live client's world.hash() is the same and ws.pending is 0 (every edit answered). Put it through joins, leave of the host, reload, drop/rejoin and latency up to 250 ms; count onEdit kinds, ws.stats.undone for turned-down guesses. The template's rules.test.ts does.
  • vp check plays with CPUs; give them a way to build (the template's bot bridges with aimBlock).

12. Pitfalls

  • The same base everywhere. Build the map from the seed (never Math.random), and don't change it after WorldSync starts except on the host. A client that changes its own world desyncs (it heals, at the cost of a whole world).
  • Clients edit through WorldSync. world.set on a client isn't sent anywhere; on the host it is (everything the host changes goes out).
  • allow sees the world as this client has it. On the host that's the truth; on a client it decides whether to show the edit at once. Keep it to what every client knows (positions as drawn, teams, the phase), and let the host's answer win.
  • Effects in onEdit, never where you called place: then they happen once on every screen, undos included.
  • useGameAssets before WorldView, so the view reads your blocks' textures.
  • One kept world a room: WorldSync's keep and HostSync's keep overwrite each other. Use one (section 7).
  • Big blasts are big batches: a radius-12 crater is ~6 000 cells, split into messages under 16 KB, but every client re-meshes the chunks around it. Fine now and then; not every frame.

SDK reference · vp docs levels · levels.md

Levels as text: textGrid and gridText

Draw a map as text, one character per cell, and say what each character is in a legend. The text is the map, its collision, its spawns and goals, all at once; gridText prints any grid back as text, so you (or an agent) can see a level in a log or a failing test. Both are in /core (three-free: rules, bots and tests use them headless).

Contents

  1. The format
  2. The legend
  3. Using the level
  4. Recipe: an arena
  5. Recipe: a heightmap island with scattered trees
  6. Recipe: a multi-floor first-person map
  7. Recipe: a symmetric team map
  8. Recipe: your own meanings (grid games, zones)
  9. Windows and bushes: sight
  10. Seeing it: gridText
  11. Pitfalls

1. The format

import { B, textGrid } from '@voxelparty/sdk/core';

const level = textGrid(`
  ##########
  #S......G#
  #..##....#
  ##########
`, { '#': B.STONE, S: 'spawn', G: { block: B.GOLD, mark: 'goal' } });
  • Directions: a line's first character is x = 0, x grows to the right; the first line is z = 0 (the far side, −z) and z grows down the page, towards the camera. As you'd draw a map.
  • Layers are y, from the ground up: textGrid([ground, firstFloor, roof], legend). One string is one layer.
  • Indentation common to every line is dropped, and blank lines before and after a layer. Short lines are padded with air. Blank lines inside a layer are rows of air.
  • ' ' and '.' are air unless the legend says otherwise ('.': B.GRASS is allowed).
  • A character the legend doesn't have is an error naming it, its line and column and layer.
  • Options: { seed, mirror: 'x' | 'z' | 'xz', swap: { r: 'b' }, shareSeam } (sections 5 and 7).

2. The legend

Each character is one of:

Entry Means
B.STONE, ids.WALL That block. 0 is air.
'spawn' A mark: air here, and the cell is in level.marks.spawn.
[B.STONE, B.DIRT, B.GRASS] A column, bottom up, starting at this layer.
TREE (any Structure) A prefab (a readVox model, a world.capture, another textGrid), stamped centred on the cell, its bottom on this layer. Its air doesn't carve.
{ block, height?, top?, meta?, mark?, prefab?, turns? } Any mix: { block: B.STONE, height: 3 } a wall 3 tall; { block: B.DIRT, top: B.GRASS, height: 4 } a hill; { block: B.GRASS, mark: 'spawn' } grass with a spawn standing on it (the mark is the cell above the column); { block: B.GRASS, prefab: TREE, turns: 1 } a tree on grass; meta is the top block's (a tint block's team, a turning top's direction).
(at) => entry A function of the cell: at.x, at.y, at.z, at.char, and at.rand() (seeded by the cell and seed: the same map on every client). Return any entry above, or null for air.

Marks are typed from the legend: with S: 'spawn', level.marks.spawn is a GridPos[] (never undefined, empty if the text has none). level.mark('spawn') is the first one, and throws naming the marks there are if there's none.

3. Using the level

A TextGrid is a Volume (ids and metas, get, set) and a Structure, so it goes wherever those do. Its cells are 1 unit, so place it with a cell offset:

world.stamp(level, 0, 4, 0);                                 // into a World (vp docs world): cell x, y, z
grid.addVolume(level, [-5, 0, -2], blockIsSolid);           // into a VoxelGrid (vp docs fps): world x, y, z
addVoxelMeshes(scene, level, engine.mats, [-5, 0, -2]);      // drawn: terrain meshes on mats.solid/water/cross
const s = level.mark('spawn');                               // { x, y, z } cells
body.x = -5 + s.x + 0.5; body.y = s.y; body.z = -2 + s.z + 0.5;   // the middle of that cell, feet on its floor
level.char(x, y, z);                                          // the text's character at a cell (' ' outside)

Build it at module level (or from link.seed) in rules.ts: every client gets the same level, and tests can use it.

4. Recipe: an arena

export const ARENA = textGrid(`
  ~~~~~~~~~~~~~~
  ~############~
  ~#1........2#~
  ~#..##..##..#~
  ~#..#....#..#~
  ~#....**....#~
  ~#..#....#..#~
  ~#..##..##..#~
  ~#3........4#~
  ~############~
  ~~~~~~~~~~~~~~
`, {
  '~': B.WATER,
  '#': { block: B.COBBLE, height: 2 },         // walls two blocks tall
  '*': { block: B.GOLD, mark: 'prize' },       // the prize sits on gold
  1: 'spawn', 2: 'spawn', 3: 'spawn', 4: 'spawn',
});
ARENA.marks.spawn;   // four cells, in text order: 1, 2, 3, 4

The floor is somewhere else (an Island, or a floor layer below: textGrid([FLOOR, ARENA_TEXT], …)).

5. Recipe: a heightmap island with scattered trees

Digits as heights read like a contour map:

const H = (n: number) => ({ block: B.DIRT, top: B.GRASS, height: n });
const TREE = textGrid([`...\n.L.\n...`, `...\n.L.\n...`, `LLL\nLLL\nLLL`, `.L.\nLLL\n.L.`], { L: B.LEAVES_OAK });
TREE.set(1, 0, 1, B.LOG); TREE.set(1, 1, 1, B.LOG);   // a trunk: prefabs are just Volumes

export const ISLAND = textGrid(`
  ~~~~~~~~~~~~~~~~
  ~~~111122111~~~~
  ~~11222233211~~~
  ~11223344332t1~~
  ~1t2334554321~~~
  ~~1122333321t1~~
  ~~~~11221111~~~~
  ~~~~~~~~~~~~~~~~
`, {
  '~': B.WATER,
  1: H(1), 2: H(2), 3: H(3), 4: H(4), 5: H(5),
  t: ({ rand }) => ({ ...H(1), prefab: TREE, turns: Math.floor(rand() * 4) }),   // a tree, turned at random
}, { seed: link.seed });
  • The function sees rand(), seeded by the cell and seed: the same island on every client, a different one per seed. Return null for air.
  • Scatter without marking every spot: '.': ({ rand }) => (rand() < 0.08 ? { ...H(1), prefab: TREE } : H(1)).
  • gridText(ISLAND, { view: 'heights' }) shows the terrain back as heights.

6. Recipe: a multi-floor first-person map

Walls are columns, so the ground floor is one layer; a storey above is its own grid (a floor slab layer, then its walls), placed one storey up:

const WALL = { block: ids.WALL, height: 3 };
export const GROUND = textGrid(`
  ############
  #1...#.....#
  #....D..^..#
  #....#.....#
  ############
`, { '#': WALL, D: 0, '^': ids.STAIRS, 1: 'spawn' });   // D: a doorway (air)
export const UPPER = textGrid([`
  ffffff
  ffffff
  ffffff
`, `
  ==GG==
  =2...=
  ======
`], { f: ids.FLOOR, '=': WALL, G: { block: ids.GLASS, height: 2 }, 2: 'spawn' });

const at: [number, number, number] = [-6, 0, -3];
grid.addVolume(GROUND, at);                            // half-unit collision cells: a voxel fills 2 × 2 × 2
grid.addVolume(UPPER, [at[0], at[1] + 3, at[2]]);      // one storey (3) up
addVoxelMeshes(scene, GROUND, engine.mats, at);
addVoxelMeshes(scene, UPPER, engine.mats, [at[0], at[1] + 3, at[2]]);

Marks are in each grid's own cells: add the same offset to get world positions.

7. Recipe: a symmetric team map

Draw one half; mirror adds the other, flipped, and swap trades the teams' characters in the copy:

export const DUEL = textGrid(`
  ..........
  .c####....
  .crr#>#...
  .c####....
  ..........
`, {
  '#': ISLAND, c: [...ISLAND, B.COBBLE],
  r: { block: [B.STONE, ids.GOAL], meta: 1, mark: 'redGoal' },
  b: { block: [B.STONE, ids.GOAL], meta: 2, mark: 'blueGoal' },
  '>': { block: ISLAND, mark: 'redSpawn' }, '<': { block: ISLAND, mark: 'blueSpawn' },
}, { mirror: 'x', swap: { r: 'b', '>': '<' } });
  • mirror: 'x' puts the flipped copy to the right (width doubled), 'z' below, 'xz' four ways.
  • shareSeam: true makes the half's last column the middle line (an odd width with a centre lane).
  • A function entry in the copy gets its twin's rand(): a random map stays fair.
  • vp init --blocks draws its whole map this way (map.ts): the goals and spawns are marks.

8. Recipe: your own meanings (grid games, zones)

A level doesn't have to be blocks. level.char(x, y, z) is the text's character at any cell, and marks collect cells, so a 2D grid game can use the text as its board:

const BOARD = textGrid(`
  ###########
  #S..i..i..#
  #.##i##i#.#
  #..iiiii..#
  ###########
`, { '#': ids.WALL, i: { block: ids.ICE, mark: 'ice' }, S: 'start' });
const slippery = (x: number, z: number) => BOARD.char(x, 0, z) === 'i';

Your rules read char (or get for the block); the view meshes the same level.

9. Windows and bushes: sight

VoxelGrid.sees stops at opaque cells, which aren't always the solid ones:

// A World: say it in the block's rules.
GLASS: { top: 'glass', opaque: false },               // you bump into it, you see through it
BUSH: { top: 'bush', solid: false, opaque: true },    // you walk through it, it hides you
// A plain VoxelGrid: its tables, by cell value.
grid.opaque[M.GLASS] = 0;
grid.solid[M.BUSH] = 0;
grid.ray(ox, oy, oz, dx, dy, dz, 30, undefined, grid.opaque);   // how far you can see

In a text map it's just a character: '=': ids.GLASS.

10. Seeing it: gridText

console.log(gridText(level));                          // from above: each column's top
console.log(gridText(world, { view: 'heights' }));     // . none, 1–9, then a–z
console.log(gridText(level, { view: 2 }));              // one layer, as textGrid reads it
console.log(gridText(level, { view: 'all' }));          // every layer, each under "y N"
console.log(gridText(world, { legend: LEVEL.legend, area: [0, 0, 20, 12] }));   // a World in your characters, a corner of it
  • A TextGrid prints in its own characters: gridText(level, { view: 0 }) is the text you wrote (with . for air). A cell changed since (a broken block) prints by its block.
  • Anything else (a World, a Volume, a Structure, a VoxelGrid) prints through legend (single blocks, and columns by their top block and meta). Blocks it doesn't name get a character each, listed in a key underneath: (# stone (3), = 200).
  • In tests: expect(gridText(w, { view: 'heights' })).toBe(EXPECTED) makes a failure show the map. After an explosion, print the world and look.

11. Pitfalls

  • Layers are one cell tall. Layer 1 is the cells just above layer 0, not the next storey: tall walls are columns (height: 3), and a storey above is its own grid placed higher (recipe 6).
  • Marks on blocks stand on top. { block: B.GRASS, mark: 'spawn' } marks the cell above the grass (where a body stands); a bare 'spawn' marks the cell itself.
  • Air never carves. A . over a column from a lower layer leaves the column. To cut something out, level.set(x, y, z, 0) after building.
  • Prefabs are clipped at the level's sides (the top grows to fit them). Leave a margin.
  • Prefab turns turn positions, not metas: a turning top's direction inside a prefab stays as it was. world.stamp(structure, x, y, z, turns) turns those too (the World knows which blocks turn).
  • Cells are ids 0–255, as everywhere: a game's own blocks from blockIds(BLOCKS) / useGameAssets.

SDK reference · vp docs design · design.md

Designing a great game

Voxel Party is a playground of voxel games, every one played in sessions (vp docs sessions): a link, and a friend is playing within a minute. Anything from a 2-player duel to a 16-player shooter, an obby, an RPG or a tower defense, untimed, people dropping in and out.

The best ones are understood instantly, grab you in the first minute whatever their mood, and give you a reason to come back: a world worth exploring, something to work towards, a look that feels finished. Section 0 is the bar for every game; sections 1–7 are the craft behind it.

Contents

  1. The bar
  2. The design paragraph
  3. Principles
  4. Shapes that work
  5. Scoring
  6. Drama and comebacks
  7. Bots that feel human
  8. Anti-patterns
  9. Idea starters

0. The bar

The bar: someone sends a link, and their friend is having fun within 30 seconds.

  • Drop-in. Opening the link puts you in the game. No waiting room inside the game, no "wait for the next round" if you can avoid it: spawn joiners into the action (safely), and make the goal obvious from the first frame. A match-based game may open with a setup menu (options, picks, teams: vp docs menus), as long as joiners drop into a running match with the defaults and a solo player isn't kept waiting.
  • Fun alone, better together. 1 player should have something to do (targets, waves, a CPU rival, a personal best, kept in storage: vp docs api §16); 2 should be a real match; the max should still be readable.
  • Short loops inside a long session. A round, a life, a wave: something ends and restarts every 1–3 minutes, with a scoreboard that shows who's ahead. Either rounds inside one endless run, or flow.end per match (vp docs sessions §5).
  • Controls people already know. WASD + mouse look + click for a shooter, WASD + pointer for a top-down game, Space to jump, Shift to sprint, R to reload. List them on the title card. More controls are fine, but each one must earn its place.
  • A clear goal and a clear winner. "First to 10 kills", "hold the flag for 60 s", "survive the wave": on screen, in the blurb, and in the HUD.
  • Juice and feedback on every hit. Hit markers, a sound for your hits that differs from being hit, screen shake, knockback, a kill feed (flow.hud.banner for big moments).
  • Respawns, not eliminations, in drop-in games: nobody should wait more than ~3 s to play.
  • Fair to latecomers: rounds short enough that joining mid-round doesn't matter, or a catch-up (the leader is a target, pickups favour whoever's behind).

The design paragraph (section 1) still comes first. It also names the player range, the controls (mouse or not), the world and its look, and how a round or match ends.

1. The design paragraph

Write this before any code and show it to the user. Example:

Counting Sheep (per-player, 45 s). Sheep leap a fence one after another, some in quick bunches and some doubling back. Press Space each time a sheep clears the fence; your count shows only at the end. After 3 waves, the closest count to the real number wins (score = -|error|, ties share). Bots count with a reaction delay and a 10–25% chance of missing a sheep in a bunch. 3-second test: a fence, a sheep in the air, a big "COUNT!" sign and a clicker in your hand.

It names the goal, the controls, the length and ending, the scoring, the bots and the first impression. If you can't write it in one paragraph, the game is too complicated.

Your version of a game you love ("my take on that horde-survival hit", "a level in the style of a classic platformer") is always fair: genres, rules, mechanics, feel and structure are free for anyone, and remixing them is how games are made. Build it. Make the name and the creative work your own: an original title, your own characters, art, sounds and level layouts, and genre words (never the other game's name) in the paragraph, the title card and the listing (vp docs sharing §4).

Another example:

Snowball Siege (1–8 players, mouse + keyboard, host-authoritative). A snowy fort on a floating island. WASD to run, the mouse to aim, click to throw, Space to jump. A hit knocks you back and scores a point; three hits and you pop back at your fort. First to 15 wins the round (a banner, confetti, scores reset), and a new round starts at once. Joiners spawn at the emptiest fort. CPUs strafe, lead their throws with 150–400 ms reactions and miss about a third. 3-second test: snowballs in flight, a big "FIRST TO 15" sign, a crosshair.

2. Principles

  • Readable in 3 seconds. From the title card's blurb and the first frame of play, a new player knows what to do. One verb, one goal. The screen shows the goal (a coin, a flag, a sign).
  • Few controls. Move and one action covers a lot; the mouse and a few more keys (section 0) are fine when the game needs them, but the fewer the better.
  • Instant response. Your character reacts on the frame you press, even online. Netcode never gets to make the controls feel slow.
  • Short loops. Rounds, lives and waves inside a long session. A round that runs long feels twice as long when you're losing.
  • Everyone is busy all the time. No waiting for your turn; if players get knocked out, the game should end soon after, or give them something to watch.
  • Interaction beats solitaire. Bumping, stealing, blocking and racing side by side make moments to laugh at. When the game is per-player, show the others (ghosts, a live board) so it still feels like a race.
  • Chaos, but fair. Randomness makes it lively; skill decides it. Everyone faces the same seeded layout and the same schedule.
  • Juice. Every event has a visual, a sound and a little motion. The world reacts: coins bounce, crates splinter, the camera shakes.

3. Shapes that work

Shape How it ends Examples
Collect the most the timer Coin Cascade, Paint Rush (cover the floor), Fishing Frenzy (heaviest haul)
Last one standing one left (or the timer as a backstop) Bomb Blitz, Hot Potato, Crumble Tumble, Balloon Bash, Quick Draw
Hold the spot the timer King of the Hill
Race everyone home, or the timer Sky Hopper, Mine Cart Rush
Rounds of choices or memory the last round Treasure Doors, Memory Match, Quick Draw

4. Scoring

Higher is better; tied scores share a place: the session ranks the run.

  • Collect: the count.
  • Race: -finishMs; unfinished runners below every finisher, ranked by how far they got.
  • Last one standing: scoresFromKnockouts(n, groups) (that is, -rank), or the time survived in ms. Players knocked out together share a place.
  • Rounds: a total over the rounds, or knockout order with a tiebreak (Quick Draw: going out later beats going out sooner; within a round the faster shot wins).
  • Closest guess: -|error|. Avoid scores that often tie at zero: a round where nobody scores is a flat ending.

5. Drama and comebacks

  • A finale. Something changes in the last 10 seconds: Coin Cascade's COIN FRENZY (coins rain three times faster, more big blocks) with flow.hud.banner and flow.intensify(). The music speeds up by itself.
  • Steal and spill. Bumping a rival makes them drop coins you can grab (Coin Cascade), so the leader is a target.
  • Show the leader. A crown on the leader, a live board, a HUD stat per player.
  • Hidden timers. Hot Potato's fuse length is secret, so every pass is tense.
  • Fake-outs. Quick Draw flashes FISH! and FIRM! before FIRE!, so jumping the gun is a real risk.
  • Sudden death when tied at the end.
  • A last-second swing is possible, but not a coin flip that erases the whole round.

6. Bots that feel human

Every seat must be playable by a CPU, and good bots are what make the game fun offline.

  • A spread of skills, handed out round-robin (skillFor): one sloppy, one steady, one sharp. Human-level, not perfect: humans should win often enough to enjoy it.
  • Reaction time: Reaction(rand, 0.18, 0.45) before acting on a cue; think a few times a second (ThinkTimer), not every frame.
  • Mistakes on purpose: Quick Draw's bots fall for a fake-out 14% of the time and jump the gun 3% of the time; Treasure Doors' bots guess at random but shy away from crowded doors.
  • Commitment: StickyTarget keeps a target until something clearly better turns up, so bots don't dither between two coins.
  • Weave: Wobble + steerTo({ weave }) so they don't move like rulers.
  • Manners: Coin Cascade's bots hold a grudge timer after a bump so they don't stun-lock anyone.
  • Read the game like a human: a bot sees the landing marker, not the hidden spawn table.
  • Test: bots alone must finish a round with varied scores, and the best bot shouldn't win every seed.

7. Anti-patterns

  • Rules that need a paragraph. If the blurb needs "and" twice, cut a rule.
  • Precision or dexterity only a keyboard expert has (fast combos, pixel-perfect jumps).
  • A joiner who has to wait, read, or configure anything before playing.
  • A game that only works at one player count.
  • Long silences: nothing to do for more than ~3 seconds.
  • Runaway leaders with no way to catch up.
  • Eliminated players staring at nothing for 40 seconds.
  • Anything that depends on reading small text mid-game.
  • A camera that loses players at the edges, or players too small to tell apart.
  • One world object per player that others can't touch: then it's four solo games side by side.

8. Idea starters

  • Sky Charge (host, 2–10, FPS): two teams on floating islands, one plants a charge at one of two sites and the other defends; rounds of 90 s.
  • Block Royale (host, 1–16, top-down + mouse): the island shrinks; last one on it wins the round.
  • Sky Racers (per-player, 1–8): laps around a cloud circuit, ghosts of everyone, best lap on the board.
  • Build Off (discrete, 2–8, mouse): build to a theme in 90 s, everyone votes.
  • Zombie Night (host, 1–6, FPS co-op): waves of voxel zombies; survive to dawn.
  • Lantern Peak (host, 1–12, obby): a tall voxel mountain of jumps, swinging logs and crumbling bridges, checkpoints as lit lanterns, a best time kept in storage.
  • Deep Hollow (host, 1–4, top-down + mouse, co-op RPG): a dungeon of rooms carved into a hill, loot, levels and a boss at the bottom; the next floor deeper every run.
  • Harbour Town (per-player, 1–16): fish, sell, upgrade your boat and unlock new waters, with everyone's catches on a dock board.
  • Lane Siege (host, 2–8, mouse): build towers along your lane and send creeps down theirs.

SDK reference · vp docs api · api.md

The SDK, a curated tour

The authoritative reference is the installed types: node_modules/@voxelparty/sdk/types/sdk/index.d.ts lists every export, and each export's doc comment lives with its module under types/minigames/kit/, types/world/, types/textures/, types/scene/, types/render/ and types/soundengine/. Grep there when you need a detail this page skips.

Contents

  1. Entry points
  2. defineGame, GameContext, GameStage
  3. The frame: Flow
  4. Input: Controls, KEYS, Keys (short; see input.md)
  5. The link
  6. Randomness and maths
  7. Netcode (short; see netcode.md)
  8. Bots
  9. Players: Avatar, PoseSmoother
  10. Stage, island, camera (short; see art.md)
  11. Voxels and textures (short; see art.md)
  12. Juice: ease, Tweens, Spring, hitstop, Vfx, Fx, Popups, Bits, particles, labels
  13. UI components (and the page's corner)
  14. Sound (short; see sound.md)
  15. Tests: @voxelparty/sdk/test
  16. Saving: storage
  17. Experimental

1. Entry points

Import What Where it runs
@voxelparty/sdk Everything: the contract, kit, voxels, textures, scene, sound. Re-exports all of /core. Only inside the game runtime (vp dev, vp check, the site). Under bun it throws on purpose.
@voxelparty/sdk/core The headless part: rng, maths and noise2D, netcode (Seats, PlayerSync, HostSync, …), block worlds (World, WorldSync, aimBlock, B), bot helpers and Stick, ranksFromKnockouts/scoresFromKnockouts, END_EVENT, GameFrame, the link types, storage (saved data), sound data (INSTRUMENTS, SFX, SONGS, Patch, Song, …, parsePattern, checkSong) and texture painters (Px, hex, flat, TexDef, …) Anywhere: rules.ts, bot.ts, sounds.ts, textures.ts, tests
@voxelparty/sdk/test FakeRoom, FakeFlow, createSoloLink, rankScores, resetStorage Tests
@voxelparty/sdk/experimental Engine internals and unsettled APIs (section 17) May change in any version: avoid
three The runtime's single copy of three.js Game code only (not headless files)

2. defineGame, GameContext, GameStage

import { KEYS, defineGame } from '@voxelparty/sdk';
export default defineGame({
  id: 'counting-sheep',          // must equal game.json's id
  name: 'Counting Sheep',
  blurb: 'Sheep leap the fence. Count them! Closest count wins.',   // title card: goal + twist
  controls: [[KEYS.action, 'Count a sheep']],                        // [keys label, what it does]
  round: 45,                     // seconds; the HUD timer counts it down. Omit for untimed session games,
                                 // and for schedule-driven games (use flow.setRound)
  music: MUSIC,                  // theme; soft under the title, full at GO, 12% faster for the last 10 s
  finale: true,                  // false: no last-10-seconds speed-up
  pointerLock: false,            // true: lock the pointer on "Click to play" and on clicks while playing
                                 // (mouse-look games; needs "pointerLock" in game.json's input)
  create: (ctx) => new CountingSheep(ctx),   // returns a GameStage (below); may be async
});

GameContext (what create gets):

  • engine: { mats: VoxelMaterials; env: Texture; icon(object, opts?): string }: the shared voxel materials (mats.solid terrain, mats.actor characters/props/FX, mats.cross plants, mats.water; never dispose them), the baked sky for arenaStage, and icon: any Object3D rendered to a PNG data URL for <img src>, lit and finished like the game on a transparent background (IconOptions: size 96, yaw 30, pitch 25, pad 0.08, fov 0 = flat, bounds, key to cache). It draws on the spot: once per icon, not every frame (art.md section 10). engine.water is the water every water block is drawn with (set its look, field floating shapes and river flow, ring foam round things bobbing: water.md), and engine.quality the player's graphics setting ('low' | 'medium' | 'high', live: draw less on 'low').
  • players: PartyPlayer[]: { name, color, look, human }, in seat order, index-aligned with link.players. color is the player's UI colour (16 of them; the first four are red #e23b3b, green #3fb34f, yellow #f2b51f, purple #8e4fd6). look goes to new Avatar(mats.actor, look). human means "this client's keyboard". Live in sessions: the same array is updated in place when people join and leave (keep the array, not a copy).
  • link: MinigameLink: networking, clock, seed (section 5).
  • flow: Flow: the frame around the game (section 3).
  • input: Controls: this player's input in actions (section 4).
  • time: GameTime: hitstop and slow motion (section 12). The dt and t your update gets are game time; time.realDt is the real frame.
  • tween: Tweens: tweens on game time, updated after your update, cleared at the end (section 12).

GameStage (what create returns):

interface GameView { readonly scene: Scene; readonly camera: PerspectiveCamera; readonly tilt?: number;  // an ArenaStage is one
  readonly insets?: readonly Inset[] }    // minimaps, mirrors: drawn over the view, read every frame
interface Inset { camera: Camera; rect: InsetRect; scene?: Scene; round?: boolean; resolution?: number; every?: number }
// InsetRect: { left? | right?, top? | bottom?, width, height } in CSS px (a missing pair centres it)
interface GameStage {
  readonly view: GameView;                // what the engine draws; read every frame, so a game may swap cameras
  update(dt: number, t: number): void;    // every frame, after flow.update and before input.endFrame
  resize?(w: number, h: number): void;    // re-fit the camera: this.view.rig.resize()
  onPlayers?(players: readonly LinkPlayer[], joined: readonly LinkPlayer[], left: readonly LinkPlayer[]): void;
                                          // sessions: someone joined or left (ctx.players is already updated)
  dispose(): void;                        // free what you made; the platform disposes flow and input
}

class CountingSheep implements GameStage {
  readonly view: ArenaStage;
  constructor(private readonly ctx: GameContext) {
    this.view = arenaStage(ctx.engine, { rig: { hold: () => ctx.flow.phase === 'intro' } });
  }
  update(dt: number, t: number) { /* … */ this.view.rig.update(dt, t); this.view.update(dt, t); }
  resize() { this.view.rig.resize(); }
  dispose() { this.view.dispose(); }
}

dt is already capped at MAX_DT (0.1 s) by the platform, so a stalled tab doesn't make things jump: don't clamp it yourself. It's game time: 0 during ctx.time.hitstop, smaller in slow motion.

3. The frame: Flow

ctx.flow runs title card → ready-up → 3-2-1-GO → play → FINISH → results. Don't build your own. In a session the title card says "Click to play"; an untimed game (no board block) goes live on that click with no countdown and no timer (timeLeft is Infinity), and the HUD follows joins and leaves (up to 16 chips). end() / finish() end the run; the platform then starts a new one (vp docs sessions).

Member Meaning
flow.phase 'intro' | 'countdown' | 'play' | 'finish' | 'results'. Drive intro orbits and victory poses from it.
flow.live phase === 'play'. Simulate and read input only while true.
flow.clock Seconds since GO on the synced clock (identical on every client).
flow.timeLeft Seconds left: min(your round, the server's hard stop). End the game at <= 0. Infinity in an untimed session.
flow.onPlay(cb) cb runs on this player's "Click to play" / Ready click, inside the gesture (lock the pointer, start audio). Returns an unsubscribe.
flow.setRound(seconds) Change the round length (schedule-driven games).
flow.end(scores, headline = 'FINISH!') Host-authoritative: the host decides for everyone. Reports every score and shows the finish on every client.
flow.finish(scores | null, headline = 'FINISH!') This client is done: reports the seats it owns (NaN for the rest), or null to just stop.
flow.hud.setStat(i, html) The line under player i's HUD chip (i: the current roster index; the line stays with that player through joins and leaves). Use hudStat(icon, value) (the chip is light paper: a pale icon texture disappears on it; use a dark-edged icon or plain text like ${score} ★). Update only when it changes.
flow.hud.setOut(i, true) Grey out a knocked-out player's chip.
flow.hud.banner(text, color = '#ffcc33') Big transient text with a whoosh: 'FRENZY!', 'SUDDEN DEATH!'. Long text shrinks to fit the screen on one line; keep it short all the same.
flow.music(song, { finale }) Start (or swap) the theme. defineGame({ music }) already does it.
flow.musicVolume(v, time = 0.4) Scale the theme: 0 for a tense silence, back to 1 after.
flow.intensify(factor?) Kick the theme into its fast version now (sudden death).

Scores: higher is better, ties share a place, non-finite scores are ignored. Examples: items collected → the count; a race → -finishMs; last one standing → scoresFromKnockouts(n, groups) (from /core: groups are seat indices in knockout order, first out first, players out on the same frame in one group; it returns -rank per seat, ready for flow.end); ranksFromKnockouts gives the ranks (1 is best) instead; survival → ms survived.

4. Input: Controls, KEYS, Keys

Actions (every game; all a board minigame may use), the game's own buttons, then the mouse and raw keys for games that list mouse / pointerLock / keyboard in game.json's input. Gamepads and touch screens drive all of it with no device code. The full guide, with a first-person camera recipe: input.md.

type Action = 'action' | 'up' | 'down' | 'left' | 'right';
input.move();               // Stick { x, z }, each -1..1: x right, z towards the camera. Same shape as the bot helpers
input.dir();                // grid games: the most recently pressed direction still held, or null
input.down('action');       // held (SPACE / X / ENTER, a pad's A, the big touch button)
input.pressed('action');    // went down this frame (a left click/tap anywhere counts as 'action', except in pointerLock games)
input.pressed('reload');    // a button of the game's own: defineGame({ buttons: { reload: { keys: ['KeyR'], pad?, label?, icon?, touch? } } })
input.presses('action');    // presses since the game began: stream this counter, not a flag
input.pressedAt('action');  // the last press, on link.now()'s clock, accurate to the key event
input.pressTimes('action'); // every press since the last frame, oldest first: readonly { at, local }[]
                            // `at` on the link clock (compare across players), `local` on performance.now()
// the mouse ("mouse"; "pointerLock" for the lock)
input.pointer();            // { x, y } NDC -1..1, y up; the centre while locked
input.mouseDown(b = 0);     // held: 0 left, 1 middle, 2 right
input.mousePressed(b = 0);  // went down this frame
input.wheel();              // px this frame, + = towards you
input.look();               // { dx, dy } px this frame, locked or not
input.ray(camera, out?);    // a three.js Ray through the pointer (the centre when locked)
input.lockPointer(); input.unlockPointer(); input.locked
input.pointerLock = false   // pause defineGame's automatic lock (a pointer-driven mode); true locks on the next click
input.aiming                // look is live: locked, or a pad / touch screen. Gate look and fire on this
input.blocked               // a modal Menu is open: everything above (and input.keys) reads idle (menus.md)
// every device
input.device                // 'keyboard' | 'touch' | 'pad'
input.label(KEYS.action)    // "SPACE", "A" or "TAP" (any KEYS, or a button's name or key label)
input.rumble(0.6, 120)      // shake the pad / buzz the phone
input.pad                   // down('lt'), pressed('y'), stick('right'), trigger('rt'), connected
input.touch                 // the touch controls: enabled, shown, setButtons([...]), root; defineGame({ touch }) to lay them out
input.bind(el, 'dash')      // any element of yours is a button (or 'click' / 'rightClick'); returns the unbind
// raw keys ("keyboard")
input.keys.down('ShiftLeft'); input.keys.pressed('KeyR'); input.keys.latest(['Digit1', 'Digit2'])
input.keys.down('Shift'); input.keys.shift / .ctrl / .alt / .meta   // either side
KEYS.move / .action / .upDown / .leftRight / .mouse / .click / .rightClick / .wheel   // title-card labels

Reaction and rhythm games judge every press this frame on its exact time, not the frame's:

for (const p of input.pressTimes('action')) judge(p.at);

The platform calls input.endFrame() after your update. Space/Enter (a pad's A or MENU) also ready up on the title card; that's fine because you only read input while flow.live.

link.mode         // 'minigame' (a board party's round) or 'session' (a room that plays just this game)
link.players[i]   // { id, name, colorIdx, control: 'local' | 'cpu' | 'remote' }, same order as ctx.players
                  // live in sessions: a new array whenever someone joins or leaves
link.onPlayers((players, joined, left) => …) → unsubscribe   // or the GameStage.onPlayers hook
link.you          // your player id, or null (spectator, or a bots-only run)
link.isHost       // runs CPUs (and the world in host-authoritative games); true offline; can change mid-game
link.online       // false offline, in vp dev and in vp check
link.seed         // identical on every client
link.now()        // synced server clock, ms
link.startAt, link.endsAt   // endsAt is Infinity in an untimed session
link.mg           // { id, mode, maxMs? (absent = untimed), players: { min, max }, input, name?, pkg? }
link.sendState(pid, blob) / link.latest(pid) / link.sample(pid, { delayMs, angles })   // prefer PlayerSync
link.sendEvent(type, data, to?) / link.onEvent((from, type, data, at) => …) → unsubscribe  // prefer HostSync for host one-offs
                                              // to: a player id or ids: only their clients get it (a joiner's world)
link.reportScore(pid, score)   // prefer flow.end / flow.finish
link.keep(world) → size        // host: a full copy of the world (JSON, ≤ KEEP_MAX = 64 KB) for whoever hosts next; latest wins, sent ≤ 1/s
                               // returns its size in characters of JSON: over KEEP_MAX it isn't kept (packInts/packBytes shrink it)
link.onKept((world, at) => …) → unsubscribe   // taking over as host mid-run: restore from it (validate it)
                                              // prefer HostSync's keep option (netcode.md §8)

6. Randomness and maths (/core)

type Rng = () => number;                 // 0..1
mulberry32(seed): Rng                    // the seeded PRNG: mulberry32(link.seed ^ 0x51ed) for a named stream
hash3(x, y, z): number                   // stable 0..1 per integer coordinate (texture/terrain variety)
noise2D(rand): Noise2D                   // seeded smooth noise, (x, y) => about -1..1: terrain, wobbly edges, Island's `noise`
clamp(v, a, b), lerp(a, b, t), smoothstep(a, b, x), lerpAngle(a, b, k), r100(v)

Use noise2D rather than adding simplex-noise yourself: it's the same noise, seeded from your rng.

7. Netcode (/core)

Seats, PlayerSync<T extends object, E> (with event/onEvent one-offs, onReset and onReport), HostSync<S, E, A, X> (with read() → { fresh, latest, at }; actions act/pending/predict/onReject, secrets tell/secret/onSecret/secretOf, timed events schedule/due, and flush: netcode.md §11), followReport(from, report, maxSpeed, dt, slack?), Reconciler({ trail, dist, time, canSnap }) (with check, record, reset), Predictions<K>, END_EVENT, GameFrame, Lockstep<W, O> (deterministic lockstep for order-driven games: RTS, tower defense, snakes; netcode.md §10), and packInts/unpackInts/packBytes/unpackBytes/KEEP_MAX (compact JSON for kept worlds and snapshots). Signatures and patterns are in netcode.md. A world of blocks everyone builds and breaks syncs with WorldSync: world.md §7.

8. Bots (/core)

Pieces to compose your own Bot class; not a base class. Bots run only where seats.role(i) === 'bot' (the host) and take a seeded rng, never Math.random, so a test replays the same match.

botRng(seed, salt = 0xb07): Rng          // the bots' own stream: botRng(link.seed)
forkRng(rand): Rng                       // a child stream, e.g. one per bot
skillFor(skills, n)                      // round-robin: skillFor(BOT_SKILLS, bots++)
jitter(rand, base, spread)               // base ± spread·base
between(rand, min, max)

new ThinkTimer(period, rand, spread = 0.2)   // .tick(dt) → true when it's time to re-plan; .now(); .delay(s); .left
new Reaction(rand, min = 0.18, max = 0.45)   // .update(dt, cueVisible) → true once the cue has shown for its reaction time
new Wobble(rand, rate = 2.1)                 // .update(dt); .offset(amount) → sideways weave for steerTo; .phase

// Steering returns a Stick { x, z }, the same shape as input.move(); pass `out` to write into your own object
steerTo(x, z, tx, tz, { weave?, stop?, slow? }, out?): Stick        // unit stick towards a target
driftToCentre(x, z, phase, { cx?, cz?, pull?, wander?, rest? }, out?): Stick
avoidRim(x, z, stick, radius, margin = 1.1, strength = 1.6): Stick  // push inward near a round edge
bestDirection(x, z, score, { dirs?, step?, stay?, stick?, current?: Stick, resolve?, substeps?, shortfall? }, out?): Stick
  // try a ring of short steps, score where each lands (higher is better): obstacles, fleeing, edges, no pathfinder
nearest(items, cost): T | null
new StickyTarget<T>(rand, { ratio = 1.3, hold? })   // .pick(items, score) keeps its target unless something is clearly better; .current; .drop(); .update(dt)

// grids (cells are x + z·w)
gridSearch(w, h, start, { enter(to, from, t), step?, jumps?, cap? }): GridSearch   // BFS with arrival times
firstStep(search, goal) / pathTo(search, goal) / floodCount(w, h, start, open, cap?) / DIRS4
ranksFromKnockouts(count, knockedOut: number[][]): number[]      // 1 is best
scoresFromKnockouts(count, knockedOut: number[][]): number[]     // -rank: pass straight to flow.end

A typical bot (from Star Catch):

import { ThinkTimer, Wobble, steerTo, type Rng, type Stick } from '@voxelparty/sdk/core';

export class Bot {
  private think: ThinkTimer; private wobble: Wobble; private target: Star | null = null;
  constructor(rand: Rng, private skill = 1) { this.think = new ThinkTimer(0.35 / skill, rand); this.wobble = new Wobble(rand); }
  update(dt: number, me: Fighter, stars: readonly Star[]): Stick {
    this.wobble.update(dt);
    if (this.think.tick(dt) || (this.target && !stars.includes(this.target))) this.target = this.pick(me, stars);
    const t = this.target;
    return t ? steerTo(me.x, me.z, t.x, t.z, { weave: this.wobble.offset(0.25), stop: 0.15 }) : { x: 0, z: 0 };
  }
  private pick(me: Fighter, stars: readonly Star[]): Star | null { /* the star it can reach soonest */ }
}

The world's move(f: Fighter, stick: Stick, dt) then takes a human's input.move() and a bot's stick alike. If an intent carries more than a stick, spread it: { ...input.move(), dash: input.pressed('action') } and { x: 0, z: 0, dash: false } for a bot.

First-person CPUs walk voxel maps with NavGrid and PathFollower (and return an FpsIntent, the shape readIntent gives a human): fps.md. new NavGrid(grid, { tuning, spacing?, radius?, clearance?, step?, jumpUp?, drop?, links? }) or await NavGrid.build(grid, o) (a slice at a time; NavGrid.start + work(ms) to drive it); nearest(x, y, z, { above?, below?, radius? }), path(a, b, { budget?, partial? }), ready, partial; new PathFollower(nav, { reach?, flight?, budget?, partial? }); stickFor(yaw, x, z, { unit? }).

9. Players

const av = new Avatar(mats.actor, player.look, { scale: 0.62, turn: 16, tag: { text: 'P1', color: player.color } });
scene.add(av.root);
av.teleport(x, 0, z, yaw);     // start, respawn, reset: jump instead of gliding
// every frame:
av.set(f.x, f.y, f.z, f.yaw);  // the simulated pose (yours, a bot's, or a remote report)
av.update(dt);                 // glides av.root there, adapting to how often updates arrive
av.shown                       // the pose on screen: aim effects at it; send it in host snapshots for remote players
av.speed                       // ground speed on screen: drive walk bobs with it
av.char.body                   // animate hops, squash, sway, lean here; av.char.root for spins, KO shrink, rings; av.char.height ≈ 1.78 (unscaled)

AvatarOptions: scale (arena games 0.62), tag (a label over the head: give it only to your own seat), teleport (jump distance that snaps, default 3), turn (yaw easing rate; 16 feels good). PoseSmoother is the same smoothing without a character (three-free).

Dressing up, looks and bodies (all optional; details in art.md §5):

av.wear(HAT, 'head');                   // a Volume (or any Object3D) at 'head' | 'hand' | 'offhand' | 'back'
av.anchors.hand.add(knife);             // or hang your own; anchors bob with the body, move with setLook
av.unwear(item) / av.unwear();          // one thing, or everything
av.tint = '#6a4cff'; av.tint = null;    // multiply the whole body (worn Volumes too)
av.opacity = 0.35;                      // ghosts, spectators (0 hides it)
av.flash('#ff4040', 0.15);              // a hit: a glow that fades
av.setLook(look | volume, voxel?);      // a new body in place: disguises, props, a werewolf
new Avatar(mats.actor, volume, { voxel: 1 / 12 });   // any voxel model as a player's body
av.dispose();                           // out of the scene; frees its body, worn Volumes, tag, own material

characterVolume(look) is a built-in character's voxels (edit one, or crowd them).

Crowds (art.md §5): hundreds of animated units, two draw calls a kind.

const army = new Crowd(mats.actor, FOOTMAN, { team: [ids.TEAM] });   // a Volume, a Look, or { body, team?, height }
scene.add(army.root);
for (const u of units) army.draw(u.id, u.x, 0, u.z, { team: TEAM_COLORS[u.team] });   // every frame
army.update(dt);                        // walk bob + lean, facing, pop-in, deaths of the undrawn, upload
army.attack(id); army.hit(id, color?); army.remove(id); army.has(id); army.count
army.put(matrix, team?, color?)         // one copy this frame, posed by you (no id, no animation)

CrowdOptions: voxel, team (block ids or a test), cap, shadows ('body': not the team part), die ('topple' | 'sink' | 'shrink' | false), dieTime, bob, lean, lunge, pop, turn, stride. CrowdDraw: yaw, team, color, scale, matrix.

10. Stage, island, camera

arenaStage(engine, StageOptions) → { scene, camera, tilt, insets, grade, sun, rig, sky, clouds, pollen(), sunAt(), xray(at, { radius, floor }), light(LightOptions), blockLight(vol, BlockLightOptions), update(dt, t), dispose() }: a GameView, so it's what view holds (insets starts empty: push a Minimap or any Inset); new Minimap({ rect?, area?, centre?, heights?, scene?, round?, resolution?, every?, rotation? }), a top-down Inset with fit(w, d), centreOn(x, z), follow(point | null) and a settable rect and rotation (which way is up, radians clockwise from north); engine.icon(object, IconOptions) for pictures of models; new Island({ rand, mats, size, land, noise?, top?(v, x, z, r, isLand), decorate?(v, isLand), layers?, tint?, origin?, underside? }) with island.noise, island.isLand(x, z), rectLand, roundLand, meadow, underside, landMask(sx, sz, shape, noise), addVoxelMeshes; stage.sky (a StageSky): set(mood, seconds?), mix(base, ...[mood, amount]), timeOfDay(hours, seconds?), easing; moods are MoodNames (MOODS: day dawn dusk night storm lava dark) or { from?, ...Partial<Mood> }; stage.light({ kind?: 'point' | 'spot', color?, intensity?, range?, angle?, softness?, shadow?, at? }) → a PointLight or SpotLight in the scene (at most MAX_LIGHTS 8, MAX_SHADOW_LIGHTS 2; make them while building); stage.blockLight(vol | LightPiece[], { voxel?, origin?, at?, blocks? }) → a BlockLight: add(CellLight) (group?: 1..4 for a dimmer), remove(l), refresh(), at(x, y, z), cell(x, y, z) (world → cell), gain and dim(group, level) (live, no re-bake), vol, field; several volumes: [{ vol, origin?, at? }, …]; glowing blocks via light in a GameBlockDef (BlockLightDef: a colour or { color, reach?, strength? }); bakeLight(vol, lights?, blocks?), lightFalloff(d, reach); stage.grade (a Grade: exposure, vignette, saturation, tint), read every frame, also GameView.grade; stage.clouds (a CloudLayer or null): show(), hide(), visible, opacity, tint, group; xray(mats, camera, at | null, { radius = 2.6, floor, occluders }) for your own camera (pass your scene as occluders so it only opens when something is in the way); underside options include minDepth (default 2; 0 thins the rock out at the edge); new CameraRig(camera, opts) (opts.smooth to glide) with fit(width, pitch, { pad, min, depth, height, heightPad, hud }), follow, zoomOn, shake, shakeAtLeast, snap, update. Details and recipes: art.md.

11. Voxels and textures

Volume, meshVolume(vol, { voxel, origin?, tint? }) → { opaque, water, cross }, blockGeometry(id, size), bitGeometry(id), B (built-in blocks), GameBlockDef (a name, [top, side, bottom?], or { top, side?, bottom?, turns? }: turns tops follow the voxel's meta, turnTop(dx, dz)), useGameAssets(TEXTURES, BLOCKS), textureDataURL(name). The painters are in /core, so a textures.ts can be tested headless: TexDef, Px, hex, pal, shade, mix, pick, ri, maskRows, maskFrom, starMask, pointInPoly, stamp, flat(...), planks, stone, cobble, voronoi, dirt, palettes (GRASS, DIRT, STONE, SAND, PATH, WOOD, BARK, SNOW, WATER), S (16). Details and the rules: art.md.

Levels as text (/core, all of it in levels.md): textGrid(layers, legend, { seed?, mirror?, swap?, shareSeam? }) → a TextGrid (a Volume and a Structure: world.stamp(level, x, y, z), grid.addVolume(level, at), meshVolume(level, …)) with marks (typed from the legend), mark(name), char(x, y, z), layers. Legend entries: a block id, a mark name, a column [bottom, …, top], a Structure (a prefab), { block, height?, top?, meta?, mark?, prefab?, turns? }, or a seeded function of the cell. gridText(anyGrid, { view?: 'top' | 'heights' | 'all' | y, legend?, area? }) prints any grid back as text. The first line is z 0 (far), x runs right, layers go up.

Block worlds (players place and break blocks, synced: bed-defence team battles, build contests, a mining game): World (/core: a VoxelGrid of blocks with rules, damage, structures and compact forms), WorldSync (/core), aimBlock (/core: the block you look at, reach, bridging), blockIds (your blocks' ids headless), and on screen WorldView (chunked, re-meshed where it changed), BlockCursor (outline, ghost, cracks) and BlockFx (block bits and sounds). The long form of a GameBlockDef takes the block's rules (hp, drop, unbreakable, placedOnly, tint, solid, opaque). All of it: world.md, and vp init --blocks for a working game.

12. Juice

Motion first: eases, tweens, springs and game time (all but ctx.time/ctx.tween are in /core).

// Easing: plain (t) => number curves over 0..1. By name in tweens, or call them yourself.
ease.outBack(k)            // pop in with a little overshoot; ease.outBack(k, 2.5) overshoots more
ease.smooth(k)             // smoothstep, gentle at both ends (what games hand-write as `ease`)
ease.arc(k)                // 0 → 1 → 0: a hop, a pulse, a flash
// linear smooth smoother arc, and in/out/inOut × Quad Cubic Quart Quint Sine Expo Circ Back Elastic Bounce
// (outElastic(k, period = 0.3)); makers: ease.steps(4), ease.bezier(x1, y1, x2, y2),
// ease.out(f), ease.inOut(f), ease.yoyo(f) (there and back in one run)

// Tweens: ctx.tween runs on game time (updated for you after update(), cleared at the end).
const tw = ctx.tween;
tw.to(mesh.position, { y: 2 }, { time: 0.5, ease: 'outBack' });       // any numeric fields: Vector3, Color, opacity
tw.to(mesh.scale, { x: 1.3, y: 0.7, z: 1.3 }, { time: 0.08, yoyo: true, repeat: 1 });   // a squash, out and back
tw.from(card.scale, { x: 0, y: 0, z: 0 }, { ease: 'outBack' });     // from these values to where it is now
tw.to(mat, { opacity: 0 }, 1).then(() => mesh.removeFromParent());   // options can be just the time
tw.value(0, score, 1.2, (v) => (el.textContent = String(Math.round(v))));   // anything that isn't a field
await tw.to(door.position, { y: 3 }, 0.6); await tw.wait(0.2); tw.to(...);   // sequences read top to bottom
tw.stop(mesh.position); tw.busy(target?); tw.clear();
// options: { time = 0.3, ease = 'outCubic', delay, repeat (Infinity: forever), yoyo, onUpdate(k), onDone() }
// A newer tween on the same target takes over the fields it moves (no fights). A stopped tween
// never resolves (so an interrupted sequence goes no further); stop(true) jumps to the end and resolves.
// new Tweens() for your own clock: update(dt) it yourself (e.g. with ctx.time.realDt for a HUD during a hitstop).

// Springs and feel maths
const squash = new Spring(1, { hz: 4, bounce: 0.5 });   // hz: wobbles a second; bounce 0 (none) .. 0.9
squash.kick(-6);                                          // a landing: dips and wobbles back to 1
const s = squash.update(dt); mesh.scale.set(1 / s, s, 1 / s);   // every frame; .to(target), .set(v), .settled()
damp(a, b, rate, dt)        // frame-rate-independent `a += (b - a) * k`: rate 5 gentle, 10 brisk, 20 snappy
dampAngle(a, b, rate, dt)   // the short way round
approach(a, b, maxStep)     // speed = approach(speed, max, accel * dt)
remap(v, a0, a1, b0, b1, ease?)   // clamped: remap(dist, 2, 12, 1, 0) = full up close, none from 12 away
pingPong(t, length = 1); wrapAngle(a); angleDiff(a, b)

// Game time: hitstop and slow motion, on ctx.time.
ctx.time.hitstop(0.07);                    // freeze 70 ms of real time: 0.03–0.05 light hits, 0.08–0.15 heavy
ctx.time.hitstop(0.1, 0.05);               // or nearly freeze (5% speed)
ctx.time.slow(0.25, 1.5);                  // quarter speed, easing back to normal over 1.5 s
ctx.time.slow(0.2, 1, { hold: 0.8, ease: 'inCubic' });   // hold first
ctx.time.scale = 0.5;                      // yours: half speed until you set it back
ctx.time.speed; ctx.time.frozen; ctx.time.realDt; ctx.time.cancel();

What game time touches. Your update(dt, t) gets game time (dt 0 in a hitstop, t stands still) and ctx.tween follows it. The flow (title card, countdown, round timer), link.now(), input and sound keep real time. It's a local effect: a host whose simulation runs on dt slows it for everyone. For a freeze only this player sees, run the netcode/core on ctx.time.realDt and draw with dt: this.core.update(ctx.time.realDt); this.draw(dt);.

Effects: Vfx. Spells, impacts, projectiles, auras and beams (the whole guide is vfx.md):

const vfx = new Vfx(view, { sprites?, cubes?, shells?, beams?, camera?, seed? });   // a GameView or a scene
vfx.update(dt);                                  // every frame (game time); vfx.dispose() at the end
const h = vfx.play(effect, { at?, to?, dir?, color?, scale?, ground?, follow?, followTo? });
h.move(at, to?); h.aim(dir); h.stop(); h.alive;  // loops (streams, holds) run until stop()
vfx.clear(); vfx.stats();                        // { sprites, cubes, shells, beams, draws, playing, layers }
// Headless (@voxelparty/sdk/core): VFX (the library), Effect and its layer types,
// checkEffect(e) → EffectIssue[], effectCost(e), effectLength(e), effectRadius(e).

Fx is for voxel juice: block chips, dust, confetti made of your blocks.

Then the bursts:

// Five pools, all on by default: spark (B.GOLD, cap 200), puff (B.CLOUD, 200), dust (B.SAND, 160),
// chip (B.PLANKS, 140) and confetti (the six cloth colours). Each takes a block id,
// { block?, cap?, motions?, floor?, shadows? }, or false (not built; its bursts and spawns do nothing).
const fx = new Fx(scene, mats.actor, {
  spark: { block: ids.SPARK, cap: 300 },  // or just `spark: ids.SPARK`
  chip: ids.WOOD,
  dust: false, confetti: false,           // switch off what you don't use
  // floor?: number | ((x, z, y) => number)  for every pool (a pool's own `floor` wins); y: the bit's height,
  //                                          so a floor function can return the ground below it
  // motions?: readonly Motion[]          for every pool (keep FX_MOTIONS' 4 slots, add yours after)
});
fx.sparkle(x, y, z, n, force, size = 0.09, color?)   // pickups, hits
fx.ringPuff(x, y, z, n, speed, size = 0.24, color?)  // landings, thumps
fx.dust(x, y, z, power = 0.7, color?)                // footfalls
fx.chips(x, y, z, n, speed, size = 0.14, color?)     // splinters, crumbs (color multiplies the block's)
fx.confetti(x, y, z, n, spread = 5)          // a win
fx.scaled(2.5).chips(x, y, z, 10, 3)         // the next burst only, 2.5× as big: sizes and spread × k,
                                             // speeds and lives × √k (the same arcs, bigger); counts as given
fx.spawn('puff', pos, vel, life, size, kind?)  // one custom particle: pos/vel any { x, y, z }; kind defaults
                                               // to the pool's FX_KIND; returns null when the pool is off
fx.update(dt); fx.dispose();                 // every frame / at the end
fx.pools.spark / .puff / .dust / .chip       // the Bits pools; fx.pools.confetti is one Bits per colour

const popups = new Popups(scene, { numbers: 64 });   // numbers: most on screen at once; in a flood the oldest go
popups.show(x, y, z, 'BUMP!', '#ffe066', { height: 0.7, life: 1.1, rise: 1.1, delay: 0, follow? });   // any text: a canvas label, reused
popups.number(x, y, z, -12, '#ff5a4a', { ...the same, key?, merge = 0.35, prefix? });   // damage/gold: instanced glyphs, ASCII,
                                                     // no canvas per number; the same key within `merge` s adds up
popups.update(dt); popups.dispose();

const bits = new Bits(bitGeometry(id), mats.actor, 240, { shadows?, floor?: number | ((x, z, y) => number), motions: [debris(), puff(), ember()] });
bits.spawn(pos: Vector3, vel: Vector3, life, size, kind = 0, floor?); bits.update(dt); scene.add(bits.mesh);
bits.spawn(...).color.set('#ff4040');   // a bit in its own colour (multiplies the block's; white on spawn)
// Try an Fx pool with its own motions first: chip: { block, cap, motions: [...FX_MOTIONS, myMotion] },
// then fx.spawn('chip', pos, vel, life, size, 4).
// motions: debris({ gravity, friction, bounce, fadeFrom, airDrag, lands, landBand }), puff({ drag, rise, wind }),
// ember({ lift, drag }), spark(), confetti({ drag, gravity, maxFall, flutter, flat }); custom: (p, dt, k, scale, env) => void
// with the helpers tumble(p, dt, factor), drag(p, dt, rate, horizontalOnly), fadeOut(k, from)

createParticles({ mode: 'drift' | 'fall' | 'orbit' | 'spray', points: Vector3[], colors, size: [a, b], additive?, speed?, amp? }, rand)  // GPU ambient particles
textSprite(text, color = '#fff', height = 1, { depthTest? }) / disposeSprite(sprite)   // pixel-font labels, world-sized
nameTag(text, { color?, px = 18, height?, minPx = 10, maxPx = 24, depthTest?, near?, far?, background?, anchor? })
                                  // a label that stays readable at any distance: `px` on screen, or `height`
                                  // in the world clamped to minPx..maxPx; through walls unless depthTest
tag.text = `${name} ♥${hp}`; tag.color = '#ff5a4a';   // both return a TextSprite: redrawn in place

13. UI components

The screen is yours, except the top-right corner. Over a game, everything the page shows lives in one box at the top right: the settings gear, a session's Lobby and Invite buttons, and under them, between rounds, when the next one starts, or a "did you like it?" prompt. The game's name, its author and the Report button are inside the gear's panel. Nothing else is drawn over your game. The box is var(--vp-corner-w) × var(--vp-corner-h) from the screen's top-right corner: 300 × 72 px, or 240 × 64 px on screens up to 480 px wide, and 64 × 64 px on a phone (either way up: up to 700 px wide or 500 px tall), where the page folds its buttons into one ☰ that opens them as a panel. Between rounds of a session it can grow a line or two downwards. Both are CSS custom properties on :root, so your own CSS can use them. Keep your HUD, minimap, kill feed and ammo out of that box (the top left, bottom corners and edges are all free). Flow's HUD chips stay out of it already.

The top centre is Flow's HUD: everyone's chips and the timer. With many players the chips wrap onto a second row (7+ players on a laptop, fewer on a phone), so don't hard-code how tall they are: var(--vp-hud-h) on :root is how far down from the top of the screen they reach, in px, kept up to date as they wrap and the window changes (0 before there's a HUD). Put your own top-centre UI (a purse, lane labels, a round banner) under it:

.my-purse { position: fixed; top: calc(var(--vp-hud-h, 0px) + 8px); left: 50%; transform: translateX(-50%); }

On screens up to 1000 px wide the chips reach the left edge too (put top-left panels under --vp-hud-h there); up to 600 px they sit under the corner box, full width; on short screens (a phone held sideways) they go small to keep to one row. A fitted camera can keep the field out from under them: rig.fit(W, 58, { depth: D, hud: true }) (art.md §4).

Phones. Two classes on :root, for two different things:

  • html.vp-phone: the screen is phone-sized, either way up (up to 700 px wide or 500 px tall), whatever plays it. Lay the HUD out for a small screen under it: shrink panels, keep them to the edges and corners, hide the ones a phone player can do without. The chips are small badges at the top left then, and the page's corner is one ☰.
  • html.vp-touching: someone plays by touch, and the touch controls take the bottom corners; --vp-touch-h says how far up they reach. Move bottom-corner HUD out of the thumbs' way: html.vp-touching .my-ammo { bottom: calc(var(--vp-touch-h, 0px) + 10px); }, and hide keyboard hints (html.vp-touching .my-keys { display: none; }).

Your own menu button. On a phone the page's ☰ sits in the top-right corner. A game can take that corner too and draw the button in its own style: ctx.flow.menuButton(false) hides the page's ☰, and your button calls ctx.flow.openMenu() to open the page's menu (New game, Lobby, Invite, fullscreen, controls, settings). Give it a real <button> (taps on it aren't the game's).

The frame's CSS is yours. The game frame loads only a small base in the vp-frame cascade layer (the colour variables --ink, --paper, --edge, --shadow, --yellow, --red, --blue, --gutter, a box-sizing reset, the body font, kbd as a key cap), and nothing from the site. Any rule a game writes wins over it, whatever its specificity, and any class name is free.

Flow already draws the HUD chips, the timer, the title and the results. For anything else:

const ui = new Ui();                          // a fixed, click-through layer; ui.dispose() removes all of it
ui.sign({ top?: '28%' | 'top', size? }).say('CATCH!', { color: '#ffcc33', sub: '2.4 kg', kind: 'big' | 'huge' | 'small' | 'wait' | 'pop', hold: 1.2 });
ui.flash(color?).go();                        // a full-screen flash
ui.hint().set('Hold [SPACE] to cast');        // [KEY] becomes a key cap
ui.meter({ variant: 'gauge' | 'bar' | 'line', zones?, needle?, fill?, danger?, label?, low?, hot? }).set(0.62);
const panel = ui.panel(); panel.hint(); panel.meter({...});   // a bottom-centre stack
ui.pill({ at: 'bottom' | 'top', bar? })       // .set(html, 'watch' | 'turn' | 'nice' | 'wait'), .setHead(html), .setCount(n, low), .setBar(v)
ui.track({ players: [{ color, me }], ticks?, look: 'dots' | 'rail' })   // race progress: .set(i, 0..1), .out(i)
ui.wait().set('1:23.4', 'Waiting for others…')
ui.board({ at: 'side' | 'centre' }).set([{ name, color, value?, bar?, tag?, me?, pop?, bad?, out? }])
hudStat(icon | null, value, bar?)             // HTML for flow.hud.setStat; icon = textureDataURL('xx_coin')
esc(text), keys('Press [SPACE]'), kbd('SPACE')

In-game menus (shops, upgrade screens, command cards) and a synced match setup (host options, each player's picks, teams): Menu, Setup (/core) and SetupMenu, in vp docs menus. A shop you use while playing is new Menu({ modal: false, key: 'KeyB' }) (the game keeps its input; B opens and closes it); compact cards, columns and className fit and restyle a big one.

Your own DOM: one root element with a class prefix unique to your game, styles in a .css file imported from your folder (it's bundled), removed in dispose().

14. Sound

sound.play(def, opts) → Voice | null, sound.playSong, sound.panFor, sound.duckMusic, and the Sfx helper. The data (INSTRUMENTS, SFX, SONGS, Patch, Song, Layer, SoundDef, midi, mtof, noteFreq, noteName, parsePattern, checkSong) is in /core, so sounds.ts and sounds.test.ts run under bun.

const sfx = new Sfx({ you: seats.you, halfWidth: 8, rival: 0.6, rivalPitch: -2, live: () => flow.live });
sfx.at(SOUNDS.land, x);           // an arena sound, panned by world x
sfx.for(SOUNDS.hop, i, x);        // player i's sound: full for you, quieter (and lower) for rivals
sfx.play(SOUNDS.frenzy);          // gated by `live`: silent once play ends
const fire = sfx.loop(SOUNDS.crackle, x, y, z, { vol?, pitch?, fadeIn?, near?, reach? });   // keeps going at a place, levelled to an ambient bed
sfx.update();                     // every frame with loops: loudness and pan from where they are now
fire.at(x, y, z); fire.follow = kart.position; fire.bend(3); fire.stop(1);   // voice.set({ vol, pan }, glide) under it

Everything Sfx plays is gated by live, so a sting at the finish or on the results card goes through sound.play(def) directly. FxOptions.confetti is a list of block ids to colour the confetti (default: the six cloth colours), pool options with blocks, or false for none. An Avatar look's shirt and overalls can be any block id, including your own game blocks from useGameAssets (costumes). Props attach to avatar.char.body; heights scale with avatar.char.height (the top of the head on the unscaled model), so check held props in the screenshots on several characters. Details: sound.md.

15. Tests (@voxelparty/sdk/test)

FakeRoom (with join / leave for sessions), FakeFlow, FakeLink: see netcode.md. createSoloLink({ mg, players, mode?, seed?, countdownMs? }) is an offline link (humans 'local', the rest 'cpu'; schedules on readyUp(); untimed when mg.maxMs is absent). rankScores(ids, scores) → { ranking, payouts } is the server's ranking. resetStorage(data?) empties the game's saved data, or seeds it like a browser that saved data last time (section 16).

Content is testable too, because its data lives in /core. Tests import describe, expect and test from bun:test:

import { expect, test } from 'bun:test';
import { checkSong } from '@voxelparty/sdk/core';
import { MUSIC } from './sounds';

test('the theme loops cleanly', () => expect(checkSong(MUSIC)).toEqual([]));   // SongIssue[]: { track, steps, problem }

parsePattern(pattern) → { events, length } (length in steps) is there for your own checks, such as "the melody is 8 bars": length === 128.

16. Saving: storage (/core)

A small key/value store for your game, kept in the player's browser between plays and across new versions of the game: personal bests, lifetime stats, unlocked cosmetics, settings, a saved level. Sessions and board minigames alike (a personal best in a 45 s round is fine).

import { storage } from '@voxelparty/sdk';   // or '@voxelparty/sdk/core' in headless files
storage.get('best', 0)               // the saved value, or the fallback when there's none (typed from it)
storage.get<Settings>('settings')    // or undefined
storage.set('best', 42)              // anything JSON can hold; changes at once
storage.delete('best')               // true if it was there
storage.has(key); storage.keys(); storage.clear();
storage.used; storage.quota          // characters of JSON in use, and allowed
  • Synchronous. The saved data arrives before your game's code loads, so read it anywhere: at module level, in create(), in a constructor. set changes it at once; the page keeps it shortly after (changes go out in batches, at most four a second, and right away when the game ends). Save at moments (a run ends, a setting changes), not every frame.
  • JSON. get returns a fresh copy each time: change it, then set it back. NaN and Infinity come back as null, a Date as a string, a Map or Set as {}.
  • Limits: 256 K characters of JSON per game (keys included; storage.used / storage.quota), 1000 keys, keys 1–64 characters. set throws a TypeError for a bad key or a value JSON can't hold (undefined, a function, a BigInt, a cycle; use delete to forget a key), and a RangeError when it would go over a limit. Either way nothing changes. If your data grows (a list of runs), trim it yourself.
  • Whose it is: this player, in this browser. Each browser keeps its own. It isn't tied to an account, synced between devices, or shared with the other players: in an online game every client reads and writes its own (so save your own player's things, link.you). Every version of your game reads the same data (published from the same account; a game shared while signed out gets one per version), and no other game can read it, even one with the same id. A private window may keep it only until the page closes. Players can delete it from the game's page (⋯).
  • Never trust it. It's in the player's browser, so they can read and edit it. Don't use it for anything other players see or depend on: scores and ranks (the server ranks what you report), or unlocks that give an edge online. Cosmetics and your own records are fine.
  • Old data. A new version of your game reads what the old one saved, so check the shape of what you get, and put a version number in anything whose shape may change.
  • Starts empty in checks. Bots-only runs (vp check, the store's screenshot runs) start with nothing saved and keep nothing, so the game must work with an empty storage. vp dev keeps it, like the site does: Clear saves (or ?fresh=1) starts over. Under bun test it's in memory: resetStorage() / resetStorage({ best: 40 }) from @voxelparty/sdk/test; there's one per process, so every client of a FakeRoom shares it.

A personal best and a run counter:

import { storage, type GameStage } from '@voxelparty/sdk';

interface Stats { v: 1; runs: number; best: number }

class Game implements GameStage {
  // What this browser saw before (anything else, like an older shape, starts over).
  private stats: Stats = load();
  // …
  /** Once, when your own player's run is over. */
  private saveRun(score: number) {
    const record = score > this.stats.best;
    this.stats = { v: 1, runs: this.stats.runs + 1, best: Math.max(this.stats.best, score) };
    storage.set('stats', this.stats);
    if (record) this.ctx.flow.hud.banner('NEW BEST!');
  }
}

function load(): Stats {
  const s = storage.get<Partial<Stats>>('stats');
  return s?.v === 1 && typeof s.best === 'number' && typeof s.runs === 'number' ? (s as Stats) : { v: 1, runs: 0, best: 0 };
}

Show it in your own UI (ui.hint().set('Best: ' + stats.best)), not in a HUD chip: it's this player's record, not something the others share.

17. Experimental

@voxelparty/sdk/experimental is engine internals and unsettled APIs. They may change in any version, so a game that imports them may break on a later runtime. These live there (3.0 moved Keys back to the stable entry, since input.keys is one):

  • raw bindings: BINDINGS (use Controls and KEYS);
  • the frame by hand: Flow (the class), MinigameDef, MinigameContext, Minigame, MinigameResult, Stage, KIT_SOUNDS, MinigameMusic, playResults (use defineGame);
  • block and atlas internals: KIND, K_AIR, K_SOLID, K_WATER, K_CROSS, TEX_*, META_TOP, HANGING, PAD_BLOCK, meta, GAME_BLOCK0, defineGameBlocks, GAME_LAYER0, MAX_LAYERS, LAYER, DECAL, layer, TEXTURES, PAD_COLORS, st, SMALL_VOXEL (use useGameAssets);
  • scene internals: createCharacter, CHARACTERS, DEFAULT_LOOK, createClouds, SUN_DIR, addSky, particleUniforms, disposeTree (use Avatar and arenaStage);
  • the SoundEngine class, renderOffline and the procedural composer (compose, STYLES, …): write your own theme (sound.md).

If you think you need one, look for the stable way first; if there really isn't one, tell the user (it may belong in the stable SDK).

SDK reference · vp docs art · art.md

Art: building the look

Any look and any mood: a sunny party island, a pitch-black corridor, a desert town, deep space. The one rule is the pixel art itself: 16×16 textures on voxel-sized geometry (section 8), which is what makes every game feel like it belongs. The runtime owns the renderer and the post effects (tone mapping, bloom, tilt-shift); you build the scene from voxels, set the mood and add lights.

Contents

  1. Style guide
  2. The stage: arenaStage
  3. The ground: Island
  4. The camera: CameraRig
  5. Players: Avatar, dressing up, and crowds (Crowd)
  6. Voxel models: Volume + meshVolume
  7. Blocks and your own textures
  8. The texture rules (condensed)
  9. Juice and UI
  10. Icons and insets: engine.icon, view.insets, Minimap
  11. Performance

1. Style guide

  • The platform's own UI uses these; borrow them when they suit your game.
  • Colours: ink #1d2340 (outlines, text), yellow #ffcc33, red #ff4b4b, blue #2f6fed, green #7ee081, paper rgba(255,252,245,.82). Player colours come from players[i].color.
  • Fonts (for your own DOM): Press Start 2P for big words and numbers, Nunito 800/900 for the rest. The runtime ships both.
  • UI shapes: a 3px ink border, 12–16px corners, a hard drop shadow 0 5px 0 #1d2340, frosted paper.
  • A party arena for a fixed camera works best compact (about 13×11 to 17×15 units), so players stay big on screen, with a visible edge (a cobble rim, fences, water). Bigger games (8–16 players, a shooter's map) use a follow camera (rig.follow) or first person (vp docs input §6). The floating Island is a ready-made ground, not a requirement: build any world from volumes and blocks.
  • First person: arenaStage with fov: 75, tilt: 0, players as Avatars (they're what others see).
  • A soft checkerboard tint on the floor makes movement readable.
  • Glowing things (fire, lava, magic, gems, lanterns) get glow on their texture; that feeds the bloom. For light that falls on the world around them, use view.light (a flashlight, a muzzle flash) or block light (lanterns, lava): section 2. Icons bring their own (section 10).
  • Dark and scary is fine: mood: 'dark' (or your own), a short fog (fog: [6, 30]), block light from lanterns, a flashlight (view.light), glowing eyes and a heavy vignette (view.grade).

2. The stage: arenaStage

readonly view: ArenaStage;   // the GameStage's `view`: the engine draws view.scene through view.camera, with view.tilt
…
this.view = arenaStage(engine, {
  shadowExtent: 10,          // half-size of the sun's shadow box: fit it to the field (smaller = crisper)
  pollen: false,             // or leave on (120 motes from the stage's own rng)
  rig: { hold: () => flow.phase === 'intro', look: [0, 0, 0.5] },   // orbit during the title card, then settle
});
this.view.rig.fit(fieldWidth + 2, 58, { min: 17 });  // frame a field this wide from 58° above the horizon
// every frame, after moving things:
this.view.rig.update(dt, t);
this.view.update(dt, t);   // clouds, and the sky's easing
// resize(): this.view.rig.resize();   dispose(): this.view.dispose() (frees the scene, keeps the shared materials)

(If you'd rather keep the name stage, hold it privately and add get view() { return this.stage; }.) StageOptions: fov (32), near, far, tilt (0.5), shadowExtent (14), shadowMapSize, fog ([140, 700]), clouds (true, or { y, seed }), mood ('day'), pollen (options or false), seed, rig, grade, mist (below) and sun: the direction towards the sun or moon, [x, y, z] (default high in the south-east). A low one ([0.6, 0.5, -0.6]: about 30° up) lays long shadows; it's fixed for the stage's life, since still shadows are drawn once and cached. view.pollen({ count, area, height, centre, colors, size, speed, amp }, rand) adds more motes (dust, fireflies, snow). view.sunAt(point) moves the shadow box (follow cams do it for you).

Ground mist (view.mist, read every frame): mist lying low over the world, thickest near the ground under the world height top, thinning to nothing there, broken into drifting patches, and lit from inside: block light glows in it, and so do the stage's lit point lights (the first four: a lantern carried through a bog makes a warm halo in the fog). Every voxel material is misted, the ground, props, characters and water alike; hollows fill up and rises stand out of it. Off by default (density 0), and a disposed stage leaves none behind.

this.view = arenaStage(engine, { mist: { density: 0.3, top: 6.2, fade: 1.6, color: '#1d2534' } });
this.view.mist.density = inBog ? 0.45 : 0.25;          // live

Mist: density (per unit of path through it: 0.15 a haze, 0.4 a bog at night), top and fade (world y it thins out at, and how far under that it's full), color (its own colour, unlit), breakup (0 even … 1 holes, default 0.5) and size (patches in units, default 9), wind (drift, units a second, default [0.3, 0.12]), glow and lightGlow (how much it catches block light and the point lights, default 1 each).

The mood (view.sky): the sky dome, the sun's colour and strength, the bounce light, the fog and the clouds, as one thing to set or ease. Never tint the scene's lights or the dome yourself.

this.view.sky.set('dusk', 4);             // ease into a preset over 4 s: day dawn dusk night storm lava dark (MOODS)
this.view.sky.set('night');               // at once
const EMBERS: MoodSpec = { from: 'dusk', horizon: '#ff6a3a', sunIntensity: 2 };   // yours: a preset with changes
this.view.sky.set(EMBERS, 2);             // (keep your moods in constants)
this.view.sky.timeOfDay(hour, 1);         // 0..24: night, dawn at 6, day 8–17, dusk at 19, night from 20:30
this.view.sky.mix('day', ['dusk', warm], ['night', dark]);   // blend yourself, every frame if you like
this.view.clouds?.hide();                 // show(), opacity, tint, group; a mood's `clouds: 0` fades them

A Mood has top horizon bottom (the dome), glow (the sun in the dome), sun sunIntensity, sky ground bounce (the bounce light), env (reflections), fog (default: the horizon), clouds (0..1) and cloudTint. Moods change colours only: the sun doesn't move, because still shadows are drawn once and cached. 'dark' is pitch black but for a faint moon: what players see comes from your lights, glowing textures and block light.

Lights (view.light): real lights for what moves or flickers: a flashlight, a torch someone carries, a muzzle flash, an alarm lamp. Make them all while building the stage and switch them with intensity (0 is off): adding or removing lights mid-game makes every shader recompile (a hitch). At most 8, 2 of them with shadows (section 11).

this.torch = this.view.light({ kind: 'spot', shadow: true, color: '#fff2d8' });   // a SpotLight: range 24, angle 24°
this.flash = this.view.light({ color: '#ffb347', intensity: 0, range: 8 });         // a PointLight, off until a shot
// every frame: a flashlight held by the camera
this.torch.position.copy(camera.position);
this.torch.target.position.copy(camera.position).addScaledVector(camera.getWorldDirection(_dir), 10);

LightOptions: kind ('point' | 'spot'), color, intensity (16 point, 60 spot: about the sun's strength a few units away), range, angle (degrees) and softness (0..1) for a spot, shadow, at.

Block light (view.blockLight(vol)): for lots of lights that stay put (lanterns down a corridor, lava, glowing crystals, a campfire). Light floods out through the volume's air from cell to cell: around corners, never through walls, fading over its reach. Every voxel surface in the volume is lit by it, the players and props walking past included, and it costs nothing a frame, however many lights there are.

const light = this.view.blockLight(this.vol);          // bakes on the next view.update
// glowing blocks light up by themselves: B.LANTERN, B.EMBER, and game blocks with `light` (section 7)
const fire = light.add({ x: 12, y: 3, z: 8, color: '#ff8a3a', reach: 7 });   // a light that isn't a block (cells)
fire.strength = 0; light.refresh();                     // put it out: a re-bake (ms), not every frame
this.vol.set(x, y, z, 0); light.refresh();              // the world changed: bake again
light.at(x, y, z);                                       // [r, g, b] in that cell: can a monster see you here?

One field at a time (a new call replaces it; view.dispose() frees it). Pass voxel, origin and at (where its mesh sits) if the volume isn't meshed at 1 unit a voxel from the world's origin, and blocks: false to light only with what you add. It's a 3D texture of 8 bytes a cell: keep the volume under about 2 million cells. A light's strength 1 is as bright as the sun next to it.

A map built in pieces (a house, a garden, a tower of sections) is lit as one: pass the pieces, each with its mesh's origin and at, and one voxel. Light crosses from one piece into the next, and refresh() copies them all in again. Place lights by world position with cell:

const light = this.view.blockLight([{ vol: house, at: houseAt }, { vol: garden, at: gardenAt }], { voxel: 0.5 });
light.add({ ...light.cell(lamp.x, lamp.y, lamp.z), color: '#ffd27a', reach: 9 });

Changing it every frame costs nothing, with no re-bake:

light.gain = this.flickOff ? 0 : 1;                      // all of it: a blackout, a brown-out, a flicker
light.add({ ...light.cell(x, y, z), color: '#dff3ff', reach: 9, group: 1 });   // lights in dimmer groups 1..4
light.dim(1, 0.5 + 0.5 * Math.sin(t * 31));              // group 1 buzzes; the rest stay steady
light.dim(2, lighthouse);                                // group 2 sweeps, switches, fades up at dusk

at(x, y, z) reports the light as it's shown now (gain and dimmers applied). Where two groups' light overlaps, each dims its own share of the cell. A WorldView's light (world.light) has gain too; a light there that flickers on its own is a strength change and refresh(l).

The grade (view.grade), read every frame: exposure (0 black, 1 as is), vignette (0.7 the usual, 2–3 a tunnel), saturation (1.1 the usual, 0 grey), tint (a colour that multiplies the picture), contrast (round a mid grey: 1 as is, 1.2 punchier), and split toning: shadows and highlights (colours multiplied into the dark and the bright parts; white is none) with split (the linear brightness halfway between them, default 0.08). Set fields, delete them to go back, or start with arenaStage(engine, { grade }). For a picture that isn't a toy diorama, tilt: 0 turns the tilt-shift blur off.

this.view.grade.vignette = 1.8;                          // a dark game: close in the corners
this.view.grade.tint = hurt > 0 ? '#ff6060' : undefined;  // a red flash when hit
this.view.grade.exposure = fade;                         // 1 → 0: fade to black
Object.assign(this.view.grade, { contrast: 1.12, shadows: '#a9b9ff', highlights: '#ffdcae', split: 0.05 });   // cold night, warm fire

See-through (view.xray): for a camera that looks at a hero over trees, walls or roofs, cut a dithered hole through the scenery (mats.solid, mats.cross; never mats.actor) around them:

this.view.rig.update(dt, t);
this.view.xray(hero.visible ? hero.position : null, { radius: 2.4, floor: 0.25 });   // world units; null closes it

floor is a world y at or below which nothing is cut (the ground they stand on). The hole only opens while scenery in the stage's scene really stands between the camera and the hero (a few body-sized rays), and fades in and out; blocks beside or under them never open it (occluders: false: always open). Scenery in an InstancedMesh or a BatchedMesh (a WorldView's chunks, props drawn in one batch) counts copy by copy, each where its matrix puts it. There's one hole at a time (the materials are shared); view.dispose() closes it. xray(mats, camera, at, { ..., occluders: scene }) does the same for a camera of your own.

3. The ground: Island

const W = 13, H = 11, M = 4;   // field and meadow margin, in voxels (1 voxel = 1 unit)
const island = new Island({
  rand: mulberry32(link.seed),             // same seed → the same island on every client
  mats: engine.mats,
  size: [W + 2 * M, H + 2 * M],
  land: rectLand({ w: W, h: H, margin: M }),   // or roundLand(n, radius)
  top: (v, x, z, r) => {                   // fill layers 1+ of each land column (layer 0 is dirt)
    const cx = x - M, cz = z - M, inside = cx >= 0 && cz >= 0 && cx < W && cz < H;
    v.set(x, 1, z, inside ? ids.FLOOR : B.GRASS);   // layer 1 is the floor: its top face is world y = 0
    if (!inside) meadow(v, x, 2, z, r);             // flowers and tall grass outside
    else if (cx === 0 || cz === 0 || cx === W - 1 || cz === H - 1) v.set(x, 2, z, B.COBBLE);  // a rim
  },
  decorate: (v) => { v.set(M, 3, M, B.LOG); v.set(M, 4, M, B.LANTERN); },   // posts, props
  tint: (x, y, z, id) => (id === ids.FLOOR && (x + z) % 2 ? [1.06, 1.06, 1] : [1, 1, 1]),
  underside: { ore: [[0.025, B.ORE_GOLD], [0.035, B.ORE_GEM]] },           // or false for a flat slab
});
this.view.scene.add(island.group);

top(v, x, z, r, isLand) and decorate(v, isLand) also get isLand(x, z) (false off the grid), for edge-aware scenery: a fence only where a neighbour is sea, a tree only on land:

decorate: (v, isLand) => { for (const [x, z] of POSTS) if (isLand(x, z)) { v.set(x, 3, z, B.LOG); v.set(x, 4, z, B.LANTERN); } },

The land edge is shaped by a noise drawn from rand. To share one noise between the land, the underside and your own terrain, make it with noise2D (from /core) and pass it as noise; the underside can sample it differently:

const n = noise2D(mulberry32(link.seed ^ 0x15a));
const island = new Island({
  rand: mulberry32(link.seed), mats: engine.mats, size: [22, 22], noise: n,
  land: roundLand(22, 9),                                     // round, radius 9, on the 22 × 22 grid
  underside: { bumps: 2, noise: (a, b) => n(a + 9, b) },      // the underside samples at x·0.25, z·0.25
});
const height = (x: number, z: number) => 1 + Math.round(n(x * 0.1, z * 0.1) * 2);   // your terrain, same noise

island.noise is the noise it used; island.isLand(x, z) asks the finished island.

Voxel column (x, z) spans world origin[0] + x to +1 (the grid is centred on the origin by default). y raises the whole island (the floor's top face is at world y y, default 0), for satellites and floating platforms around the main one. The top slab is only layers voxels tall (default 5, so y 0–4): anything top or decorate sets at y ≥ layers is silently dropped. For taller scenery (a barn, a tower) pass layers: 12 or so, or build it as its own Volume + meshVolume model on top. To change the ground mid-game (crumbling tiles, paint), edit island.top and call island.refresh(): fine now and then, not every frame. For many small changes, draw the changing part as an InstancedMesh instead. For a world players build and break (blocks placed and broken all game), use a World and WorldView: chunks that re-mesh only where something changed, with the same look (vp docs world).

The underside tapers to at least 2 voxels at the edge; underside: { minDepth: 0 } lets it thin out to nothing, so a thin shape (a winding lane, a bridge) keeps a keel only where it's wide.

4. The camera: CameraRig

view.rig is a CameraRig. Every frame it goes: play pose (fitted or followed) → intro orbit blend → adjust → zoom → shake → look.

rig.fit(width, pitchDeg, { pad = 6, min = 0, depth = 0, heightPad = 0 });   // a fixed view that fits any window
rig.fit(W, 54, { pad: 1, depth: D, heightPad: 2 });                         // a W × D field: whichever needs more room
rig.follow(target, [0, 4.6, 11.5], { aim: [0, 1, 0], rate: 7 });           // chase cams: call every frame before update
rig.zoomOn(winner ? spot.set(f.x, 0.6, f.z + 2.4) : null);                 // ease in on the winner at the end; every frame
rig.shake(0.14);                   // bumps 0.1–0.2, big hits 0.3–0.5: jolts add up, to a cap (shake(amount, cap = 0.5))
rig.shakeAtLeast(0.4);             // a gunshot, a slam: the biggest jolt wins, they don't add up
rig.snap();                        // after a cut, a respawn or a teleport: jump to the pose, don't glide
rig.look.set(x, y, z);             // move the fitted view's centre

depth is the field's extent along z on the ground; the rig foreshortens it for the pitch (depth × sin(pitch) on screen). height instead fits a height that already faces the camera.

Around the HUD: hud. Everyone's chips and the timer take the top centre (more with many players, when they wrap). hud: true frames the field in the band under them, exactly (the near edge's perspective included), centred there, and follows the chips as they wrap, the window as it changes and a phone as it turns:

rig.fit(W, 58, { depth: D, hud: true });                           // under the chips
rig.fit(W, 58, { depth: D, hud: { bottom: 80 } });                 // and above an 80 px bar of your own
rig.fit(W, 58, { depth: D, hud: { touch: true } });                // and above the touch controls on a phone

The touch controls sit in the bottom corners, so most games let the field run between them; touch: true is for fields that must be seen whole. For a camera of your own, hudInsets() says what the HUD takes in px and screenBand(hudInsets()) the free band in NDC rows. CameraRigOptions: hold, look, intro ({ time, spin, reach, rise, swing } or false; side-on games use swing: 0.9, spin: 0.15), zoom ({ offset, time, amount, release }), shakeDecay, adjust(pos, look, dt) for custom moves, onFollow, and smooth (a rate in 1/s, e.g. 6): the final pose (after adjust and zoom) glides instead of jumping, so camera moves made in adjust or by changing fit ease in. It still jumps on the first frame, during the intro orbit and after snap(); follow cams glide at follow's own rate either way.

  • Fixed three-quarter view (58°) for arenas. Side-on (15–25°) for lineups like Quick Draw. follow for courses and races, on your own runner (or a bot's when spectating).
  • In a bots-only run there's no "you": frame the whole field, or follow the leader.

5. Players: Avatar and animation

this.avatars = players.map((p, i) => {
  const av = new Avatar(engine.mats.actor, p.look, { scale: 0.62, turn: 16, tag: i === seats.you ? { text: `P${i + 1}`, color: p.color } : undefined });
  const ring = new Mesh(new RingGeometry(0.5, 0.72, 24), new MeshBasicMaterial({ color: p.color, transparent: true, opacity: 0.85, depthWrite: false }));
  ring.rotation.x = -Math.PI / 2; ring.position.y = 0.04;
  av.char.root.add(ring);          // a coloured ring under each player's feet
  av.teleport(start.x, 0, start.z, 0);
  scene.add(av.root);
  return av;
});

Bring them to life in update (the avatar owns root's position and yaw; the rest is yours):

  • walk bob: body.position.y = Math.abs(Math.sin(walk)) * 0.35 with walk += dt * 16 while av.speed > 0.3;
  • sway body.rotation.z = Math.sin(walk) * 0.12; breathing when idle (scale.y ± 0.025);
  • a hop on pickups, a lean and stretch on dashes, a dizzy spin (char.root.rotation.y) when hit;
  • knockouts: spin up and shrink, or tumble off the edge; setOut(i, true) on the HUD;
  • the finish: winners bounce (Math.abs(Math.sin(t * 7)) * 1.2), and the camera zooms on them.

Names over heads: nameTag. A textSprite is sized in the world, so it's huge up close and unreadable far away. A nameTag stays readable: the same size on screen, or a world size kept between two sizes. Set its text any time; it redraws in place.

const tag = nameTag(p.name, { color: p.color, far: 40 });            // 18 px, seen through walls, gone past 40
tag.position.y = av.char.height + 0.3;
av.char.body.add(tag);                                               // bobs with the body
tag.text = `${p.name} ♥${hp}`;                                      // later, as often as it changes
nameTag('Moss', { height: 0.5, minPx: 12, maxPx: 22 });              // shrinks with distance, within limits
nameTag('IT', { depthTest: true, background: '#ffcc33', color: '#1d2340' });   // hidden by walls, on a pill
new Avatar(mats.actor, look, { tag: { text: 'P1', color, px: 16 } });            // Avatar's tag, as a name tag

Held, worn, and other looks

Every avatar has four anchors in its body, so held and worn things bob and squash with it: head (the top of the head), hand (the free hand, the character's right), offhand (the other, where the character's own prop is), back (between the shoulders, on the back's face).

const HAT = new Volume(9, 4, 9);  /* … */                // modelled at 9 voxels a unit, like the characters
av.wear(HAT, 'head');                                   // sits on the head
av.wear(SWORD, 'hand');                                 // held by its lowest voxels: build it hilt-down
av.wear(PACK, 'back');                                  // hangs behind
av.wear(gunMesh, 'hand', { offset: [0, 0.05, 0.2], turn: Math.PI });   // any Object3D, as it is
av.anchors.hand.add(torch, light);                       // or hang your own things there

Volumes are meshed on the avatar's material, so a tint or flash reaches them too. unwear(item) takes one off, unwear() everything.

Looks, per avatar, costing nothing until used (the avatar then draws with its own copy of the material, the same shader):

  • av.tint = color multiplies the body (a team shade, an ink splat, frozen blue); null restores it;
  • av.opacity = 0.35 for ghosts, spectators and fade-outs (0 hides it);
  • av.flash(color = white, time = 0.15, strength = 1) for hits, heals and pickups.

Other bodies: new Avatar(mats.actor, volume, { voxel: 1 / 12 }) uses any voxel model as a player (a killer, a monster, a prop to hide as); it keeps the smoothing, tag, anchors (worked out from the model: head on top, hands at the sides) and looks. av.setLook(lookOrVolume, voxel?) swaps the body in place mid-game (a disguise, a transformation): the pose, tag and worn things stay, the anchors move. characterVolume(look) gives a built-in character's voxels to edit.

Rebuilding avatars? av.dispose() takes one out of the scene and frees its body, worn Volumes, tag and own material (not the shared materials, nor meshes of yours it wore).

Crowds: Crowd

Armies, hordes, creeps, flocks, an audience: hundreds of animated units at two draw calls a kind (the body and its team part), with no animation code of your own. One Crowd per kind; each frame draw the units that are there, by id, then update:

const ids = useGameAssets(TEXTURES, BLOCKS);
const army = new Crowd(engine.mats.actor, footmanVolume(ids), { team: [ids.TEAM] });
scene.add(army.root);

// every frame
for (const u of world.units) army.draw(u.id, u.x, 0, u.z, { team: TEAMS[u.team] });
army.update(dt);

// on events
army.attack(u.id);        // a lunge forward (negative `lunge` option: a recoil for archers)
army.hit(u.id, '#f44');   // a flash
army.remove(u.id);        // gone now, no death
  • Animated for you: units face where they walk (or the yaw you give), bob and lean with each stride, pop in when first drawn, and die when they stop being drawn (die: 'topple', 'sink', 'shrink' or false; dieTime). Tune bob, lean, lunge, pop, turn, stride.
  • Team colours: the model's team blocks go into a second mesh, tinted per unit. Paint them light (white or a pale cloth): the colour multiplies. color tints a whole unit; scale sizes it.
  • Models: a Volume (voxel, 1/9 by default), a character look ({ char: 3, … }: a crowd of frogs), or geometry you made ({ body, team?, height }).
  • Your own motion: draw(id, …, { matrix }) or put(matrix, team?, color?) draws a copy exactly where you say, no animation; mix them freely with animated units. Footman Frenzy poses its own.
  • Cost: about 0.2 ms of CPU for 500 animated units; two draw calls (plus shadows: shadows: 'body' skips the team part's) whatever the count. The meshes are uploaded and the shader compiled when the crowd is made (no hitch when the first unit appears); the pool doubles when it runs out; a crowd that holds still uploads nothing, so its shadows stay cached.
  • Units are on mats.actor, so they stay whole behind scenery; the x-ray cuts mats.solid (put buildings and walls there, instanced ones too). crowd.dispose() frees it (geometry you passed stays yours).

6. Voxel models: Volume + meshVolume

Props, pickups and obstacles are small voxel models, built in code:

export function coinGeometry(ids: { GOLD: number; RIM: number }): BufferGeometry {
  const N = 11, T = 3, c = (N - 1) / 2;
  const v = new Volume(N, N, T);                         // sx, sy, sz; ids 0 = air
  for (let y = 0; y < N; y++) for (let x = 0; x < N; x++) {
    const d = Math.hypot(x - c, y - c);
    if (d > 5.3) continue;
    if (d > 3.9) for (let z = 0; z < T; z++) v.set(x, y, z, ids.RIM);
    else v.set(x, y, 1, ids.GOLD);
  }
  // 13 voxels per unit, origin at the model's centre: about 0.85 units across
  return meshVolume(v, { voxel: 1 / 13, origin: [N / 2, N / 2, T / 2] }).opaque!;
}
const mesh = new Mesh(coinGeometry(ids), engine.mats.actor);   // props, characters and FX use mats.actor
mesh.castShadow = true;
  • meshVolume(vol, { voxel, origin?, tint? }) returns { opaque, water, cross } (each may be null). voxel is the world size of one voxel: 1 for terrain, 1 / 9 to 1 / 14 for props (the characters are 1/9), or [x, y, z] for a stretched model. origin is the voxel-space point that becomes the geometry's origin.
  • Terrain-scale volumes (1 voxel = 1 unit): addVoxelMeshes(group, vol, mats, [x, y, z], tint?) meshes them on mats.solid/water/cross with shadows.
  • Water blocks come out as real water (depth, foam at the banks, glints, flow, caustics on the bed), baked from the volume when it's meshed. Its colours, clarity, foam and flow are ctx.engine.water.set(…); a water block's meta is its flow. All of it: vp docs water.
  • Maps, arenas and terrain are easiest drawn as text: textGrid gives a Volume (and the spawns and goals as marks) to hand to addVoxelMeshes, a VoxelGrid and a World alike (vp docs levels).
  • Volume also has get, setIfAir, inside, and a meta byte per voxel.
  • Shape carries the detail: a lighter voxel on a top edge, a darker band, a 1-voxel outline in a deeper colour.

7. Blocks and your own textures

Built-in blocks (B): GRASS DIRT STONE COBBLE MOSSY SAND PATH PLANKS LOG LEAVES_OAK LEAVES_PINE LEAVES_CHERRY SNOW WATER CAP STEM PIPE PIPE_RIM GIFT GOLD CLOUD LANTERN ORE_GOLD ORE_GEM DEEPSTONE EYE GEM EMBER TALL_GRASS FLOWER_RED FLOWER_YELLOW FLOWER_BLUE FLOWER_WHITE MUSHROOM VINE ROOTS SKIN CLOTH_RED CLOTH_BLUE CLOTH_GREEN CLOTH_YELLOW CLOTH_PURPLE HAIR SHOE WHITE BLACK CHEEK METAL METAL_DARK SCREEN FUR FUR_LIGHT FROG FROG_LIGHT FOX PIG PIG_DARK BEAK CAPY CAPY_DARK QUILL KAIJU KAIJU_LIGHT NAVY DOG (plus the board's PAD_*). Check types/world/blocks.d.ts for the list in your SDK version.

Your own textures are 16×16 pixel art painted in code, in textures.ts. The painters are in /core, so a test can paint every texture headless (GameBlockDef is a type from the main entry; a type-only import is erased, so bun can still load the file):

import { Px, S, hex, pal, shade, starMask, stamp, flat, type Rng, type TexDef } from '@voxelparty/sdk/core';
import type { GameBlockDef } from '@voxelparty/sdk';
const INK = hex('#1d2340');

function wool(p: Px, r: Rng) { p.noise(pal('#f4f1ea', '#ebe6dc', '#fbf9f4'), r, 0.05); }   // a material: tiles seamlessly
function sign(p: Px, r: Rng) {                                                              // a decal: a framed picture
  const base = hex('#ffc629');
  p.each((x, y) => {
    let c = shade(base, 0.95 + r() * 0.1);
    if (x === 0 || y === 0 || x === S - 1 || y === S - 1) c = INK;            // 1px ink frame
    else if (x === 1 || y === 1) c = shade(base, 1.3);                        // bevel: light top/left
    else if (x === S - 2 || y === S - 2) c = shade(base, 0.64);               // dark bottom/right
    p.set(x, y, c);
  });
  stamp(p, starMask(7.5, 8, 5.6, 2.5), hex('#fffbe6'), INK, hex('#c98a0c'));  // glyph with ink outline and shadow
}

export const TEXTURES: TexDef[] = [
  { name: 'cs_wool', paint: wool },
  { name: 'cs_sign', decal: true, paint: sign },
  { name: 'cs_fleece', paint: flat('#d9d2c3', '#cfc7b6') },   // near-flat: for small props
  { name: 'cs_ember', glow: 2, paint: flat('#ff9a3c', '#ffb85c') },
];
export const BLOCKS = { WOOL: 'cs_wool', SIGN: 'cs_sign', FLEECE: 'cs_fleece', EMBER: 'cs_ember',
  CRATE: ['cs_crate_top', 'cs_crate_side'],       // one texture, or [top, side, bottom?]
  ARROW: { top: 'cs_arrow', side: 'cs_wool', turns: true },   // a top that turns: see below
  LAMP: { top: 'cs_ember', light: { color: '#ff9a3c', reach: 8 } },   // gives off block light (section 2)
} satisfies Record<string, GameBlockDef>;

// game.ts, in the constructor, once, before anything uses the ids (useGameAssets is from '@voxelparty/sdk'):
const ids = useGameAssets(TEXTURES, BLOCKS);   // → { WOOL: 128, SIGN: 129, … }
  • TexDef: name, paint(p, r), glow? (0..3, self-lit), cutout? (transparent pixels: plants), decal? (true, or 'always' for the rare face that is the picture, like eyes).
  • Prefix every texture name with 2–3 letters from your id (cs_); names must be unique.
  • Budget: 160 game textures and 128 game blocks.
  • Px: set(x, y, rgb, a?), get, each(fn), noise(palette, r, jitter = 0.07), sprinkle(r, n, colors), tone(x, y, factor). Colour helpers: hex, pal, shade, mix, pick, ri. Glyphs: maskRows(['..##..', …], ox, oy), maskFrom(fn), starMask(cx, cy, outer, inner), stamp(p, mask, fill, outline, shadow?). Ready-made painters: flat(...hexes), planks, stone, cobble, dirt, and the palettes GRASS DIRT STONE SAND PATH WOOD BARK SNOW WATER.
  • textureDataURL('cs_sign') gives a PNG data URL for HUD icons (hudStat(icon, n)).
  • Pictures on the floor that point somewhere (speed pads, arrows, one-way signs): give the block turns: true and set each voxel's meta to turnTop(dx, dz), e.g. vol.set(x, 0, z, ids.ARROW, turnTop(1, 0)): the top edge of the picture as painted then faces +x. Paint it pointing up; as painted (meta 0) it faces +z, towards the usual camera.

8. The texture rules (condensed)

The world runs at 16 texels per unit. Small props are voxel models at 1/9–1/14 of a unit; if each tiny face showed a whole texture, props would shimmer with noise.

  1. Size voxel models with meshVolume(vol, { voxel: 1 / N, origin }). The mesher scales positions and texture coordinates, so one voxel shows about one texel. Never geometry.scale(...) a voxel geometry, and never shrink a voxel mesh with a constant mesh.scale at build time. Runtime scale for animation (pop-ins, squash, 0.5×–2×) and instance matrices is fine.
  2. One block as a cube: blockGeometry(id, size) (size a number or [x, y, z]). Particles: bitGeometry(id): every face shows the texture's middle texel, so pick a block whose middle is the colour you want (B.EMBER for fire, not the framed B.LANTERN). Bits throws if given a full-texture cube.
  3. Materials vs decals. A material tiles seamlessly (grass, stone, wool, metal, fire). A decal (decal: true) is a picture of one whole face: a glyph, an icon, a framed panel, a sign. If it would look wrong cut in half or tiled next to itself, it's a decal. Decals show whole on voxels ≥ 1/4 unit and are sampled like materials on smaller ones.
  4. Small props use near-flat materials (flat(...), ±5% jitter) and let the voxel layout draw the highlights. Don't use terrain textures on props for their look.
  5. Keep glow small on small props: one or two glowing voxels (a gem tip), not the whole surface.
  6. Texture look: tiling materials get subtle per-pixel noise (±5%); panels on block-sized voxels get a light bevel (top/left ×1.25–1.3, bottom/right ×0.62–0.75), a 1px ink frame and glyphs stamped with the #1d2340 outline.

9. Juice and UI

Every meaningful event gets a visual and a sound: a popup (+1, BUMP!, -3), an effect, a hop or spin on the character, and a shake for big hits. Spells, impacts, projectiles, auras, beams, buffs and deaths are Vfx effects (vfx.md). They work like sounds: the game's own, one per moment, sized to it, in its palette, in vfx.ts, seen in vp gallery. The VFX library is for learning and copying. The look is big and juicy: glow, bloom and soft light, with particles, debris and patterns as chunky pixels and voxels, like the 16×16 textures. Not a RingGeometry mesh and a few sparks. Pulse anything about to go off; pop new things in with an overshoot (ctx.tween.from(m.scale, { x: 0, y: 0, z: 0 }, { ease: 'outBack' }), or ease.outBack(k) in your own animation). Give hits weight with a short ctx.time.hitstop(0.05) plus a shake, and landings a Spring squash; fx.scaled(2) makes the same burst bigger for a bigger event. Never hand-write easing curves: ease has them all. One Fx covers most juice: five pools (spark, puff, dust, chip, confetti), each with its own block, cap, motions and floor, and false for the ones you don't use (new Fx(scene, mats.actor, { spark: ids.SHINE, chip: { block: B.COBBLE, cap: 160 }, dust: false })); fx.spawn(pool, pos, vel, life, size) for custom bursts. Bursts take a colour too (fx.chips(x, y, z, 10, 3, 0.14, team.color), or fx.spawn(...)?.color.set(c)): it multiplies the block's, so tint bits made from a light block. Numbers that fire many times a second (damage, gold) go through popups.number(x, y, z, -dmg, '#ff5a4a', { key }), not show: instanced glyphs, no canvas per number, and hits with the same key add up. Signatures for Fx, Popups, Bits, createParticles, textSprite, nameTag and the Ui components are in api.md sections 12–13. Prefer flow.hud.setStat and flow.hud.banner over custom UI; use Ui for meters, hints and signs. The top-right corner (var(--vp-corner-w) × var(--vp-corner-h)) is the page's, for its gear and session buttons: put your own UI anywhere else (api.md section 13).

10. Icons and insets

Icons: a model as a picture for your HTML. Tower portraits on a command card, a shop's wares, a character picker. engine.icon(object, opts?) renders any Object3D to a PNG data URL, lit like the world (the sun, the sky's bounce and reflections, the same voxel materials) and finished like the main view (tone mapping, colour), transparent around the model:

// A command card: one icon per tower, made once in the constructor (never every frame).
const card = document.createElement('div');
card.className = 'td-card';               // your CSS: bottom centre, pointer-events: auto (ui.el is click-through)
card.innerHTML = TOWERS.map((t) => {
  const url = engine.icon(new Mesh(t.geometry, engine.mats.actor), { size: 56, key: t.id });
  return `<button data-tower="${t.id}"><img src="${url}" width="56" height="56" alt="">${esc(t.name)}</button>`;
}).join('');
ui.el.append(card);
flow.hud.setStat(me, hudStat(engine.icon(coinMesh, { size: 24, key: 'coin' }), gold));   // a HUD chip's stat icon
  • IconOptions: size (CSS px, default 96, or [w, h] for a portrait; the image has up to 2 pixels per CSS px, so give the <img> its CSS size), yaw (degrees round the model: 0 looks at its +z side; default 30, a three-quarter view), pitch (degrees above the horizon, default 25), pad (margin as a fraction of the picture, default 0.08), fov (0, the default, is a flat orthographic picture; 20–40 for perspective), bounds (a Box3 in the model's own coordinates: frame just the head for a portrait), key (cache: the same key returns the first picture).
  • The model is framed to fit, as posed: its own position, rotation and scale count, its parents' don't. It may live in your scene (an Avatar's root, a placed tower) or nowhere; it's borrowed for the picture and put back. Build throwaway models from shared geometry.
  • The light turns with yaw, so every icon is lit the same way. No bloom, vignette or tilt-shift.
  • An icon draws on the spot (a few ms; the first also compiles shaders): make each once, in the constructor, or pass key, and keep the string.

Insets: a second view. A minimap, a rear-view mirror, a spectator picture-in-picture. List them in the view's insets (an ArenaStage has an empty array to push to; it's read every frame, like view.camera). The engine draws each over the main view, in its screen rect, finished like the main view (no tilt-shift, bloom or vignette):

// A round minimap in the bottom-left corner, following you.
this.map = new Minimap({ area: [W + 4, H + 4], rect: { left: 16, bottom: 16, width: 180, height: 180 }, round: true });
this.view.insets.push(this.map);
this.map.follow(this.avatars[me].root.position);   // or leave it fixed on the area
// your own HTML ring over it: position: fixed; left: 16px; bottom: 16px; 180px; border-radius: 50%; 3px ink border

// A rear-view mirror: any camera, any rect (a perspective camera's aspect is kept at the rect's).
const mirror = new PerspectiveCamera(50, 1, 0.5, 400);
this.view.insets.push({ camera: mirror, rect: { top: 16, left: 16, width: 240, height: 100 }, resolution: 0.5, every: 2 });
// every frame: mirror.position.copy(kart.position).add(up); mirror.lookAt(behind);
  • Inset: camera, rect (CSS px: one of left/right, one of top/bottom, a missing pair centres it; width, height), scene? (default the view's; a separate stylised map scene works too, transparent where it draws nothing), round? (clip to the circle inside the rect), resolution? (0.25..1: draw fewer pixels), every? (redraw every Nth frame, showing the last picture in between).
  • Minimap(opts): an orthographic top-down camera, north (−z) up. Options rect (default bottom left, 180 × 180), area ([x, z] world units kept in view, default [30, 30]), centre, heights ([bottom, top], default [-12, 12]: whatever's above top or below bottom is left off the map, so a roof, or the floor above the one you're on, goes by top), scene, round, resolution, every, rotation. Methods fit(w, d), centreOn(x, z), follow(point | null); set map.rect to move it.
  • map.rotation: which way is up, radians clockwise from north (0 north, π/2 east). A map that turns with the player: map.rotation = Math.PI - body.yaw every frame (first person: where you look is up); third person, the camera's heading.
  • Layers pick what each camera sees. Everything starts on layer 0, which every camera sees: blip.layers.set(1); map.camera.layers.enable(1) puts a marker only on the map (a big bright block over each player reads better than their model), and roof.layers.set(2); view.camera.layers.enable(2) keeps a roof off it. Keep lights on layer 0.
  • Cost: an inset draws its scene a second time (the view's shadows are reused, not redrawn). A 180 px map is cheap; for a big one or a busy scene, use resolution: 0.5 or every: 2.
  • Keep insets out of the page's top-right corner (section 9).

11. Performance

  • Target 60 fps on a laptop. Use InstancedMesh for anything repeated (coins, tiles, sheep): set count and setMatrixAt each frame, then instanceMatrix.needsUpdate = true. It works on every voxel material (mats.solid, actor, water, cross), like a plain Mesh. setColorAt(i, color) gives each copy its own colour, multiplying its textures (team colours, hit flashes, frozen tints) on every voxel material: one geometry, not one per colour. Set a colour on every slot when you build the mesh (so the shader is compiled with them from the start), and instanceColor.needsUpdate = true when they change.
  • No allocation in hot loops: module-level scratch const _v = new Vector3(), _m = new Matrix4().
  • Build geometries once; re-mesh volumes only when they change.
  • Keep draw calls under ~300 a frame (vp check --long counts them). Every mesh is one, and a moving mesh that casts a shadow is two. Shadows of things that hold still are drawn once and cached, so don't make scenery bob or sway by moving meshes: leave it still, or animate it in one InstancedMesh.
  • Put every mesh and material in the scene when the stage is built (hidden, or an InstancedMesh with count = 0), not mid-round: shaders compile before the first frame, and a material type that first shows up mid-round freezes the game while it compiles.
  • Lights (view.light): each one is extra shading on every pixel it reaches, so it allows 8. Make them all when the stage is built and switch them with intensity, never by adding, removing or hiding them: a change in the number of lights recompiles every shader and freezes the game. A light with a shadow draws the scene again every frame (a point light's six times), so at most 2, and a spot for a flashlight. Block light (section 2) is free a frame: use it for everything that stays put.
  • Free what you create in dispose(): this.view.dispose() frees everything in the scene except the shared materials; also call fx.dispose(), popups.dispose(), ui.dispose(), unsubscribe onEvent/HostSync, stop sustained sounds, clear timers.

SDK reference · vp docs water · water.md

Water: every water block, with depth, light and flow

Place B.WATER blocks in any volume, island or World and the SDK draws them as block-game water with depth and light: an animated picture of streaks in four shades, clear shallows you see the bed through, darker deep water, the sky at a grazing angle, pixel glints on the crests where they mirror the sun (the moon at night), a thin line of foam where the water meets the banks and anything floating, whitecaps lapping by the banks, light dancing on the shallow bed, currents carrying it all downstream, white water on rapids, falling water streaking down and churning where it lands, little splashes where rain falls. Nothing to set up: the depths, edges and flow come from your blocks, and the light from your stage's sky. Everything it shows is a setting you can change live, and style: 'classic' brings back the water from before this version.

Pixel style, like the rest: a flat surface with no smeared highlights, every pattern on the 16-texel-a-unit grid the block textures use, stepping 8 frames a second.

Contents

  1. What you get for free
  2. The look: engine.water.set
  3. Flow: a water block's meta
  4. Rivers: flowFromPath
  5. Falling water
  6. Floating things: field, ring
  7. The light: sky, night, rain, lamps
  8. Quality and phones
  9. Opting out: style: 'classic'
  10. How it works, and pitfalls

1. What you get for free

Any water block meshed by meshVolume, addVoxelMeshes, an Island or a WorldView is drawn with the shared mats.water, and its volume's field is baked when it's meshed: per column, how deep the water is (to the bed; water with nothing under it counts as bottomless sea), how far it is from the edge (banks, rocks, anything standing in it), which way it flows (the blocks' meta, below) and where its surface is. The blocks under the water (mats.solid) take the water's colour the deeper they are and get caustics by day.

  • A World on screen (WorldView) keeps its field up to date: when blocks change, only the columns under the chunks that changed are baked again, in the same frame's budget as its re-meshing. Pour a lake, dig a channel, drop a stone in: the foam follows.
  • Meshes can move, be instanced or batched: each finds its own field in its own space. Up to 32 volumes with water at once (dispose meshes you don't show any more).
  • Published games draw with the site's runtime, so they get this without being published again.

2. The look: engine.water.set

ctx.engine.water is the one water every mesh uses. set changes any settings, live (the others keep their values); the platform resets them after each game.

create(ctx) {
  ctx.engine.water.set({ depthTint: 'tropical', flow: 'E', foam: 0.6 });
}
Setting Default
depthTint 'lake' the colours by depth: 'lake', 'pond' (teal), 'tropical' (clear turquoise), 'swamp' (murky green), 'murky' (grey-green); or yours: { shallow, mid?, deep } (CSS colours)
clarity the preset's 0 murky … 1 crystal: how deep you see the bed ('lake' 0.45, 'pond' 0.75, 'tropical' 0.95)
foam 1 0 none … 1: the edge line, whitecaps, rings, white water, churn
caustics 1 0 none … 1: light on the shallow bed by day
glitter 1 0 none … 1: the glints of sun, moon and lamps on the crests
flow 'none' a current wherever the cells have none: 'N', 'E', 'S', 'W' (a gentle one) or [x, z] units a second
rain 'auto' splashes where rain lands: 0..1, or 'auto' (the mood's rain: storm rains; or light({ rain }))
waves 0.035 how high the surface bobs, units
quality 'auto' 'low', 'high', or 'auto': the player's graphics setting (section 8)
style 'default' 'classic': the water before SDK 3.13, exactly (section 9)

A game that tints its water blocks (a meshVolume tint) keeps the tint: it colours the depth colours. The presets are in WATER_PRESETS.

3. Flow: a water block's meta

A water cell's meta byte is its flow (/core has the helpers):

Bits
0–2 direction: 0 N (−z), 1 NE, 2 E (+x), 3 SE, 4 S (+z), 5 SW, 6 W, 7 NW
3–5 speed level: 0 still … 7, FLOW_STEP (0.3 units a second) each
6 FLOW_FALLS: falling water lands here (churning foam)
import { B, flowMeta, flowOf, metaOf } from '@voxelparty/sdk/core';

world.set(x, y, z, B.WATER, flowMeta(2, 4));    // flowing east, 1.2 units a second
vol.set(x, y, z, B.WATER, metaOf(0.9, -0.3));   // the nearest byte to a vector
const [vx, vz] = flowOf(world.metaAt(x, y, z)); // push a boat along with it

The flow is read from the topmost water cell of each column. Fast water (about 0.75 units a second and up) breaks into white water; faster water has more whitecaps. The picture and flecks of froth drift with it. A water block's meta isn't used for anything else.

4. Rivers: flowFromPath

For a river, give its centre line from upstream to downstream, each point [x, z, half-width, speed] in world units, and get a flow byte per column: fastest mid-channel, slack by the banks.

import { flowFromPath, paintFlow } from '@voxelparty/sdk/core';

const path = [[-30, 0, 3, 0.8], [0, 6, 2, 1.6], [30, -4, 4, 0.6]] as const;   // a fast narrow bend in the middle
const flow = flowFromPath(vol, origin, path);      // origin: where cell (0, 0, 0)'s corner is
paintFlow(vol, flow);                              // into the water cells' meta, then mesh
// or, without touching the meta:
ctx.engine.water.field(vol, { at: origin, flow });

For a World, pass [world.x0, world.y0, world.z0] and world.cell (the last argument), and set each water cell with its byte (world.set(x, y, z, B.WATER, flow[x + z * world.nx])), so the change is synced and drawn; paintFlow is for a Volume before it's meshed.

5. Falling water

The mesher tells water's side faces apart: water with more water above it, or the edge of deep water with nothing beside it below, is falling (it streaks downwards); the side of a pool standing on a step is the water's own edge (barely there). The field finds the waterfalls itself (a column of water standing two or more cells over open air beside it) and churns foam where they land. To add churn where your game says water lands, set FLOW_FALLS in those cells' meta, or give world points with engine.water.feet([[x, z, radius], …]) (at most 4).

6. Floating things: field, ring

Things standing in the water (rocks, posts) are edges already: they're blocks. Things floating on it aren't: tell the water about them, and their edges foam like a bank's.

// Before meshing (or after: it bakes again). World units; `at` is where the volume's cell (0,0,0) is.
ctx.engine.water.field(vol, {
  at: [ox, oy, oz],
  discs: lilies.map((l) => [l.x, l.z, 0.42]),          // lily pads: [x, z, radius]
  bars: logs.map((l) => [l.x0, l.z0, l.x1, l.z1, l.r]),  // logs: [x0, z0, x1, z1, radius]
});
const [vx, vz] = ctx.engine.water.flowAt(vol, x, z);   // the current there, for drifting things

// Every frame: something bobbing (a float, a duck, a fish thrashing): a ring of foam round it.
ctx.engine.water.ring(float.x, float.z, 0.15);          // at most 16 a frame

7. The light: sky, night, rain, lamps

The water reads its light from the scene it's drawn in, every frame: the sun (direction and colour), the fog's colour (the sky near the horizon), the stage's mood (the sky overhead, and rain: the storm mood rains 0.6; add rain to your own moods), and how dark it is (a dark, cold sky is night: the glints of sun become the moon's). With block light (glowing blocks, stage.blockLight, a WorldView with light) the crests glint with it at night.

  • Lamps: engine.water.lamps([[x, y, z], …]) names lights by the water whose light glints on it at night (at most 12, nearest first); with none named, block light glints instead.
  • Your own sky: a game with its own day, night and weather gives the light itself, every frame; what it leaves out is still worked out:
ctx.engine.water.light({ day: 1 - night, night, rain, sun: toSun, sunColor, sky: fogColour, skyTop, lamps: night, moon: toMoon });
ctx.engine.water.light(null);    // back to the scene's

engine.water.lit has the light it used last.

8. Quality and phones

One shader draws all water; its cost is a few texture reads and some hashing per water pixel. quality: 'auto' follows ctx.engine.quality, the player's graphics setting ('low' | 'medium' | 'high', automatic unless they picked one: low on weak GPUs and most phones). On 'low' the water drops the lamps' glints, the rain splashes, the flecks of froth and the second layer of caustics. ctx.engine.quality is yours to read too (fewer particles on 'low').

9. Opting out: style: 'classic'

ctx.engine.water.set({ style: 'classic' });   // the water before this SDK: a bobbing, scrolling, see-through texture

Use it where the new look fights the game: a see-through pool whose floor is the gameplay, ice, or water that should read as a flat colour from far away. It's live, like every setting.

10. How it works, and pitfalls

  • Every field lives in one texture (an atlas); a water or solid vertex carries its field's number in the mesher's aux attribute. Only meshes made by meshVolume (and so addVoxelMeshes, Island) or WorldView have one: water geometry you build by hand draws as open water of middling depth, with no edges.
  • A volume's field is baked when it's meshed. Changed the blocks of a Volume? Mesh it again (a WorldView does this by itself).
  • Shapes (discs, bars) and flowAt are in world units from at; for a World, its origin.
  • Water with nothing under it (a single layer, a sea at the bottom of the volume) is bottomless: as deep and dark as it gets. Put a bed under water you want to look shallow.
  • The field is 2 texels a unit: detail smaller than half a unit (a one-voxel gap) blurs.
  • More than 32 volumes with water on screen at once: the rest draw as open water.

SDK reference · vp docs sound · sound.md

Sound and music

Everything is synthesized with WebAudio: no sample files. A sound is plain data (a Patch), a theme is plain data (a Song). The runtime owns the audio context and the mixer, so the players' volume settings keep working. Match the game's mood (toy-like and bouncy for a party game, low and sparse for a scary one); someone with their eyes shut should still be able to follow the game.

Contents

  1. What Flow already plays
  2. sounds.ts and wiring
  3. The Patch format
  4. Recipes
  5. The Song format and the theme
  6. Checking your work: sounds.test.ts

1. What Flow already plays

Don't duplicate these: the theme (soft under the title card, ducked for 3-2-1, full at GO), the countdown ticks and GO, the finale (a "hurry" sting, then the theme 12% faster for the last 10 s, and ticks on the last 5), the FINISH hit or TIME UP whistle, the results fanfare or sad trombone, and a whoosh on every flow.hud.banner. Use flow.musicVolume(0, 0.5) for a tense silence and flow.intensify() to kick into the fast version early (sudden death, a frenzy).

2. sounds.ts and wiring

// sounds.ts: plain data, from the headless core, so bun can load it in tests
import { INSTRUMENTS, SFX, type Patch, type Song, type SoundDef } from '@voxelparty/sdk/core';
export const SOUNDS = {
  catch: { … } as Patch,
  rattle: (rnd) => ({ … }),     // a factory: a fresh random patch on every play
  land: SFX.land,               // reusing a library sound is fine; a tuned variant is better
} satisfies Record<string, SoundDef>;
export const MUSIC: Song = { … };   // pass it to defineGame({ music: MUSIC })
  • Aim for 8–16 sounds covering every meaningful event, named for what happens (sheepJump, countWrong), not how they sound.
  • sounds.ts imports only from @voxelparty/sdk/core and your own sound files: no game code, no @voxelparty/sdk main entry (it throws under bun, and sounds.test.ts imports this file). The data and types are all there: INSTRUMENTS, SFX, SONGS, Patch, Layer, Song, Track, SoundDef, PlayOpts, midi, mtof, noteFreq, noteName, parsePattern, checkSong. Playing them (sound, Sfx) is game.ts's job, from the main entry.
  • Only presentation code plays sound (game.ts, effects), never rules.ts or bot.ts. Hook sounds where the visuals already react to an event, so every client hears each event once.
  • Yours vs theirs: your own actions full volume; rivals' quieter (0.5–0.7) and panned by where they are. Sfx does it:
    const sfx = new Sfx({ you: seats.you, halfWidth: 8, rival: 0.6, rivalPitch: -2, live: () => flow.live });
    sfx.for(SOUNDS.jump, i, f.x);                     // player i's sound
    sfx.at(SOUNDS.land, x);                           // a world sound, panned
    sound.play(SOUNDS.win, { vol: 0.8, pan: sfx.pan(x) });
    
    First person and chase cameras hear from the camera instead: ears (a three.js camera works as is) and at3d, quieter with distance and panned by where the sound is left or right of the view:
    const sfx = new Sfx({ you: seats.you, ears: () => this.view.camera, falloff: 0.07, live: () => flow.live });
    sfx.at3d(SOUNDS.boom, x, y, z);                   // half as loud 14 units away; too faint plays nothing
    sfx.at3d(SOUNDS.shot, x, y, z, { vol: 0.8, pitch: -2 });
    sfx.at3d(SOUNDS.creak, x, y, z, { near: 1, reach: 12 });   // full within 1 unit, silent from 12
    const { vol, pan } = hear(camera, x, y, z);       // or just the numbers
    
    new Sfx({ near, reach }) makes that curve the default for at3d and loops. A top-down or chase camera floats far above the hero: listener: () => this.view.rig.listener() measures distance from what the camera looks at (pan still goes by the camera). Loops do that by themselves. Footsteps and other constant sounds: local player only, or heavily throttled.
  • Sustained sounds (sustain: true: fuses, wind, engines) return a Voice. Keep the handle, voice.bend(semitones, glide) to retune it, voice.set({ vol, pan }, glide) to move its loudness and pan, and voice.stop(release) when it should end, at the finish, and in dispose(). Nothing may keep playing after the game closes.
  • Sounds that keep going at a place (a campfire, a waterfall, a kart's engine, a generator's hum): sfx.loop plays a sustained patch there, heard from the ears like at3d, and sfx.update() every frame keeps its loudness and pan right as you move and turn. Out of earshot, or while the live gate is shut, it goes quiet and costs nothing, and fades back in when it's near.
    • Levelled for you. Each loop patch is measured once (steady loudness, K-weighted like LUFS) and brought to one quiet ambient level, about 8 dB under a typical hit. So vol: 1 is "a normal ambient bed" whatever the patch's layers add up to: a waterfall, a fire and a hum at vol: 1 all sit at the same loudness. Use vol for intent: 0.5 a faint hum, 1.5 a big bonfire, 2 an engine you're driving. Don't make loop patches loud to be heard; that's what vol is for.
    • Distance: full within near (default 2), fading, silent at reach (default 16: a campfire heard within 10–15 units). A waterfall or a stampede carries further (reach: 40), wind everywhere is falloff: 0. The older falloff still works (the same curve up close, silent from 3 / falloff).
    • Stacking: only the nearest four copies of one patch play (together at most 3 dB over the nearest), and all loops together are held under a ceiling, so twenty torches are a bed, not a wall of noise. Place as many as the map wants.
    • They play on the ambience channel (the player's Ambience slider, inside Effects).
    const fire = this.sfx.loop(SOUNDS.crackle, fx, fy, fz, { vol: 1.5, reach: 14 });  // a big fire, heard near it
    const falls = this.sfx.loop(SOUNDS.falls, wx, wy, wz, { near: 4, reach: 40 });   // a waterfall carries
    const motor = this.sfx.loop(SOUNDS.engine, 0, 0, 0, { vol: 2 });
    motor.follow = kart.position;                     // or set motor.x / y / z yourself
    // every frame:
    motor.bend(kart.speed / 5);                       // revs
    this.sfx.update();
    // done with it (the fire goes out; dispose() stops them all with sfx.stopLoops()):
    fire.stop(1.2);
    
  • sound.play returns null while audio is asleep (no user gesture yet, a hidden tab), so use ?. on the handle.
  • Don't touch sound.muted or sound.setVolume: the platform owns the mix.
  • vp check can't hear, but it measures: its sound log prints the game's typical sound and each loop at its loudest as heard, in dB, and warns when a loop comes within 3 dB of the typical sound (lower that loop's vol or reach).

sound.play(def, opts) options: pitch (semitones), note ('E5' or MIDI), freq, vol, pan (-1..1), dur (gate seconds), delay (seconds from now), bus, force (skip cooldown and voice limits). Also sound.panFor(offset, halfWidth) and sound.duckMusic(amount, seconds).

3. The Patch format

interface Patch {
  layers: Layer[];          // mixed together; most good sounds have 2–4
  vol?: number;             // default 1; gameplay sounds sit around 0.1–0.5
  root?: number;            // the pitch layers are written at (for play({ note }))
  vary?: number;            // random ± cents per play: use it on anything that repeats
  varyVol?: number;         // random volume variation 0..1
  reverb?: number;          // 0..1 send; 0.1–0.3 for gameplay, more for big moments
  echo?: { time, feedback, mix, tone? };
  sustain?: boolean;        // hold until voice.stop()
  maxVoices?: number;       // default 8; the oldest copy is stolen
  cooldown?: number;        // ignore replays closer than this (s); default 0.025
  bus?: 'sfx' | 'ui' | 'music' | 'ambience';
}
interface Layer {
  wave: 'sine' | 'square' | 'sawtooth' | 'triangle' | 'noise' | 'pink' | 'brown';
  freq?: number; to?: number; sweep?: number; curve?: 'exp' | 'lin';   // pitch glide
  steps?: number[]; stepTime?: number;                                 // arpeggio in semitones
  vibrato?: { rate, depth /* cents */, delay? };
  fm?: { ratio, index, decay? };                                       // bells, zaps, metal
  voices?: number; spread?: number; detune?: number;                   // thick detuned stacks
  rate?: number;                                                       // noise playback rate (lower = darker)
  env?: { a?, d?, s?, r? };                                            // defaults a 0.004, d 0.12, s 0, r 0.06
  dur?: number; lock?: boolean; at?: number; vol?: number; pan?: number;
  filter?: { type: BiquadFilterType, freq, to?, time?, q?, track? };
  drive?: number;           // soft-clip grit, 1..50
  crush?: number;           // bitcrush levels, 4..64
}

Design by layers: a transient (a noise click or a sharp FM attack) + a body (a pitched tone or a sweep) + optionally a tail (reverb, echo). Pitch carries meaning: climb it on streaks ({ pitch: Math.min(12, streak) }), bright for rewards, low for danger, a touch lower for rivals. Nothing should be much louder than SFX.explosion (vol 0.85, three layers).

Library sounds to reuse or compare against, in SFX: coin, star, itemGet, whoosh, jump, doubleJump, land, footstep, dash, fall, hurt, powerup, bonk, pop, explosion, splat, crumble, bang, bellStrike, heartbeat, boo, creak, treasure, applause, the loops fuse, wind, charge, and UI sounds (click, confirm, deny, tick, …).

4. Recipes

/** Pickup: a two-step square "bling" with a glassy FM sparkle. Play with { pitch: streakStep }. */
pickup: { vol: 0.34, vary: 12, maxVoices: 6, cooldown: 0.03, reverb: 0.12, layers: [
  { wave: 'noise', env: { a: 0.001, d: 0.012, s: 0 }, vol: 0.25, filter: { type: 'highpass', freq: 6000 } },
  { wave: 'square', freq: 1047, steps: [0, 7], stepTime: 0.05, env: { a: 0.001, d: 0.28, s: 0 }, dur: 0.22, vol: 0.7, filter: { type: 'lowpass', freq: 6000 } },
  { wave: 'sine', at: 0.05, freq: 2093, fm: { ratio: 2, index: 0.8, decay: 0.08 }, env: { a: 0.001, d: 0.3, s: 0 }, vol: 0.35 },
] },

/** Hop: a quick rising square chirp over a sine. */
hop: { vol: 0.3, vary: 60, layers: [
  { wave: 'square', freq: 250, to: 700, sweep: 0.12, env: { a: 0.002, d: 0.16, s: 0 }, vol: 0.35, filter: { type: 'lowpass', freq: 2600 } },
  { wave: 'sine', freq: 500, to: 1100, sweep: 0.1, env: { a: 0.002, d: 0.14, s: 0 } },
] },

/** Heavy landing: a sub thud, a gravelly crunch and a metal clang. */
slam: { vol: 0.55, vary: 60, reverb: 0.15, maxVoices: 2, layers: [
  { wave: 'sine', freq: 110, to: 42, sweep: 0.16, env: { a: 0.001, d: 0.3, s: 0 } },
  { wave: 'brown', env: { a: 0.001, d: 0.15, s: 0 }, vol: 0.7, drive: 3, filter: { type: 'lowpass', freq: 700 } },
  { wave: 'sine', freq: 520, fm: { ratio: 1.41, index: 3, decay: 0.1 }, env: { a: 0.001, d: 0.35, s: 0 }, vol: 0.22 },
] },

/** Dash / swoosh: filtered noise sweeping up. */
dash: { vol: 0.3, vary: 40, layers: [
  { wave: 'noise', env: { a: 0.02, d: 0.18, s: 0 }, filter: { type: 'bandpass', freq: 600, to: 3200, time: 0.18, q: 1.2 } },
] },

/** Wrong / fault: a buzzy falling two-note "bwomp". */
wrong: { vol: 0.3, layers: [
  { wave: 'sawtooth', freq: 220, steps: [0, -5], stepTime: 0.14, env: { a: 0.005, d: 0.3, s: 0.2, r: 0.1 }, dur: 0.28, filter: { type: 'lowpass', freq: 1200 } },
] },

/** Rattle: random clicks, new every play (a factory). */
rattle: (rnd) => ({ vol: 0.4, maxVoices: 4, layers: Array.from({ length: 6 }, () => ({
  wave: 'noise' as const, at: rnd() * 0.25, env: { a: 0.001, d: 0.02 + rnd() * 0.02, s: 0 }, vol: 0.4 + rnd() * 0.4,
  filter: { type: 'bandpass' as const, freq: 1500 + rnd() * 3000, q: 5 } })) }),

/** A fuse: sustained crackle. const v = sound.play(SOUNDS.fuse); v?.bend(5, 0.4) as it gets frantic; v?.stop() */
fuse: { vol: 0.25, sustain: true, maxVoices: 2, layers: [
  { wave: 'noise', env: { a: 0.05, s: 1, r: 0.1 }, filter: { type: 'bandpass', freq: 3500, q: 2 }, vibrato: { rate: 13, depth: 900 } },
] },

Or reuse and tune: SFX.jump, { ...SFX.pop, vol: 0.2 }.

5. The Song format and the theme

interface Song { bpm: number; steps?: number /* per beat, default 4 */; loop?: boolean; swing?: number /* 0..0.5 */; vol?: number; tracks: Track[] }
interface Track { patch: SoundDef; pattern: string; vol?: number; transpose?: number; pan?: number; gate?: number /* 0..1, default 0.9 */ }

The pattern language, one token per sixteenth note:

C5  F#4  Bb3      a note          C4+E4+G4   a chord
x  X              a drum hit (X accented)
-                 hold the previous note one more step
.                 rest            |          a bar line (ignored; for reading)
(C5 E5 G5 .)*4    repeat a group

Each track loops over its own length, so a 1-bar drum pattern runs under an 8-bar melody. Every track must be a whole number of bars (16 steps per bar at the default 4 steps per beat) or the parts drift apart.

Instruments in INSTRUMENTS: marimba, bell, lead, keys (electric piano), flute, brass, trombone, stab, pad, bass, softBass, and drums kick, snare, clap, rim, hat, openHat, crash, shaker, vinyl (record crackle). Custom instruments are just patches (a steel drum, an organ, a chip lead).

Writing an original theme that sounds like the game plays:

  • 8–16 bars, ideally an A and a B section so it doesn't wear thin over a minute;
  • a real progression for the mood (I–V–vi–IV bright; vi–IV–I–V wistful; i–bVII–bVI–V spooky or tense; a I–IV–V calypso for beaches), a melody built from a motif (repeat it, vary it, answer it), a bass that bounces or walks, drums that fit the genre;
  • 100–140 bpm; remember Flow speeds it up 12% for the finale;
  • balance: melody vol 0.8–1, chords and bass below, drums punchy but not dominant.

A small example (C major, 8 bars as two 4-bar halves):

export const MUSIC: Song = {
  bpm: 120, swing: 0.06,
  tracks: [
    { patch: INSTRUMENTS.marimba, vol: 0.9, pattern:
      'C5 . E5 . G5 . E5 . A5 . G5 . E5 . D5 . | C5 . E5 . G5 . C6 . B5 . G5 . A5 . G5 . | ' +
      'F5 . A5 . C6 . A5 . G5 . E5 . C5 . E5 . | D5 . F5 . A5 . G5 - - - . . . . . . | ' +
      'C5 . E5 . G5 . E5 . A5 . G5 . E5 . D5 . | C5 . E5 . G5 . C6 . B5 . G5 . A5 . G5 . | ' +
      'F5 . A5 . C6 . A5 . G5 . F5 . E5 . D5 . | C5 - - - . . . . . . . . . . . . ' },
    { patch: INSTRUMENTS.bass, gate: 0.5, vol: 0.85, pattern:
      '(C3 . . . C3 . . . A2 . . . A2 . . . | F2 . . . F2 . . . G2 . . . G2 . . .)*4' },
    { patch: INSTRUMENTS.kick, vol: 0.6, pattern: 'X . . . x . . . X . . . x . . .' },
    { patch: INSTRUMENTS.snare, vol: 0.5, pattern: '. . . . X . . . . . . . X . . .' },
    { patch: INSTRUMENTS.shaker, pan: 0.3, vol: 0.7, pattern: '(x x X x)*4' },
  ],
};

(Melody 8 bars, bass 2 bars × 4 = 8, drums 1 bar each.) The library themes in SONGS are there to study or borrow a drum track from; write the game's own theme. (The procedural composer is in /experimental since 2.0: don't build on it.)

6. Checking your work: sounds.test.ts

Because sounds.ts imports only /core, tests can load it. The template ships sounds.test.ts; keep it, and run it with bunx vp test (and vp check runs it too):

import { expect, test } from 'bun:test';
import { checkSong } from '@voxelparty/sdk/core';
import { MUSIC } from './sounds';

// Every track loops over its own length: one that isn't a whole number of bars drifts off the beat.
test('the theme loops cleanly', () => {
  expect(checkSong(MUSIC)).toEqual([]);
});

checkSong(song, beatsPerBar = 4) returns a SongIssue[] ({ track, steps, problem }): each track of a looping song whose length isn't a whole number of bars, and each pattern that doesn't parse (a bad note like H5, a broken ( … )*n). Fix the pattern, not the test: pad a short track with . rests or finish the phrase. Several songs (a calm theme and a frenzy theme)? Check each one. parsePattern(pattern) → { events, length } is there for checks of your own (the melody is 8 bars: length === 128).

Write one bar per | group, 16 tokens each, so the counts are easy to read. vp check runs muted, so you can't hear anything: ask the user to listen in vp dev.

Music that has to line up with gameplay (rhythm games)

Flow starts music under the title card, so it isn't in step with flow.clock. For a beat you play to: leave music out of defineGame (or fade Flow's copy with flow.musicVolume(0)), and at GO start your own copy on the chart's clock:

const stepDur = 60 / MUSIC.bpm / (MUSIC.steps ?? 4);          // seconds per step
this.song = sound.playSong(MUSIC, { offset: flow.clock / stepDur });   // offset counts steps
// dispose(): this.song?.stop()

Judge hits on the press time against the chart, not on the frame the press was noticed, and judge every press (two can land in one frame):

for (const p of input.pressTimes('action')) {
  const beat = ((p.at - link.startAt) / 1000) / stepDur;   // `at` is on the link clock, like flow.clock
  judge(beat);
}

SDK reference · vp docs vfx · vfx.md

Visual effects: spells, impacts, auras, beams

Vfx is the effect engine, the way sound is the sound engine. An effect is plain data (an Effect): a few layers played together. Each layer is particles, voxel cubes, a ring or ground mark, a column, a dome or a beam, drawn by shaders, with no textures.

Effects work like sounds. Every game makes its own effects for its own moments, in its own palette, in vfx.ts, the way it writes its own sounds.ts. The SDK's library (VFX: fire, frost, lightning, holy, arcane, poison, hits, pickups) is there to learn from and copy. Don't use it as a default: grabbing a key off a table is not a frostNova.

A flat RingGeometry mesh plus a few Bits sparks looks like placeholder art, and so does its shinier cousin, a white shock ring on everything. A frost nova is ice creeping out to a jagged crystal edge, voxel ice chunks flying, shards jutting up and cold mist: each moment has its own silhouette.

Contents

  1. Playing effects
  2. vfx.ts: your game's effects, and the rules of thumb
  3. The look: chunky pixels and voxels, with real glow
  4. The layers
  5. Power: code any spell (your own pixel art, effects as code, nesting and flights, formations, onDeath, GLSL, spawn, themes)
  6. Recipes
  7. Why it's cheap, and stays cheap
  8. Seeing effects: vp gallery
  9. Pitfalls

1. Playing effects

import { Vfx } from '@voxelparty/sdk';
import { EFFECTS } from './vfx';

this.vfx = new Vfx(this.view);                     // a GameView (scene + camera) or just a scene
// every frame, with game time (a hitstop freezes effects too):
this.vfx.update(dt);
// in dispose():
this.vfx.dispose();

this.vfx.play(EFFECTS.keyGet, { at: key.position });            // fire and forget
this.vfx.play(EFFECTS.bombBlast, { at: pos, scale: 1.5 });       // bigger
this.vfx.play(EFFECTS.towerBuilt, { at: pos, color: team.color });  // recoloured
this.vfx.play(EFFECTS.teslaZap, { at: tower, to: creep });       // beams go from `at` to `to`
this.vfx.play(EFFECTS.thunder, { at: target, to: 'sky' });      // a strike from 10 units above
this.vfx.play(EFFECTS.cleave, { at: hero, dir: facing });        // `forward` layers follow `dir`

play returns a handle. Effects that loop (auras, projectiles, channels, shields) run until you stop them:

const aura = this.vfx.play(EFFECTS.onFire, { follow: unit.root });  // follows an Object3D (or () => pos)
aura.stop();                                                     // it fades out; nothing new starts

const ball = this.vfx.play(EFFECTS.fireball, { at: from });      // a projectile with a trail
ball.move(p);                                                    // every frame: the trail fills in between
ball.stop(); this.vfx.play(EFFECTS.fireballHit, { at: p });      // on impact

const beam = this.vfx.play(EFFECTS.lifeDrain, { at: caster, to: victim });
beam.move(casterPos, victimPos);                                 // or followTo: victim.root

Loops that belong to game state (a status on a unit, a zone on the ground) are easiest with keep: ask for them every frame, and sweep() stops the ones you didn't ask for, so a unit that dies or loses the status loses its effect with no handles to track:

for (const u of units) if (u.burning) this.vfx.keep(`burn:${u.id}`, EFFECTS.onFire, { follow: u.root });
for (const z of zones) this.vfx.keep(`zone:${z.id}`, EFFECTS.blizzard, { at: z.pos, scale: z.radius / 3 });
this.vfx.sweep();                                                // once a frame, after the keeps

PlayOptions: at, to, dir, color, scale, ground (where cubes land; default at's height), follow, followTo. The handle has move(at, to?), aim(dir), stop() and alive. new Vfx(view, { scale: 1.5 }) scales every effect for a far or top-down camera. vfx.clear() removes everything (a new round). vfx.stats() says how many things are alive and how many draw calls they take.

Effects are only visual: play them on every client from the same events you already use for sounds (a HostSync event, a Lockstep tick's outcome, your own cues). Nothing needs syncing.

2. vfx.ts: your game's effects, and the rules of thumb

Keep effects next to sounds.ts, as plain data from the headless core:

// vfx.ts
import { VFX, type Effect } from '@voxelparty/sdk/core';

export const EFFECTS = {
  /** The Frost Mage's Q: a cone of ice shards. */
  frostCone: {
    layers: [
      { kind: 'particles', shape: 'shard', count: 30, life: [0.4, 0.7], size: [0.25, 0.4],
        speed: [8, 12], dir: 'forward', spread: 25, drag: 2, spin: 5, color: ['#ffffff', '#8fe8ff', '#3fa0ff00'] },
      { kind: 'particles', shape: 'smoke', count: 8, life: 1, size: [0.8, 1.2], speed: [3, 5],
        dir: 'forward', spread: 30, drag: 3, color: '#dff6ffb0', alpha: [0.6, 0] },
    ],
  },
  /** The shrine's blessing: started from the library's heal, then made ours (gold, our pixel size, slower). */
  blessing: { ...VFX.heal, scale: 1.3 },   // a start: then change its colours, shapes and timing
} satisfies Record<string, Effect>;

Rules of thumb:

  1. Size the effect to the moment. A pickup is a tiny glint of 0.2–0.4 s in the object's own colour. A hit is a quick flash and a spray. An ultimate is big and layered, with a ground mark that lingers. If everything is huge, nothing reads.
  2. One effect per meaningful game moment, named for the moment: keyGet, doorUnlock, towerBuilt, bossEnraged. Name it for what happens, not for a spell school (fireNova). Two moments that feel different get two effects.
  3. Give each moment its own silhouette. Ask what this thing looks like when it happens: a cannonball digs a crater and throws dirt; a slam cracks the ground; a claw leaves three claw marks; a splash throws drops and ripples; a crit is a burst star; a heal rises in crosses. Rings are seasoning, not the dish. A shock ring belongs to a real wave (a nova, a stomp, a blast's shockwave), coloured for the moment and thin. A plain white shock ring on everything is the laziest effect there is: vp check warns when a game's effects lean on it (checkEffectSet), and when more than half of them carry a shock or soft ring at all.
  4. Write it for this game. Picture the moment, then build it from layers, and reach past them when you need to (§5): draw the game's own particles as pixel art (its key, its coin, its rune), stamp its sigil on the ground, chain a charge into a flight into an impact, spell a word in sparks. A library effect or a sketchEffect is only a starting point to rewrite. Put the game's palette on the whole set with themeEffects. Audition every effect and change it until it belongs to this game.
  • Name the export EFFECTS. vp check runs checkEffect on every effect vfx.ts exports: typos, bad colours and layers that make nothing fail the check, and floods are warned about. vp gallery finds them there too.
  • Aim for an effect for every ability, hit, death, pickup and level-up, the way every event has a sound.
  • Give your game its own look: a palette of 3–5 colours used by all its effects, and a shape vocabulary (rune and bolt read as magic, shard as ice and glass, cubes as anything physical).

3. The look: chunky pixels and voxels, with real glow

Make spells big and juicy, with glow, bloom and soft light, but the particles, debris and patterns read as chunky pixels and voxels: pixel sparkles, voxel chunks, and ground runes as crisp as the texels. That's the particle version of the 16×16 texture rule. Soft is for light, smoke and mist: a flash, a glow pool on the ground, a cloud.

  • Particles and patterns draw on the blocks' texel grid by default: pixel is texels a unit (16, the blocks' own; 8 is chunkier; 0 is smooth). Light draws smooth by default (glows, light pools, light shafts, lightning and laser cores) and so does smoke. Leave the rest pixel.
  • Your own pixel art (§5) is always pixel-exact: its grid is the pixels.
  • Debris is cubes: voxel chunks that tumble and land, in the colour of what broke.
  • Big soft light belongs under the pixels, not instead of them: a disc ring as a glow pool, a glow dome, a short flash. The pixels carry the shape.
  • Impacts leave the ground changed, not ringed: crack (dark for stone and earth; glowing with blend: 'add' for lava, lightning or holy light), crater, scorch, splat, frost, claw; and throw the material: cubes or chunk particles in the colour of what was hit.
  • A wave you want to show goes coloured and patterned (ripple dashes, runes, dash) or thin (shock with width 0.1–0.25), never a fat white halo.

4. The layers

Every layer has at (a delay in s), life (s), color (a ramp over its life: '#fff' or ['#fff', '#ffd23f', '#ff4b0000'], #rrggbbaa for alpha), alpha (a curve, default [1, 1, 1, 0]), glow (brightness; over 1 blooms, default 1.6 for glowing layers), blend ('add' glows, 'alpha' covers: smoke, dust, goo) and tint (how much it takes the play's color, default 1; 0 keeps a white-hot core or black smoke as they are).

Numbers: a VfxRange is n or [min, max] (picked per particle). A VfxCurve is n or keyframes spread over the life ([0, 1, 1, 0] pops in, holds, shrinks away).

kind what its own fields
particles sprites drawn in the shader. The shape is one of glow dot pixel star flare ring smoke shard flame plus heart spark bubble leaf rune bolt skull note z swirl drop snow arrow chunk (a knobbly bit of rock, wood or bone, lighter than a cube), or your own pixel art (§5) shape, spin, stretch (streaks along the motion: sparks, rain), glsl (§5)
cubes voxel chunks that tumble and land (debris, coins, splinters) spin, floor (default true: they land on ground)
ring flat rings and ground marks. The style is one of shock soft disc (a pool of light) runes (a magic circle) dash spikes scorch frost splat target slash; impact marks crack (branching cracks racing out), burst (a spiky POW star), crater (a bowl, a rim, thrown debris), ripple (rings of broken dashes: water, sound, a pulse), claw (three raking marks); or your own pixel art (§5) radius (curve), width (a share of the radius), face: 'forward' (stands up), art
effect another effect played as a part of this one: repeated, turned, offset, flown to to (§5) effect, count, every, offset, rotate, scale, jitter, follow, travel, arc, then
pillar columns. The style is one of beam flame swirl (tornado) rays wall radius, height (curves), taper (0 a cone, 2 a funnel)
dome spheres. The style is one of shield blast (a churning fireball) bubble glow radius (curve), squash
beam at → to. The style is one of lightning laser chain drain rope width (curve), jitter, arc

Particles and cubes: how many and how they move.

field meaning
count / rate + dur / perMeter a burst; a stream per second (for dur s, or until stopped); a trail per unit moved
from, radius, height, length where they're born: point sphere ball ring disc box line (to to, or length along dir), lifted by height
speed, dir, spread, lift launch: out in up down forward back random none, opened into a cone of spread°, plus upward lift; or to: each flies to the play's to and lands on it exactly as it dies (speed ignored; drag and gravity bend the path, not where it lands). A tracer is one dir: 'to' spark with stretch
gravity, drag, swirl, pull, turbulence fall (negative floats up); slow to a hang; circle the centre (rad/s); draw in to the centre by death (pull: 1); wander
size, grow world size, times a curve over life
local move with the effect after birth (an aura on a walking hero), instead of being left behind (a trail)
thin false: never thinned for being far away or off screen, for what the player must read at any distance (a fire zone's extent, a bomb's ring). Graphics quality and full pools still thin it
formation born on points you draw instead of from: a list, a function, or a formation helper (§5)
onDeath, onDeathChance play an effect where each one dies (§5)

Rings, pillars, domes and beams also have count + every (ripples), hold (plays the first half of its life, holds the middle until stop(), then the rest: shields, auras, channels), y (lift), spin (rad/s), scroll (pattern speed) and local (default true: they follow).

5. Power: code any spell

The layers above cover most spells. When you picture something they can't make, these tools can. All of them keep the pixel-and-voxel look.

Your own pixel art: particles and ground marks

A particle's shape can be your own pixel art, a text grid up to 32×32 with the top row first. With no legend, . is empty and # + : - are 100/75/50/30% shades of the layer's colour. With a legend, each character is its own colour, times the layer's colour (leave color white to show it as drawn). frames animate over each particle's life. A ring's art stamps pixel art on the ground, stretched over its diameter (radius grows it, spin turns it).

const KEY: PixelArt = { rows: ['.oo.....', 'oyyo....', 'oyyoooo.', 'oyyoyyyo', '.oo.oyo.', '.....o..'],
                        legend: { o: '#6a4a10', y: '#ffd23f' } };
const COIN: PixelArt = { frames: [['.###.', '#yyy#', '#y#y#', '#yyy#', '.###.'], ['..#..', '.#y#.', '.#y#.', '.#y#.', '..#..'],
                                  ['..#..', '..#..', '..#..', '..#..', '..#..'], ['..#..', '.#y#.', '.#y#.', '.#y#.', '..#..']],
                         legend: { '#': '#c08a10', y: '#ffe066' } };   // a coin that spins
keyGet: { layers: [
  { kind: 'particles', shape: KEY, count: 1, life: 0.7, size: 0.6, speed: 1.6, dir: 'up', gravity: 2.5, blend: 'alpha', glow: 1 },
  { kind: 'particles', shape: 'pixel', count: 8, life: [0.3, 0.6], size: [0.06, 0.1], speed: [1, 2], dir: 'up', spread: 40, color: ['#fff6c8', '#ffd23f'] },
] },
clanMark: { layers: [{ kind: 'ring', art: SIGIL_ROWS, radius: [0.5, 2.2, 2.2], spin: 0.4, life: 2.4, color: ['#fff', '#c58bff'], glow: 1.8 }] },

The art is packed into one small texture the first time it plays, and it costs the same as any particle. checkEffect reports rows of the wrong length and characters missing from the legend.

Effects as code

An effect can be a function (rnd, call) => Effect. It's called on every play, with a seeded random source and the play's details (call.at, call.to, call.dir, call.scale and call.distance from at to to). This is the sound engine's (rnd) => Patch, for effects. Use it for casts that differ each time, a random rune, or an effect that fits the distance:

crit: ((rnd, call) => ({
  scale: 1 + Math.min(1, call.distance / 10),
  layers: [
    { kind: 'particles', shape: 'flare', count: 1, life: 0.15, size: 1.4 },
    { kind: 'particles', shape: 'star', count: 6 + Math.floor(rnd() * 10), life: [0.3, 0.6], speed: [3, 6], dir: 'out', drag: 3,
      color: ['#fff6c8', rnd() < 0.5 ? '#ffd23f' : '#ff7ab8'] },
  ],
})) satisfies EffectFn,

Checks and vp gallery call a function on stand-in plays (sampleEffect).

Nesting and chaining: kind: 'effect'

A layer can be another effect. It plays at the effect's position plus offset (in the effect's frame: x right, y up, z forward along dir), count times, every s apart, each one turned rotate° further. scale scales it, jitter nudges each one randomly, and follow: true makes it move with its parent. With travel it flies from at to the play's to over that many seconds, bowing up by arc, and then plays where it lands. That's a whole spell in one effect:

fiveFold: { layers: [
  { kind: 'effect', effect: EFFECTS.sparkPop, count: 5, every: 0.1, offset: [2, 0, 0], rotate: 72 },   // five pops round a circle
] },
fireballSpell: { layers: [
  { kind: 'particles', shape: 'pixel', rate: 50, dur: 0.5, from: 'sphere', radius: 1.2, pull: 1, life: 0.4 },  // charge
  { kind: 'effect', at: 0.5, effect: EFFECTS.fireball, travel: 0.7, arc: 2, then: EFFECTS.fireballHit },  // fly, then burst
] },
// vfx.play(EFFECTS.fireballSpell, { at: hand, to: target })

Formations: particles born in a shape you draw

formation replaces from. It's a list of [x, y, z] points in the effect's frame (particle i is born on point i, so count = the list's length draws it once), or a function (i, n, rnd) => [x, y, z]. The formation helpers make the common ones: circle, arc, spiral, helix, sphere, line, path, polygon, star, pentagram, grid (a text grid of pixels) and text (words in a 3×5 pixel font). dir: 'out' flies them away from the centre.

import { formation } from '@voxelparty/sdk/core';
{ kind: 'particles', shape: 'pixel', formation: formation.pentagram(2, 10), count: 50, life: 0.9, size: 0.09 },
{ kind: 'particles', shape: 'pixel', formation: formation.text('WIN', 0.28), count: 200, life: 1.6, size: 0.2, drag: 1 },
{ kind: 'particles', shape: 'pixel', count: 160, life: 2.5, swirl: 0.8,                    // a spiral galaxy
  formation: (i, n, rnd) => { const k = i / n, a = k * 7 + (i % 2) * Math.PI, r = 0.3 + k * 2.6;
                              return [Math.cos(a) * r, 0.4 + rnd() * 0.2, Math.sin(a) * r]; } },

Upright text reads from the front: aim the play's dir at the camera.

Sub-effects on death: onDeath

Particles and cubes can play an effect where each one dies, in its direction of travel. onDeathChance (0..1) sets off only some of them. Motion is closed-form, so the engine knows the death point without simulating anything. Use it for fireworks, meteors that shatter, and sparks that crackle:

firework: { layers: [{ kind: 'particles', shape: 'spark', count: 1, life: 0.7, speed: 8, dir: 'up', gravity: 9, stretch: 0.06,
  onDeath: { layers: [
    { kind: 'particles', shape: 'pixel', count: 40, life: [0.8, 1.2], speed: [3, 5], dir: 'random', drag: 1.5, gravity: 2,
      color: ['#ffffff', '#ff5a8a', '#7a3aff00'], onDeath: EFFECTS.crackle, onDeathChance: 0.3 },
  ] } }] },

Chains are capped (400 waiting, 48 started a frame), so a runaway chain thins out.

Your own particle shape in GLSL (advanced)

glsl on a particle layer is the body of vec2 shape(vec2 p, float k, float seed, float t). p runs -1..1 across the particle, already on its pixel grid. k is its age 0..1, seed its own 0..1, and t the clock. It returns (coverage 0..1, brightness). hash1, hash2, noise2, fbm2 and edge(d, w) are in scope. Each distinct GLSL shape costs a draw call and a shader compile, so use a few, not dozens:

{ kind: 'particles', count: 24, life: 1, size: 0.5, from: 'ring', radius: 1.2, speed: [1, 2], dir: 'up', color: ['#fff', '#4affe0'],
  glsl: 'float d = abs(p.x) + abs(p.y); float w = 0.25 + 0.15 * sin(t * 8.0 + seed * 6.0); return vec2(step(d, 0.95) * step(0.95 - w, d), 1.0);' }

Particles from your own code: vfx.spawn

vfx.spawn(layer, at, vel?, { color, scale, count, ground, dir }) makes particles of layer right now, at at, with your velocity (world units a second). The layer supplies the look and the motion (gravity, drag, colour over life). Use it for shell casings, sparks from your own simulation, or anything your code places one by one.

A game's identity: restyle, themeEffects, vary

  • themeEffects(EFFECTS, { palette, pixel, glow, voxel, speed, scale }) puts the game's look on all its effects:
    • palette moves every colour onto its nearest hue in your palette, keeping its lightness (whites and greys stay).
    • voxel: true turns falling pixels, shards, dots and leaves into cubes.
    • speed makes everything snappier or floatier without changing the distances.
  • restyle does the same for one effect.
  • vary(effect, seed, 0.25) makes a cousin: the second goblin's hit, related but not a copy.

Tools

  • effectToCode(effect, 'name') prints an effect as TypeScript, ready for vfx.ts.
  • sketchEffect({ verb, element, size }, seed) rolls a rough starting point for a moment (impact frost big, pickup gold with the key's color), to copy and rewrite. It's a sketch, not a finished effect: write your effects yourself.

6. Recipes

Projectile + impact. Play a looping effect with a perMeter trail, move() it every frame, and on impact stop() it and play the hit:

{ layers: [
  { kind: 'particles', shape: 'glow', rate: 30, life: 0.12, size: 1.2, color: '#b8e8ff', local: true },   // the head
  { kind: 'particles', shape: 'spark', perMeter: 8, life: [0.2, 0.4], size: 0.15, stretch: 0.05, color: ['#ffffff', '#5fb0ff00'] },
] }

An aura that follows a unit: rate layers with local: true and a hold ring, played with follow: unit.root, stopped when the buff ends.

A tower zapping a creep: a beam with a short life, { at: towerTop, to: creep }, plus a small burst at the creep. For a continuous channel, give the beam hold: true and followTo.

Charging up: particles from: 'sphere' with pull: 1 (they arrive at the centre as they die), a growing dome glow with hold. On release, stop() it and play the blast.

Team colours: play with { color: team.color }. Give the layers that should keep their own colour tint: 0 (smoke, a white core, gold coins).

Big and small: scale scales sizes, radii, speeds and gravity together, so a level-3 fireball is { scale: 1.6 }, not a new effect.

Ground marks that linger: a ring with style: 'scorch' (or crack, crater, splat, frost, claw), a life of 4–8 s and alpha: [1, 1, 1, 0].

7. Why it's cheap, and stays cheap

  • No CPU per particle. A particle's whole life is a formula of the numbers it was born with and the clock: the GPU works out where it is. Spawning writes a few floats; after that it costs nothing on the CPU. A hitstop freezes effects for free.
  • A handful of draw calls. Every effect shares one draw call per kind of thing (sprites that glow, sprites that cover, cubes, rings, pillars, domes, beams), and only while some are alive.
  • Thinning by itself. Bursts and streams make fewer particles on lower graphics settings, as the pools fill, for effects far away or small on screen, and none off screen. The pools reuse their oldest slots when full; at most half a pool is born in one frame. Play what the moment deserves and let the engine budget it.
  • What still costs: overdraw from many big sprites on screen. Prefer a few large ones plus a dome or ring over hundreds of big sprites. checkEffect warns past about 900 particles alive in one effect, and when many particles are over 5 units wide.

vp gallery (in your game). Add your effects to gallery.ts:

import { EFFECTS } from './vfx';
g.effects('Effects', EFFECTS);                        // a group, each one added
g.effect('Fireball (red team)', EFFECTS.fireball, { color: '#e23b3b', colors: { blue: '#3b8ee2' } });

The overview shows each effect at its busy moment on a dark plate. vp gallery --motion shows its film strip, with six frames bunched early (bursts are over in a blink). Looping effects run 2.5 s (loop) and are then stopped, so the strip shows them fade too. Trails circle so they show. --bg dark suits glowing effects. Read the pages: an effect you haven't seen is an effect you haven't made.

The library goes in the same way, to see what's there: g.effects('Library', VFX).

9. Pitfalls

  • Forgetting vfx.update(dt) leaves everything frozen at its first frame. Forgetting stop() on a looping effect leaves it running forever.
  • Playing the library for everything. VFX.* effects are examples. A game whose key pickup is a frost nova looks lazy: make your own for each moment (§2, §5). vp check notes library effects played straight from game code.
  • Glow on bright ground. Glowing layers keep their hue on a sunny meadow, but pale colours still read as white there. Use saturated colours and glow 1.2–2. Keep 2.5+ for tiny, bright cores.
  • Beams need to. Without one they strike down from 10 units above at, with a warning; to: 'sky' says you meant it. Emitters with dir: 'forward' and upright rings need dir.
  • Cubes land on ground, which defaults to at's height. Play an explosion at chest height with ground: 0.
  • Effects are transparent and don't write depth. Draw solid gameplay objects as meshes, not as effects.

SDK reference · vp docs sharing · sharing.md

Sharing a game

How a finished game reaches other people, from most private to most social. The workflow ends with vp share (§2): it uploads the game and puts it on the user's page.

1. The file

bunx vp pack builds dist/<id>.vpgame. Anyone can drag it onto https://voxelparty.io (or press M there → "+ Add a game file"). It's saved in that browser's My Games and plays there solo (with CPUs). Good for trying it on another computer; clumsy for friends, and a file alone can't be played online: the other players have no way to load it.

bunx vp share

It packs the game (exactly what vp pack builds), uploads it unlisted, opens its claim link in the browser and prints:

Sky Charge is online (unlisted).
  Put it on your page: https://voxelparty.io/#claim/<hash>/<key>
    (opened in your browser: sign in, and it's in "Your games" with Play and Publish)
  Play it: https://voxelparty.io/?play=<hash>
    (opens a lobby for it: press Invite there and send friends the party link, or add CPUs and Start)
  • Put it on your page (opened for you; --no-open only prints it): the user signs in there (Discord) and the game lands in Your games on the site's Create page, with ▶ Play and Publish. The link is secret, and it's the only way to claim the upload: whoever opens it signed in first owns it, so don't post it anywhere. If no browser opened (a remote machine), give the user the link.
  • Play it (?play=<hash>): opens a party with a lobby for this game (a session, vp docs sessions). Press + Invite to copy the party's link (?room=CODE, it shows the game's picture when pasted in Discord) and send it: friends who open it land in the same lobby. Add CPUs there, or press Start to play right away. It also adds the game to the opener's My Games. This is the way to play a game together, and alone.
  • Unlisted means only people with a link can find it: nothing lists it.
  • The hash is that exact build. Uploading the same build again says "this exact version was already uploaded" and gives the same links (the claim link only to the address that first shared it, until it's claimed). After any change, vp share again and send the new links: old links keep playing the old version.
  • Share only after bunx vp check is clean (all ✔, no ⚠) and the user has played it: the link is how other people first meet the game.

The first real online test is usually the user plus a friend in one party (vp dev and vp check run one client). Ask them to report desyncs, stalls, joins that go wrong, or a round that never ends; the FakeRoom tests (vp docs netcode §6) are how you reproduce and fix those.

3. Timed games

A game with a board block in game.json has a round time limit. It plays in sessions like every other game, and its store page shows the round length.

4. Publishing to the community

The store listing is yours to write, in game.json, before vp share:

{ "id": "goose-chase", "name": "Goose Chase", "players": { "min": 1, "max": 8 }, "input": ["mouse"],
  "description": "You're a goose. Steal hats, honk at villagers and get away before the farmer catches you.",
  "tags": ["stealth", "comedy", "chase"] }
  • description: 1-2 sentences, at most 280 characters: what you do and how you win, in plain words a player gets at a glance. Not a feature list.
  • tags: 3 of your own words for what the game is (genre, mood, how it's played), the words someone would search for. Lowercase, dashes for spaces ("tower-defense", "co-op"); anything else is cleaned up that way. The store's tag filters are the tags games use most, so a common word (racing, shooter, party, puzzle, co-op, chaos) gets found more than a made-up one.
  • Both in genre words, never another game's: no franchise or game names, none of its character, item or map names, no "X-like" or "clone of X". A game inspired by one you love is welcome (any genre, rules and feel are free to use); its title and listing are its own.

They ride along in the package (outside its hash, so changing them isn't a new version), and the site's Publish form starts from them. vp check and vp share warn (⚠) while either is missing.

vp share is unlisted. To list a game publicly, the user does it on the site (there's no CLI command for it):

  1. Open https://voxelparty.io (the store is the home page) and press Sign in (Discord) in the top bar.
  2. In Create, drop the .vpgame (or pick it from My Games) and press Publish. That asks for a title, a short description and up to 3 tags, already filled in from game.json's description and tags (above): the user checks them and can change anything. Uploads made from the site while signed in belong to that account. A vp share upload is anonymous until its claim link is opened signed in; nothing else claims it (dropping the same file on the site doesn't).
  3. Listed games show up in the store: shelves (trending, new this week, most played, top rated), tag filters, search, and a page per game and per creator. Each game's page has ▶ Play (a lobby for it; a guest in someone's party suggests it to the host instead), and says "N players", the round length and "Mouse" as they apply. Players can vote once they've played, and report a game; games with enough reports are hidden for review.

The pictures are taken automatically. Nobody uploads a thumbnail. On publish, the server plays the game with your seat on autopilot (input.autopilot: your CPU plays it, the view is yours) and CPUs in the others, and screenshots it:

  • stills at about 6, 16 and 30 s into play (the first is the cover);
  • a short looping clip right after the cover.

So the pictures are what a player sees, a few seconds in: first person in a first-person game, your character's camera in a third-person one. The action should be on screen, the HUD readable, nothing blank or waiting on a human. A game that never reads input.autopilot is photographed with CPUs only instead, where a first-person game must still point its camera at something. That's the same thing vp check's autopilot screenshots show, so check those with this in mind. They appear a minute or two after publishing; until then the card shows a generated cover.

Publishing a new version (a new file) moves the listing to it, keeps its votes and tags, and retakes the pictures. Tell the user this; don't invent commands or promise features beyond it.

SDK reference · vp docs testing · testing.md

Seeing your game: autopilot, vp shot, film strips and the visual checks

bun test proves the rules and the netcode. This is how you look at the game without a person at the keyboard: your own seat played by your CPU, the game fast-forwarded frame by frame by a script, screenshots and film strips of it, and checks that read those pictures for you.

Contents

  1. Autopilot: your CPU plays your seat
  2. vp shot: scripted play and pictures
  3. Film strips: movement in one picture
  4. The visual checks and the sound log
  5. What vp check adds
  6. Scripted play by hand: __vp on the vp dev page
  7. Every model at once: vp gallery
  8. Pitfalls

1. Autopilot: your CPU plays your seat

input.autopilot is true when a CPU should drive this player's seat: your own bot brain plays it, while the camera, HUD, sounds and first-person view stay yours. vp shot, vp check and vp dev ?autopilot=1 turn it on (never the site). It's how your first-person camera, your HUD and your own-player code get tested headless, where nobody is at the keyboard.

Every template already does it, and it's two lines in any game: intent() says "nothing, a CPU has this one", and the core falls back to the seat's bot.

// game.ts
private intent(): Intent | null {
  if (this.ctx.input.autopilot) return null;      // your CPU plays your seat
  return readIntent(this.ctx.input, { out: this.read });
}

// core.ts
constructor(link: MinigameLink, frame: GameFrame, private readonly intent: () => Intent | null) { … }
const it = role === 'local' ? (this.intent() ?? this.bot(p.id).update(dt, p, this)) : this.bot(p.id).update(dt, p, this);

Read it every frame (a script can switch it on and off mid-game). Keep everything else the same as for a person: the view follows your body, the HUD shows your numbers, your sounds play. A game without the intent pattern does whatever it does with your input instead: input.autopilot ? this.bot(you).update(dt, me, world) : input.move().

2. vp shot: scripted play and pictures

bunx vp shot                       # the default: your seat on autopilot, strips and shots of play
bunx vp shot shots.ts              # your own script
bunx vp shot --phone               # on a phone held sideways (844×390, touch, a notch)
bunx vp shot shots.ts --phone      # your script on a phone

It builds the game, plays it in a muted headless browser on a fast-forwarded clock (the game only moves when the script says, one 60 Hz frame at a time, as fast as the machine goes), takes title.png (the title card), presses Ready and any setup's Start, then runs the script. Pictures and report.json land in .vp/shots/; open each picture and look at it. Flags: --players N, --seed N (the same seed plays the same game), --mode session|minigame, --autopilot (your seat on autopilot from the start), --size 1280x720, --phone (or --touch), --no-play (stay on the title card).

On a phone (--phone) the screen is 844×390 CSS px with the touch controls and a notch (--portrait: held upright, 390×844), and each picture of play says how much of the screen the HUD covers, piece by piece: HUD on the phone's screen: play-1.png 12% (div.bar 7%, div.hearts 3%). A phone player sees the game through what's left, so think of a good mobile game: a few small pieces hugging the edges and corners, the middle clear. Past 18% it's a ⚠. Make it fit with CSS under html.vp-phone: shrink panels, sink round gauges half past the screen's edge, put text on one line, and hide what a phone player can't use (key hints, a desktop-only panel). Check both ways up: people hold phones upright too. Open the pictures and look: the number doesn't see a panel that's in the way of the action. The pictures (on any screen) show the site's corner at the top right as players see it: its buttons, or the ☰ on a phone.

A script is a module whose default export gets t:

// shots.ts
import type { Shots } from '@voxelparty/sdk/test';

export default async (t: Shots) => {
  await t.hold('up', 800);                       // W for 0.8 s of play
  await t.look(240, 0);                          // turn right (mouse look, no pointer lock needed)
  await t.shot('corner');                        // .vp/shots/corner.png
  await t.press('action');                       // jump
  await t.strip('jump', 8, 700);                 // 8 frames over 0.7 s, one picture
  await t.autopilot(true);                       // your CPU takes over
  await t.until('game.core.world.fighters.size >= 4', 10_000);
  await t.camera({ at: [0, 30, 24], look: [0, 0, 0] });   // a wide photo…
  await t.shot('overview');
  await t.camera(null);                          // …and the game's camera back
  console.log(await t.eval('game.core.world.scores()'));
};
t.
wait(ms) let ms of the game's time pass
shot(name?) a screenshot, checked (section 4); returns its path
strip(name, frames = 6, ms = 1000) a film strip (section 3)
press(key) hold(key, ms?) release(key?) a key: an action ('action', 'up'…), a code ('KeyR', 'ShiftLeft') or a character ('r'); hold without ms holds until release
move(x, z, ms?) walk like input.move(): x right, z towards the camera
look(dx, dy) mouse movement in px: what input.look() reads
click(x, y, button?) mouse(button?, ms?) point(x, y) the mouse, CSS px: the game's button and whatever HTML is there (menu buttons work)
autopilot(on?) your CPU plays your seat (section 1)
play() past the title card: Ready, then any setup's Start (already done unless --no-play)
camera(pose | null) hold the camera at { at, look, fov? } (world coordinates, arrays) after every update, whatever the game does with it, or give it back; it plays one frame, so the next shot already shows it (HTML the game places by its own camera, like name tags, stays where the game put it)
eval(code) until(code, ms?) run code inside the game (game, ctx, link, engine in scope), or wait until it's truthy
join() leave(pid?) a CPU drops in, someone leaves (sessions)
lint() sounds() warn(text) the HUD check now, the sound log so far, a warning of your own

Because the clock is the game's, a script is exact and repeatable: hold('up', 800) is 800 ms of play whatever the machine, and the same seed gives the same pictures. The input is real keyboard and mouse events inside the game's frame, so it goes through your normal input code.

3. Film strips: movement in one picture

A screenshot can't show a jump arc, a knockback, a camera that lags, an animation that pops, or a hit that has no feedback. t.strip('jump', 8, 700) takes 8 frames evenly over 0.7 s of play and tiles them into one PNG, three to a row, each numbered with its time (3 +200ms). Read it like a comic: does the body rise and fall smoothly, does the camera follow, does the hit flash, do the particles last long enough? Strip what you just tuned, every time: a jump, a dash, a hit, a death, a pickup, the first second after GO.

4. The visual checks and the sound log

Every t.shot (and every vp check screenshot) is checked, and each finding is a ⚠ with the picture's name. They're warnings, not failures, and each one is meant to be fixed:

  • The 3D view is blank: black, white, one flat colour, or so little detail it's sky or a wall filling the view (checked with the HTML hidden). The camera is inside something, looking the wrong way, not placed yet, or there's nothing to see.
  • The 3D view is frozen: two pictures of play seconds apart are the same.
  • Z-fighting: two meshes draw a face in the same place, facing the same way, so it flickers between them (stripes of grass on a rock wall). On mats.solid and mats.actor the SDK settles most of it by itself: of two things with faces in one plane, the smaller is always drawn in front (a drawer shut in its cabinet, a sign on a wall), and copies in one InstancedMesh go by their index. So the ⚠ only names what that can't settle: two meshes about the same size, or one mesh whose faces overlap. It names both (material, size, where they start) and the box where they fight. Nearly always two volumes that share blocks: build them as one Volume, or place them side by side (a volume w wide at x 0 ends where the next starts, at x w, not w - 1). Things that cross (boards nailed over each other): give each its own depth, a centimetre or two apart. A decal meant to lie on a face: give its material polygonOffset, or mark the mesh userData.vpOverlapOk = true. From a script: t.zfights().
  • HUD text cut off by its box (overflow: hidden; an ellipsis on purpose is fine), partly off screen, on top of other text, or smaller than 10 px.
  • HUD panels on top of each other (one hides the other, text and all) and HUD under the site's corner (top right, var(--vp-corner-w) × var(--vp-corner-h)), on every screen.
  • On a phone (--phone, and vp check's phone runs): text under the touch controls ([data-vp-touch]) or in the notch's safe area: keep HUD inside env(safe-area-inset-*). And a crowded HUD: more than 18% of the screen painted over the game (panels, bars, icons, text; not the touch controls or a menu). From a script: (await t.lint(true)).cover.
  • No sounds of the game's own played (only the frame's Ready and countdown).
  • Your seat stands idle (autopilot runs): the game never read input.autopilot, so nobody played your seat and the pictures show a player standing still. Read it where you read your input (section 1).
  • Movement in stops and starts (vp check's autopilot run): at the end it takes your seat back, holds right and then up for 1.5 s each, and records the camera and every character frame by frame. Anything that moves most of the time but unevenly (stands still, then jumps, several times a second) is named: character 1 (0.99). Steady movement scores under 0.1. It's what players call buggy movement: draw your own character from a prediction that glides between ticks (ls.predictor() in a Lockstep game, your local body every frame otherwise), and others with PlayerSync.smooth or Reckon. In a script: drive.track(true), hold, then drive.trackReport() (through t.eval).

The sound log counts every sound played, even muted: SFX.coin ×12 for the shared ones, and your own by what played them: SnowballFight.cues (game.js:630) ×37. A sound that never shows up never played; one with hundreds of plays per second is stuck in a loop. It also measures loudness (K-weighted dB, like LUFS, rendered offline): levels: typical sound -22.4 dB; loops at their loudest: Camp.light (game.js:88) -27.9. The typical sound is the median of your own sounds, each at the loudest vol it played; a loop is its steady level as heard where it was loudest. A ⚠ names any sfx.loop that came within 3 dB of the typical sound, any sustained patch played with sound.play that loud (an ambient bed belongs in sfx.loop), and all loops together going over it: sounds that keep going should sit well under the hits (vp docs sound).

5. What vp check adds

Besides its bots-only rounds and sessions, vp check plays two runs with your seat on autopilot: one at 1280×720 (pilot-title.png, pilot-go.png, a strip at GO, pilot-play-1.png, pilot-play-2.png, then the movement check above), one on a phone held sideways (phone-title.png, phone-play.png) and one upright (portrait-title.png, portrait-play.png). They show what a player sees, first-person code included, and fail on any error like the other runs. All screenshots get the visual checks. A game that needs more than 4 players (players.min, the fewest it works with) is checked with what it needs.

6. Scripted play by hand: __vp on the vp dev page

The vp dev page has the same helpers as t, as window.__vp (in real time there):

await __vp.autopilot(true)          // or open the page with ?autopilot=1
await __vp.play()                   // or ?autoplay=1: past the title card by itself
await __vp.hold('KeyW', 500); await __vp.look(200, 0); await __vp.click(640, 360)
await __vp.camera({ at: [0, 20, 20], look: [0, 0, 0] })
await __vp.lint()                   // what's wrong with the HUD right now
await __vp.sounds()                 // which sounds played so far
await __vp.eval('game.round')       // anything inside the game

Also ?shot=1 (no harness buttons over the game).

Play shows a handful of your models at a time. vp gallery shows all of them: declare them in a gallery.ts next to index.ts (each with a builder that calls your own model code), and it draws them with the game's engine on numbered pages in .vp/gallery/ (40 a page, each page at most about 1,536 px so every label stays legible), with turnarounds, silhouettes, motion strips, game-distance views and textures on request, and checks: broken or empty models, near-duplicates ("#41 and #88 look 94% alike"), budget outliers, floating models, off-style textures. --diff shows only what changed since the last run; --scene finds models the game draws that the gallery doesn't declare; vp dev ?gallery browses them live. vp check runs its checks whenever there's a gallery.ts.

// gallery.ts
import type { Gallery } from '@voxelparty/sdk/test';
export default (g: Gallery) => {
  const ids = useGameAssets(TEXTURES, BLOCKS);
  g.group('Toys', { scale: 'shared' });
  for (const t of TOYS) g.add(t.name, () => new Mesh(toyGeometry(ids, t), g.engine.mats.actor), { tags: [t.rarity] });
  g.textures('Textures', TEXTURES);
};

After making or changing any art: bunx vp gallery, read every page, fix what's flagged, and include the pages in your report. Everything else is in vp docs gallery.

8. Pitfalls

  • Autopilot does nothing: the game ignores input.autopilot (section 1), so your seat stands still in every picture. Or its bot assumes it only ever runs on the host for other players.
  • "Couldn't get into play": the title card's button or the setup's Start never showed (a custom start screen?). Press your own buttons with t.click(x, y) or t.press(...).
  • A script that waits in a loop on the real clock hangs: inside the game every clock is the fast-forwarded one. Use t.wait and t.until.
  • Pointer lock is granted in vp shot and vp check runs the way a browser grants it after a click (a headless browser never would), so a game with pointerLock: true is locked once play() presses "Click to play", and look() and click() reach readIntent. A game that locks at some other moment has to call input.lockPointer() itself, as it would for a player.
  • The fast-forwarded clock is exact but not real time: real frame-rate hitches only show in vp check's real-time runs and when a person plays.
  • A hitch in vp check --long that play doesn't have: work spread over frames by a time budget on performance.now() (build a few ms a frame) runs whole in one frame when that clock stands still. Measure budgets with perfNow() from @voxelparty/sdk/core, the real clock (WorldView and NavGrid.work do).

SDK reference · vp docs board · board.md

Board minigames

Voxel Party's board party is one optional way to play: 2–4 friends (topped up with CPUs) take turns on a board, and between turns everyone plays a minigame from the host's line-up. The winner gets coins. A board minigame is a game with a board block in game.json; it can go in party line-ups and be played in a session (timed runs, vp docs sessions).

Start one with bunx vp init <id> --minigame (the template is Star Catch, a complete board minigame). vp docs design (sections 1–9) has what makes them fun.

The manifest

{ "id": "star-catch", "name": "Star Catch", "players": { "min": 2, "max": 4 }, "board": { "maxMs": 50000 } }
  • board.maxMs: the server's hard stop, 3 000 – 300 000 ms. Set it to your round length plus 5–10 s (the FINISH beat and the score reports need it), and end the game yourself before it.
  • players: min <= 2 and max >= 4 (a party can be 2, 3 or 4, with CPUs in empty seats). max can be higher for sessions; in a party it's never more than 4.
  • input must be []: the board stays pick-up-and-play, so move + action only (no mouse aiming, no extra keys; a left click still counts as action).

What a board minigame must do

  • Play every seat as a CPU. Parties fill empty seats with CPUs, and vp check plays with CPUs only (link.you is null, seats.you is -1): the game must run and end with nobody at the keyboard.

  • Work with 2, 3 and 4 players. Size everything by players.length, never a hard-coded 4.

  • End on its own and score everyone, well within maxMs:

    • host-authoritative and discrete games: the host calls flow.end(scores, headline) with a score for every seat;
    • per-player games: each client calls flow.finish(scores, headline) with a score for the seats it owns and NaN for the rest. A round the server's hard stop has to end is a bug (vp check fails it).
  • Rounds of 30–90 s, understood within 3 seconds of the title card, at most 3 controls.

  • Higher scores are better; ties share a place. Payouts: 1st 10 coins, 2nd 5, 3rd 2, 4th 0.

  • The roster is fixed for a board round (nobody joins mid-minigame; someone who drops is played by the host as a CPU). The same game in a session also has a fixed roster per run, so board minigames don't need the drop-in handling of vp docs sessions §3. Using Seats indices is fine.

  • Survive drops mid-round. Who plays a seat can change while the round runs: a player whose connection drops turns 'cpu' on the host (seats.role(i) === 'bot'), and when the host drops, another client becomes the host (link.isHost turns true) with every CPU seat. So never decide once, in a constructor, who simulates what:

    // ✗ both freeze the round (it waits for the hard stop), or crash on a seat with no CPU
    const host = link.isHost;
    const bots = link.players.map((_, i) => (seats.role(i) === 'bot' ? new Bot(i) : null));
    
    // ✓ ask every frame, and make a CPU when a seat first needs one
    if (link.isHost) simulate(dt);
    for (let i = 0; i < seats.count; i++) if (seats.role(i) === 'bot') (bots[i] ??= new Bot(i)).update(dt);
    

    A new host carries on from the world it last received (HostSync does: vp docs netcode), runs every rule the old host ran, and reports the scores. vp check plays two rounds where this happens.

The frame

defineGame({ round: 45, … }) gives the HUD timer; flow.timeLeft counts down from the smaller of your round and maxMs. Title card ("Ready!") → 3-2-1-GO → play → FINISH → results (coins), all owned by the platform. Your game only simulates while flow.live.

vp check for board minigames

Typecheck, pack, source warnings, bun test, then 3 bots-only rounds in a muted headless browser with 4, 2 and 3 players (--seeds 6 for more; the counts keep cycling). Screenshots in .vp/check/: <n>p-seed-<s>-1-intro.png, 2-play (~4 s after GO), 3-late (~19 s after GO), 4-results. It fails when a round errors, doesn't finish, or needs the hard stop; ⚠ when every player tied (usually a soft lock). Then two rounds where someone drops, you on autopilot: a player's connection drops a quarter of the way in (at most 10 s), so their seat turns 'cpu' here (drop-player-*.png), and one where the host drops and you become the host (drop-host-*.png). Both must still end by themselves. Then a round with your seat on autopilot (input.autopilot: your CPU plays you, the view is yours) and one on a phone, and every screenshot gets the visual checks (vp docs testing). Look at every screenshot, for every player count. bunx vp shot takes more pictures, and film strips of movement, whenever you want them. --long then fast-forwards round after round (a new seed each, 3 to 20 rounds, about --minutes 20 of play) and reports what each round's frames cost and how big the game got: see vp docs sessions §7.

The quality bar

  • 30–90 s, at most 3 controls, the blurb says the goal and the twist in one or two sentences.
  • Your own actions respond on the same frame, online too.
  • Bots play every seat competently, differ in skill, and sometimes make human mistakes.
  • Every round ends cleanly, everyone is scored, the podium makes sense.
  • A readable field, juice on every event, a camera that frames everyone.
  • 8+ sounds and an original theme, rivals quieter than you.
  • vp check all ✔ with no ⚠, screenshots looked at, well under 1 MB, 60 fps.