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
- Bun:
bun --version. Missing? macOS/Linuxcurl -fsSL https://bun.sh/install | bash; Windowspowershell -c "irm bun.sh/install.ps1 | iex". Then open a new shell. - Chrome, Edge or Chromium for
vp check(setVP_BROWSERto its path if it isn't found). - A new game (the id: 2–32 of
a-z 0-9 -, starting with a letter):
Addbunx --package https://cdn.voxelparty.io/sdk/voxelparty-sdk-3.21.1.tgz vp init sky-charge --name "Sky Charge" cd sky-charge--fpsfor a first-person shooter (movement, a map, CPUs and hitscan netcode, ready to grow), or--blocksfor a block game where players place and break blocks (a bridge duel: a syncedWorld, bridging, CPUs that build).initcopies a small, complete, working game and runsbun install. Read its files before writing anything: they show every piece together. - An existing game: read
game.jsonandindex.ts, runbunx vp check, go on from there. (Aboardblock in itsgame.jsonmakes 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.
- 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.minis 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. - 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 readvp docs sessions. - Rules and bots first, proven by
bunx vp test.rules.tsandbot.tsare three-free and run underbun test. Draw maps as text (textGrid,vp docs levels), not nestedsetloops, 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; aFakeRoomhost 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. - 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. bunx vp checkuntil 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 runbunx vp check --long: it fast-forwards a 20-minute match in seconds and flags slowdowns, leaks and hitches.- Look at it play:
bunx vp shot(vp docs testing). Support autopilot first (yourintent()returns null oninput.autopilotand 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). Thenvp shot --phoneandvp shot --portraitfor 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). - See every model:
bunx vp gallery(vp docs gallery). Highly recommended. Declare each model and texture ingallery.ts(the template has one) with the same code the game uses. After making or changing any art, runbunx vp gallery, read every page (40 numbered models a page;--turnaround,--silhouette,--motion,--tiny,--bg darkfor more), fix what's flagged (the red ⚠ cells: broken, floating, look-alikes, budget outliers, off-style textures), and include the pages in your report.--diffshows only what changed since the last run. - Let the human play:
bunx vp devin the background, then http://127.0.0.1:5180 (?players=3adds CPUs,?bots=1to watch). Ask how it feels; tune. For your own pictures usevp shotscripts;t.eval('game.x')(or__vp.evalon that page) reads or sets anything inside the game: no CDP frame-hopping or debug globals. - Hand it over: first write the store listing in
game.jsonyourself, 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. Oncevp checkis clean and they've played it, runbunx 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 shareagain: each version is its own upload. More invp 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,tagsor 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/coreand/test. - Every seat playable by a CPU; the game runs with nobody at the keyboard (
link.younull). - Shared randomness from
link.seed(mulberry32(link.seed ^ SALT),botRng), neverMath.randomin rules or bots. - State is what's true now: no clocks, frame counters or random numbers in
sendState,PlayerSync.sendor 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 withHistory,vp docs netcode§2). - Draw other players with
PlayerSync.smooth(i)(smooth, ~100 ms behind) orReckon(where they are now, for things you aim at or dodge). Never extrapolatelatest()yourself: it freezes and jumps at ordinary latency (vp docs netcode§2). - In a
Lockstepgame, draw the character you steer fromls.predictor(), with onesteerBodyand onemoveBodyshared 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 issync.act(a)(drawn at once withpredict, applied once by the host), a role or hand issync.tell(pid, s), a moment everyone must share issync.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.isHostevery frame (the host changes). - Finish correctly: host-authoritative games
flow.end(scores)with every seat's score; per-player gamesflow.finish(scores)withNaNfor seats you don't own. Runs end only when the game chooses to. A game that plays match after match inside one run callsflow.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 withinput.pressed('reload')), not a rawinput.keysread: then pads and phones get it too. Gate look and fire oninput.aiming, notinput.locked(vp docs input§3–4). AMenusteers by pad by itself, and itskeyopens 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'sstorage(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.tweenfor pop-ins, squashes and fades,Springfor wobble,ctx.time.hitstopandslowfor weight (vp docs api§12). Don't write your owneaseOutBackor tween loop.Fits every screen: fitted cameras use
rig.fit(…, { depth, hud: true })so the field isn't under the chips; names over heads arenameTags (readable at any distance), not scaledtextSprites; 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 withvp shot --phoneand--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.bannerandui.sign()already sit there; your own call-outs do too (top: 28%or so, nottop: 50%withtranslate(-50%, -50%)). Only the crosshair and hit markers belong in the exact centre.Effects work like sounds (
vp docs vfx): the game's ownVfxeffects invfx.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. TheVFXlibrary 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),onDeathsub-effects, custom GLSL shapes,vfx.spawn;themeEffectsputs 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 (acrack, acrater, aclawmark, aripple, aburst, cubes of the thing's material): rings are seasoning, kept for real waves and coloured; a plain white shock ring on everything looks lazy, andvp checkwarns. Tracers: onedir: 'to'spark withstretch.Many units means
Crowd; dressing players meansAvatar.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/flashfor 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
- The manifest
- The life of a session
- The roster is live: joins and leaves
- Host changes
- Rounds inside a session, and the scoreboard
- Bots in a session
- Testing: FakeRoom joins and leaves,
vp check - 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 use1unless the game breaks with fewer (a duel needs2). 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 (seevp docs input):mouse(pointer, buttons, wheel),pointerLock(mouse look; the page lets the sandbox lock the pointer),keyboard(raw keys throughinput.keys). Default[].- No
boardblock: 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.playersis replaced by a new array;ctx.playersis the same array, updated in place (soctx.players.lengthandseats.countare always right);- indices shift: a leaver's slot disappears and everyone after them moves up one;
- your
GameStage.onPlayers?(players, joined, left)hook runs, andlink.onPlayers(cb)listeners (same arguments;playersislink.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 aids: 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.hudfollows 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 (orflow.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.
HostSyncdoes 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:
loadruns on the new host beforeisHostturns 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):atis when it was kept); players' own positions come back through their streams within a tick. WithoutHostSync: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
Setuphands 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 whensetup.matchdiffers from its own, that fires on the reloaded host beforeonKepthas 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)inFakeRoom, whose new game getsonKeptbefore its first frame. - Without a kept world, a new host can only adopt the latest snapshot (
sync.read().latestwhenisHostturns 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" withflow.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), callflow.matchOver(scores)(SDK 3.4,scores[i]forlink.players[i]as withflow.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 onSetupgets 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.endtakes one score per current roster index (scores[i]forlink.players[i]). Per-player games useflow.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.minand adds more up toplayers.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.minis 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 inSetup. With two people and two CPUs, if the host picks 1v1, bench the CPUs (host, at the match start):
Only the host can tell a CPU from a person (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 matchseats.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 aSetupteam, or throughLockstep, whosejoininput sayscpu). 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 equalsroom.kept.worldright afterroom.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
onPlayershook 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'skeep, ≤ 64 KB) and a new host loads it. - Works with 1 player and with
players.max. - Ends runs with
flow.endand a clear winner, or plays matches inside one run and callsflow.matchOver(scores)when each is decided (or truly never ends: a sandbox). -
vp checkclean, 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
- The model
- The building blocks (
Seats,PlayerSync,HostSync,Reckon,followReport,Reconciler,Predictions,History) - Shape A: host-authoritative (sketch)
- Shape B: per-player (sketch)
- Shape C: discrete events (sketch)
- Testing with
FakeRoomandFakeFlow - Pitfalls
- Sessions: a live roster and host changes
- Shooters: the shooter decides what it hit
- Shape D: deterministic lockstep (
Lockstep), for RTS, tower defense and snakes - 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 checkthere's one client, which is the host and owns every seat. link.players[i](the same order asctx.players[i]) hascontrol, 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.modeis'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.clockis seconds since GO on that clock, identical everywhere. Anything scheduled (FIRE moments, round starts, moving platforms) is a function offlow.clock, never of summed-updt. - 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);
HostSyncadds 15/s; everysendEventandreportScoreis 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
HostSyncdoes 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). KeepsendStateandPlayerSync.sendevery frame: the skipping is the link's job.vp check --longwarns about a field that keeps changing by itself. link.sendEvent(type, data, to)withto(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 forkeepMs(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 lowerkeepMs(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, throughonEvent: 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.instanceis a new random string each time the game starts on a client; PlayerSync stamps it on what it sends ($i,$eand$tare its own keys in the state), and when a seat's changes it forgets that seat (counters read 0 again) and callsonReset. 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
WaitCardsaying "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 withsendState, so a timeout still counts what you were on. - The host (whoever
link.isHostis at resolve time) collects picks fromlink.onEvent, makes the CPU picks, resolves with the seeded rng, and broadcastslink.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.onEventindispose()(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.
FakeRoomoptions: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(aMapof 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.keptis what the room holds ({ world, at }, null once the run is over). When the host leaves, the new host'sonKeptgets it insideroom.leave, before its next frame as host. - Each
FakeLinkalso hasreported(the scores it sent) andsent{ states, events, keeps, bytes, dropped }. new FakeFlow(link, round?)is aGameFrame(live,clock,timeLeft,end,finish, plusover,headline,setRound,dispose).endrelays the finish to the otherFakeFlows exactly asFlow.enddoes.- Promises don't settle inside
room.run: the loop is synchronous, solink.results.then(...)(andFakeFlow's own "TIME UP!" from it) only runs after it returns. Checkroom.resultsin 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 === 0in host-authoritative games; nothing dropped;room.stats.maxMessagewell 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 orMath.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.eventandlink.sendEventcarry their time). - The NaN score rule (per-player):
flow.finishgets a finite score only for owned seats.strictScoresonly catches a non-host breaking it; on the host a stray finite score silently wins, so compute scores withseats.owns(i) ? … : NaN. Host-authoritative games useflow.endwith every score instead. - One finish.
flow.end/flow.finishonly count the first time; guard with anendedflag so you don't spend work every frame, and stop sending state after it. - Rate limits. Never
sendEventevery frame. One-offs ride onHostSync.event(the host's) andPlayerSync.event(a player's); state goes throughsendState/PlayerSync(batched). A burst of 10sendEvents 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).IslandandarenaStage's pollen consume their own rng in a fixed order. - Clocks. Schedule shared moments by
flow.clock, not by adding updt(frame rates differ). The platform already capsdtatMAX_DT(0.1 s), so a hitch doesn't teleport things through walls; headless cores driven byFakeRoomget its fixed step. Don't add your own clamp. - Untrusted input. Validate everything from the network:
HostSync'svalid,Number.isFiniteon streamed numbers, bounds on indices.read()'slatestandat(andPlayerSync'slatest/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 checkplays 4-, 2- and 3-player rounds. Sessions: 1 up toplayers.max, changing mid-run. Size arrays byplayers.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.isHosteach frame rather than caching it, and have the host keep the whole world (HostSync'skeep) 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
hostStepresult, the clients'onEvent), not where it's detected, so every client sees and hears it exactly once. Coin Cascade pushescuesfrom both paths and draws only from those. - Other players:
smooth()orReckon, never your own guess fromlatest()(section 2). - Snapshots small: flat number arrays ×100 (
r100), about 1 KB. SendAvatar.shownfor 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.playersis replaced andctx.playersupdated 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) inMaps, and look indices up when an API needs one.PlayerSyncandHostSyncare index-based at the call (send(i, …),latest(i)) but keyed by pid inside, so they stay right as long asiis the current index.GameStage.onPlayers(players, joined, left)(orlink.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.isHostturns 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 aslink.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 beforeisHostreads 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.atis 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) andlink.onKept((world, at) => …). - Too big?
link.keepreturns the world's size in characters of JSON; overKEEP_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 withpackInts({ delta: true }for sorted or slowly changing ones: ids, tiles, paths), grids withpackBytes, and leave out what the new host can rebuild (paths, caches, effects). Unpacking throws on junk: it's inside yourload, which validates anyway.
Worlds that are big by nature (an RTS late game) usesave: () => ({ 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 */ } },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 usesWorldSync(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
isHostturns 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)onKeptnever fires;FakeRoomhands the kept world over inroom.leave,room.dropandroom.reloadof 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 (
loadabove), never assume a counter or sequence number from a player only goes up (PlayerSync.onReset,link.instance), and for match setup useSetup, 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 === Infinityandlink.endsAt === Infinity: never compute "time left" fractions from them. Schedule byflow.clockorlink.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 withPlayerSync.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 lowerkeepMs(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 inHostSyncsnapshots, anddmg,frag,spawnandwinasHostSyncevents. 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
dmgevent and the victim's owner applies it withpush. 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:
PlayerSynchandles its own (onReset), clear your per-sender state there (fire-rate timers). Keep the match withHostSync'skeepso 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
Historyof positions on the host and look atpast.seen(at),atbeing 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'sls.orderFor(pid, o)).{ k: 'system', o }: the host'sls.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: falseturns them off).away: a person whose connection dropped; the host's CPU plays for them (orderFor) untilaway: false.ls.membersis 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); neverMath.sin/cos/atan2/pow/expon 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 insidestep), neverMath.random,Date.noworlink.now(). - Iterate in a fixed order: arrays, or
Maps filled in the same order on every client. Sort anything you build from aSetor 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). hashcovers everything that matters (a 32-bit FNV over the numbers);save/loadround-trip exactly. Test both: step two worlds with the same orders and compare, andload(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 everyhashEveryticks (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 runsdelayMsbehind the clock, learned from how late orders arrive (betweendelay.minanddelay.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 fromls.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'sto), 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.keepsSkippedcounts 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, orme.teleport()). frommust return a new object andstepmust 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
actoption 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, insideact().atis when they did it (link time, clamped to at most a second back): judge "was it still there then?" with it. Returnfalseto 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.pendingis your unconfirmed actions, oldest first;predictreplays 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.loaddoesn't take the kept world (you already have a newer one), returnfalsefrom it, so the record follows your world. - A host that reloads should wait for its kept world before acting as host (Setup's
freshsays 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/pointerLockingame.json'sinput). 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
- Choosing controls
- Actions
- Your own buttons (
defineGame({ buttons })) - Gamepads and touch screens
- The mouse (
"mouse") - Pointer lock and mouse look (
"pointerLock") - Raw keys (
"keyboard") - Recipe: a first-person camera
- Recipe: aim and shoot at the pointer (top-down)
- Networking input
- 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
ButtonSpechas 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,pressedAtandpressTimesall take button names. An unknown name throws, so a typo shows up on the first frame.- The names
click,rightClick,middleClickandwheelare 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, notinput.locked: pads and touch screens look without a lock. - Use
input.labelin 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
mousegames 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-hon :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-touchingis set while they play by touch, for CSS that moves or hides desktop-only HUD. The screen's size ishtml.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()) whenflow.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 wheninput.aiming.- Sensitivity: about
0.0022radians 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.updatein 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, invp shotandvp 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)(aVector3) 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 toPlayerSync'sanglesso they interpolate the short way), and counters for one-off actions (shots: 12), neverfired: trueflags. - Shots that matter (damage, kills) are decided by one authority, the host. Send the shot (origin,
direction, the time on
link.now()) withlink.sendEvent; the host checks it against where it had everyone and broadcasts the hit withHostSync.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
- The pieces
- The map:
VoxelGrid - Moving:
FpsBody,fpsStep,FpsTuning - The view:
FpsCameraandreadIntent - Shooting: rays, hit boxes, spread, splash
- Netcode
- CPUs:
NavGridandPathFollower - Testing
- 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.yaw0 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 (
readIntentdoes it). - The camera turns by
(pitch, yaw + π, 0, 'YXZ')(FpsCameradoes it). - A stick is
{ fwd, side }(forward +, right +);input.move()has forward as−z(readIntentconverts).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
Volumeof the solid cells,meshVolume(vol, { voxel: 0.5 })at the grid's origin; Frag Island'sbuildStructures), or stamp what you draw into it:grid.addVolume(island.top, [island.origin[0], island.y - 2, island.origin[1]], blockIsSolid)puts anIsland's top slab in (floor top atisland.y), leaving its plants out. Share the land shape function between theIslandand your grid when you fill the floor yourself. fillcovers 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 (maxif none), and the face's normal.grid.sees(a…, b…): line of sight, stopped bygrid.opaquecells (every value but 0 unless you clear one:grid.opaque[GLASS] = 0sees through a window you still bump into;grid.solid[BUSH] = 0walks through a bush that still hides you).ray's last argument picks what stops it (grid.opaquefor 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
cellto 1. - A map players build and break (walls to blow open, bridges, cover that gets shot away) is a
World: aVoxelGridof blocks with rules, synced edits and chunked drawing, andfpsStepandNavGridrun on it as they are (vp docs world). grid.solidsays 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
fpsStepmoves in slices so nothing tunnels through a wall, even at 40 units/s on a 0.1 s frame; it climbsstep-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) andr.landedfeed the camera (section 4).launch(body, to, arc)throws the body toto(its feet) along an arcarcabove the higher end, and turns air control off forlaunchGraceso steering doesn't eat the arc: jump pads, launchers, a knock-up.onPad(body, pad): standing on aJumpPad({ x, y, z, half, to, arc }).body.ground,body.vx/vy/vzare 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'smovedis any{ stepped, landed }:fpsStep's result, or those two numbers passed on from a headless core that moves your body.readIntentgives{ fwd, side, jump, dyaw, dpitch, fire, alt, firePressed, altPressed, slot, wheel }:fire/altheld,firePressed/altPressedgone down this frame (semi-automatic guns, a scope toggle);fireonly whileinput.aiming(the pointer locked, or a pad or a touch screen: so the click that locks doesn't shoot),jumpisaction(Space, a pad's A, the touch JUMP button),slot0–8 for keys 1–9,wheel−1/0/+1. While a menu holds the input it's all idle.scale: fp.lookScalemakes 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), thenhold(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 ofupdate. 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
Menuand keep them withstorage(Gun Game'sOmenu). 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];
}
rayBoxtests 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.spreadDirwith 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);
NavGridmakes a standing spot on every 1-unit column with headroom, and joins neighbours you can walk to (withinstep), 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.5on a grid of half-unit cells. A half-unit column is narrower than a body, so it needs free cells beside it (the tuning'sradius, or passradius): a 1-unit doorway anywhere is a way through, a half-unit slit isn't. Four times the spots, so give the follower a smallerreach(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 thatnearestfinds nothing). To drive it yourself, the same frames on every run (the host, tests):const nav = NavGrid.start(grid, o)andnav.work(3)in each update until it returns true. linksare 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? }).PathFollowerwalks 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'sstep(stairs it walks up).goToonly 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.nodesfor 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.partialsays 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
fpsStepand 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'srules.test.ts), with people joining and leaving, the host leaving, androom.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 andbunx vp shotput 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
forwardOfandstickFor, and the camera'syaw + π. - 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);fpsStepdoes it in slices. Rockets and pellets: trace them withgrid.rayfrom the last position to the next. - Feet, not eyes. Rays start at
y + eye; hit boxes stand ony. - 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.yagainst a floor (Frag Island: −14) and count it as a death;groundBelow=-Infinitymeans there's nothing under you. - Don't call
view.rig.updatein 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
- The pieces
- The world:
World - Blocks and their rules
- Edits:
apply,allow, damage,onEdit - Drawing:
WorldView,BlockCursor,BlockFx - Building:
aimBlock, reach, bridging - Netcode:
WorldSync - Inside
Lockstep - Structures, maps and saves
- Performance
- Testing
- 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).
sizeis in cells;cellis a cell's size in world units (default 1: a block a unit, like anIsland) andoriginwhere 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.boxHitswork as they do on a grid, and bump into solid blocks only: water and plants (and blocks withsolid: false) aren't in the way.seeslooks past blocks that aren'topaque(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 (WorldSynccalls it when it starts), and everything after is what players changed:placed(x, y, z),reset()(back to the map, for a new round) andsaveDiff()(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 aLockstepworld'shash.- Storage is one array in
VoxelGridorder (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. |
rulesinWorldOptionssets any block's rule by id, the built-in ones too; it wins overblocks.world.rule(id)is the rule as the world has it.- Team colours: one
WOOLblock withtint: trueandpalette: teams.map((t) => t.color)beats a block per team (blocks are limited to 128 a game). - Glowing blocks (
lightin the GameBlockDef) light the world whenWorldViewhaslight(section 5). - Your own plants:
WEED: { top: 'mg_weed', plant: true, tint: true }(acutouttexture) 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'
allowis where the game's rules go: reach, teams, a build phase, cooldowns, an inventory, "not inside a player".WorldSyncasks 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'shealMs, 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, andundowhen the host turned yours down.e.kindisplace,break,hit(damaged, not broken),blastorundo;e.id/e.wasthe 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 inonEditon every client. For your own caches.world.blast(x, y, z, r, by)breaks everything breakable withinrcells (by the rules:unbreakableandplacedOnlyhold): TNT, a meteor, a sinkhole. One change, every cell in it anonEditwithkind: '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);
WorldViewmeshes the whole world on its firstupdate(orbuild()), then only the chunks that changed:budgetMsa frame (default 2; at least one chunk; real milliseconds, sovp check --longsees the frames players do), nearest the camera first. It looks exactly likemeshVolume(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 anIsland. However big the world, it's three draw calls: the chunks are batched (aBatchedMesheach 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.farhides chunks further away.shadows: falsefor a world that doesn't need them.statshas 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 youworldView.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'sblockLight(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,flowOfin/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: falsefor 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. Withsfxandsoundsit 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);
eyeandforwardare{ x, y, z }s: the camera's (for a first-person body,(x, y + eye, z)andforwardOf(yaw, pitch)'s three numbers);feetis 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.hitis the block (plants too, never water) with the face you look at (nx, ny, nz) and how far;aim.placeis the empty cell in front of that face, withinreach(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, andplaceis the cell beside the block you stand on, the way you face (aim.bridgedis true). Walk backwards clicking and you've built a bridge, the way players of every block game do. - Not inside a player:
aimBlockkeeps you out of your own way; for everyone else, check inworld.allowwithcellHitsBody(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
onEditfires, so the effects are instant), and go to the host in one message every 50 ms. The host checks each against the rules andallowwith its own view, applies what passes and answers. One it turns down is put back on your screen (onEditwithkind: '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,stampandblast(a bed coming back, a disaster). Clients never change the world themselves: only throughWorldSync. - 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.readyis true once they have it (and on the host once it has started): don't let a player edit before,editreturns 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
mayResumeandkeepWait, as inLockstep. - 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 withHostSync'skeep, passkeep: falsehere 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
hzbatches plus whole-world pieces at 20 a second while someone joins. Next to aPlayerSync(20) and aHostSync(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.pendingcounts your edits the host hasn't answered;ws.statshas 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
maxRateedits 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)
matchcounts cells that have a block in either (the build or the target):scoreis 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: truein the GameBlockDef) turn with the structure. - MagicaVoxel:
readVox(bytes, (i, r, g, b, a) => blockId)reads a.voxmodel 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, thenstampit, orpackStructureit once and ship the string. packsuits 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.stampandsetdon't go through the rules oronEdit: 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.updatep99 ~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; anfpsStep~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,
applyedits, check the rules (checksays why not).world.hash()must equalworld.rehash()(worked out from scratch). - Netcode: a
FakeRoomwith oneWorldSyncper link, every client editing, then assert every live client'sworld.hash()is the same andws.pendingis 0 (every edit answered). Put it through joins,leaveof the host,reload,drop/rejoinand latency up to 250 ms; countonEditkinds,ws.stats.undonefor turned-down guesses. The template'srules.test.tsdoes. vp checkplays with CPUs; give them a way to build (the template's bot bridges withaimBlock).
12. Pitfalls
- The same base everywhere. Build the map from the seed (never
Math.random), and don't change it afterWorldSyncstarts 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.seton a client isn't sent anywhere; on the host it is (everything the host changes goes out). allowsees 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 calledplace: then they happen once on every screen, undos included. useGameAssetsbeforeWorldView, so the view reads your blocks' textures.- One kept world a room:
WorldSync'skeepandHostSync'skeepoverwrite 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
- The format
- The legend
- Using the level
- Recipe: an arena
- Recipe: a heightmap island with scattered trees
- Recipe: a multi-floor first-person map
- Recipe: a symmetric team map
- Recipe: your own meanings (grid games, zones)
- Windows and bushes: sight
- Seeing it:
gridText - 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.GRASSis 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 andseed: the same island on every client, a different one per seed. Returnnullfor 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: truemakes 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 --blocksdraws 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
TextGridprints 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, aVolume, aStructure, aVoxelGrid) prints throughlegend(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 (theWorldknows 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
- The bar
- The design paragraph
- Principles
- Shapes that work
- Scoring
- Drama and comebacks
- Bots that feel human
- Anti-patterns
- 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.endper 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.bannerfor 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.bannerandflow.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:
StickyTargetkeeps 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
- Entry points
defineGame,GameContext,GameStage- The frame:
Flow - Input:
Controls,KEYS,Keys(short; see input.md) - The link
- Randomness and maths
- Netcode (short; see netcode.md)
- Bots
- Players:
Avatar,PoseSmoother - Stage, island, camera (short; see art.md)
- Voxels and textures (short; see art.md)
- Juice:
ease,Tweens,Spring, hitstop,Vfx,Fx,Popups,Bits, particles, labels - UI components (and the page's corner)
- Sound (short; see sound.md)
- Tests:
@voxelparty/sdk/test - Saving:
storage - 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.solidterrain,mats.actorcharacters/props/FX,mats.crossplants,mats.water; never dispose them), the baked sky forarenaStage, andicon: anyObject3Drendered to a PNG data URL for<img src>, lit and finished like the game on a transparent background (IconOptions:size96,yaw30,pitch25,pad0.08,fov0 = flat,bounds,keyto cache). It draws on the spot: once per icon, not every frame (art.md section 10).engine.wateris the water every water block is drawn with (setits look,fieldfloating shapes and river flow,ringfoam round things bobbing: water.md), andengine.qualitythe player's graphics setting ('low' | 'medium' | 'high', live: draw less on'low').players: PartyPlayer[]:{ name, color, look, human }, in seat order, index-aligned withlink.players.coloris the player's UI colour (16 of them; the first four are red#e23b3b, green#3fb34f, yellow#f2b51f, purple#8e4fd6).lookgoes tonew Avatar(mats.actor, look).humanmeans "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). Thedtandtyourupdategets are game time;time.realDtis the real frame.tween: Tweens: tweens on game time, updated after yourupdate, 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.
5. The link
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-hsays 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.setchanges 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.
getreturns a fresh copy each time: change it, thensetit 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.setthrows aTypeErrorfor a bad key or a value JSON can't hold (undefined, a function, a BigInt, a cycle; usedeleteto forget a key), and aRangeErrorwhen 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 emptystorage.vp devkeeps it, like the site does: Clear saves (or?fresh=1) starts over. Underbun testit's in memory:resetStorage()/resetStorage({ best: 40 })from@voxelparty/sdk/test; there's one per process, so every client of aFakeRoomshares 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(useControlsandKEYS); - the frame by hand:
Flow(the class),MinigameDef,MinigameContext,Minigame,MinigameResult,Stage,KIT_SOUNDS,MinigameMusic,playResults(usedefineGame); - 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(useuseGameAssets); - scene internals:
createCharacter,CHARACTERS,DEFAULT_LOOK,createClouds,SUN_DIR,addSky,particleUniforms,disposeTree(useAvatarandarenaStage); - the
SoundEngineclass,renderOfflineand 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
- Style guide
- The stage:
arenaStage - The ground:
Island - The camera:
CameraRig - Players:
Avatar, dressing up, and crowds (Crowd) - Voxel models:
Volume+meshVolume - Blocks and your own textures
- The texture rules (condensed)
- Juice and UI
- Icons and insets:
engine.icon,view.insets,Minimap - 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, paperrgba(255,252,245,.82). Player colours come fromplayers[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 floatingIslandis a ready-made ground, not a requirement: build any world from volumes and blocks. - First person:
arenaStagewithfov: 75, tilt: 0, players asAvatars (they're what others see). - A soft checkerboard
tinton the floor makes movement readable. - Glowing things (fire, lava, magic, gems, lanterns) get
glowon their texture; that feeds the bloom. For light that falls on the world around them, useview.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.
followfor 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.35withwalk += dt * 16whileav.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 = colormultiplies the body (a team shade, an ink splat, frozen blue);nullrestores it;av.opacity = 0.35for 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
yawyou 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). Tunebob,lean,lunge,pop,turn,stride. - Team colours: the model's
teamblocks go into a second mesh, tinted per unit. Paint them light (white or a pale cloth): the colour multiplies.colortints a whole unit;scalesizes 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 })orput(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 cutsmats.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).voxelis the world size of one voxel:1for terrain,1 / 9to1 / 14for props (the characters are 1/9), or[x, y, z]for a stretched model.originis 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 onmats.solid/water/crosswith 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:
textGridgives aVolume(and the spawns and goals as marks) to hand toaddVoxelMeshes, aVoxelGridand aWorldalike (vp docs levels). Volumealso hasget,setIfAir,inside, and ametabyte 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 palettesGRASS 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: trueand set each voxel's meta toturnTop(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.
- Size voxel models with
meshVolume(vol, { voxel: 1 / N, origin }). The mesher scales positions and texture coordinates, so one voxel shows about one texel. Nevergeometry.scale(...)a voxel geometry, and never shrink a voxel mesh with a constantmesh.scaleat build time. Runtime scale for animation (pop-ins, squash, 0.5×–2×) and instance matrices is fine. - One block as a cube:
blockGeometry(id, size)(sizea 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.EMBERfor fire, not the framedB.LANTERN).Bitsthrows if given a full-texture cube. - 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. - 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. - Keep glow small on small props: one or two glowing voxels (a gem tip), not the whole surface.
- 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
#1d2340outline.
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(aBox3in 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'sroot, 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 ofleft/right, one oftop/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. Optionsrect(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 abovetopor belowbottomis left off the map, so a roof, or the floor above the one you're on, goes bytop),scene,round,resolution,every,rotation. Methodsfit(w, d),centreOn(x, z),follow(point | null); setmap.rectto 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.yawevery 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), androof.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.5orevery: 2. - Keep insets out of the page's top-right corner (section 9).
11. Performance
- Target 60 fps on a laptop. Use
InstancedMeshfor anything repeated (coins, tiles, sheep): setcountandsetMatrixAteach frame, theninstanceMatrix.needsUpdate = true. It works on every voxel material (mats.solid,actor,water,cross), like a plainMesh.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), andinstanceColor.needsUpdate = truewhen 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 --longcounts 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 oneInstancedMesh. - Put every mesh and material in the scene when the stage is built (hidden, or an
InstancedMeshwithcount = 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 withintensity, 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 aspotfor 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 callfx.dispose(),popups.dispose(),ui.dispose(), unsubscribeonEvent/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
- What you get for free
- The look:
engine.water.set - Flow: a water block's meta
- Rivers:
flowFromPath - Falling water
- Floating things:
field,ring - The light: sky, night, rain, lamps
- Quality and phones
- Opting out:
style: 'classic' - 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
Worldon 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
auxattribute. Only meshes made bymeshVolume(and soaddVoxelMeshes,Island) orWorldViewhave 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 (aWorldViewdoes this by itself). - Shapes (
discs,bars) andflowAtare in world units fromat; for aWorld, 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
- What Flow already plays
sounds.tsand wiring- The Patch format
- Recipes
- The Song format and the theme
- 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.tsimports only from@voxelparty/sdk/coreand your own sound files: no game code, no@voxelparty/sdkmain entry (it throws under bun, andsounds.test.tsimports 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) isgame.ts's job, from the main entry.- Only presentation code plays sound (
game.ts, effects), neverrules.tsorbot.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.
Sfxdoes it:
First person and chase cameras hear from the camera instead: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) });ears(a three.js camera works as is) andat3d, 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 numbersnew Sfx({ near, reach })makes that curve the default forat3dand 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 aVoice. Keep the handle,voice.bend(semitones, glide)to retune it,voice.set({ vol, pan }, glide)to move its loudness and pan, andvoice.stop(release)when it should end, at the finish, and indispose(). 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.loopplays a sustained patch there, heard from the ears likeat3d, andsfx.update()every frame keeps its loudness and pan right as you move and turn. Out of earshot, or while thelivegate 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: 1is "a normal ambient bed" whatever the patch's layers add up to: a waterfall, a fire and a hum atvol: 1all sit at the same loudness. Usevolfor 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 whatvolis for. - Distance: full within
near(default 2), fading, silent atreach(default 16: a campfire heard within 10–15 units). A waterfall or a stampede carries further (reach: 40), wind everywhere isfalloff: 0. The olderfalloffstill 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); - 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
sound.playreturnsnullwhile audio is asleep (no user gesture yet, a hidden tab), so use?.on the handle.- Don't touch
sound.mutedorsound.setVolume: the platform owns the mix. vp checkcan'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'svolorreach).
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
vol0.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
- Playing effects
vfx.ts: your game's effects, and the rules of thumb- The look: chunky pixels and voxels, with real glow
- The layers
- Power: code any spell (your own pixel art, effects as code, nesting and flights, formations,
onDeath, GLSL,spawn, themes) - Recipes
- Why it's cheap, and stays cheap
- Seeing effects:
vp gallery - 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:
- 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.
- 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. - Give each moment its own silhouette. Ask what this thing looks like when it happens: a
cannonball digs a
craterand throws dirt; a slamcracks the ground; a claw leaves threeclawmarks; a splash throws drops andripples; a crit is aburststar; a heal rises in crosses. Rings are seasoning, not the dish. Ashockring 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 checkwarns when a game's effects lean on it (checkEffectSet), and when more than half of them carry a shock or soft ring at all. - 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
sketchEffectis only a starting point to rewrite. Put the game's palette on the whole set withthemeEffects. Audition every effect and change it until it belongs to this game.
- Name the export
EFFECTS.vp checkrunscheckEffecton every effectvfx.tsexports: typos, bad colours and layers that make nothing fail the check, and floods are warned about.vp galleryfinds 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 (
runeandboltread as magic,shardas 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:
pixelis texels a unit (16, the blocks' own; 8 is chunkier;0is 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
discring as a glow pool, aglowdome, a short flash. The pixels carry the shape. - Impacts leave the ground changed, not ringed:
crack(dark for stone and earth; glowing withblend: 'add'for lava, lightning or holy light),crater,scorch,splat,frost,claw; and throw the material:cubesorchunkparticles in the colour of what was hit. - A wave you want to show goes coloured and patterned (
rippledashes,runes,dash) or thin (shockwithwidth0.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:palettemoves every colour onto its nearest hue in your palette, keeping its lightness (whites and greys stay).voxel: trueturns falling pixels, shards, dots and leaves into cubes.speedmakes everything snappier or floatier without changing the distances.
restyledoes 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 forvfx.ts.sketchEffect({ verb, element, size }, seed)rolls a rough starting point for a moment (impactfrostbig,pickupgoldwith the key'scolor), 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.
checkEffectwarns past about 900 particles alive in one effect, and when many particles are over 5 units wide.
8. Seeing effects: vp gallery
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. Forgettingstop()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 checknotes 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
glow1.2–2. Keep 2.5+ for tiny, bright cores. - Beams need
to. Without one they strike down from 10 units aboveat, with a warning;to: 'sky'says you meant it. Emitters withdir: 'forward'and upright rings needdir. - Cubes land on
ground, which defaults toat's height. Play an explosion at chest height withground: 0. - Effects are
transparentand 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.
2. Links: vp share
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-openonly 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 shareagain and send the new links: old links keep playing the old version. - Share only after
bunx vp checkis 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):
- Open https://voxelparty.io (the store is the home page) and press Sign in (Discord) in the top bar.
- 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 fromgame.json'sdescriptionandtags(above): the user checks them and can change anything. Uploads made from the site while signed in belong to that account. Avp shareupload is anonymous until its claim link is opened signed in; nothing else claims it (dropping the same file on the site doesn't). - 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
- Autopilot: your CPU plays your seat
vp shot: scripted play and pictures- Film strips: movement in one picture
- The visual checks and the sound log
- What
vp checkadds - Scripted play by hand:
__vpon thevp devpage - Every model at once:
vp gallery - 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.solidandmats.actorthe 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 volumewwide at x 0 ends where the next starts, at xw, notw - 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 materialpolygonOffset, or mark the meshuserData.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, andvp check's phone runs): text under the touch controls ([data-vp-touch]) or in the notch's safe area: keep HUD insideenv(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 withPlayerSync.smoothorReckon. In a script:drive.track(true), hold, thendrive.trackReport()(throught.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).
7. Every model at once: vp gallery
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)ort.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.waitandt.until. - Pointer lock is granted in
vp shotandvp checkruns the way a browser grants it after a click (a headless browser never would), so a game withpointerLock: trueis locked onceplay()presses "Click to play", andlook()andclick()reachreadIntent. A game that locks at some other moment has to callinput.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 --longthat play doesn't have: work spread over frames by a time budget onperformance.now()(build a few ms a frame) runs whole in one frame when that clock stands still. Measure budgets withperfNow()from@voxelparty/sdk/core, the real clock (WorldViewandNavGrid.workdo).
SDK reference · vp docs gallery · gallery.md
The gallery: every model and texture, on pages you can read at a glance
A game makes dozens or hundreds of models (fish, weapons, toys, bosses, props), and play shows only
a few of them at a time. vp gallery draws all of them, with the game's own engine and
materials, on a few numbered pages, and checks them for you: broken or empty models, parts that
float loose, near-duplicates, budget outliers, floating models, textures that break the style.
After making or changing any art, run bunx vp gallery, read every page, fix what's flagged,
and include the pages in your report. Highly recommended.
Contents
gallery.ts: declaring the assets- Running it, and the pages
- The checks
- Iterating:
--diff, filters,--scene - Browsing:
vp dev ?gallery - Pitfalls
1. gallery.ts: declaring the assets
Put a gallery.ts next to index.ts. Its default export gets a Gallery and adds every model with
a builder: a function that returns a fresh three.js object, made by the same code the game uses.
Builders run only for the assets a page shows. gallery.ts isn't part of the game's package, so
nothing it imports ships to players.
// gallery.ts
import { Group, Mesh } from 'three';
import { useGameAssets } from '@voxelparty/sdk';
import type { Gallery } from '@voxelparty/sdk/test';
import { BLOCKS, TEXTURES } from './textures';
import { FISH, RODS, fishGeometry, rodGeometry } from './models';
export default (g: Gallery) => {
const ids = useGameAssets(TEXTURES, BLOCKS); // as create() does
const mesh = (geo) => new Mesh(geo, g.engine.mats.actor);
g.group('Fish', { scale: 'fit', ground: false }); // each fills its cell
for (const f of FISH) {
g.add(f.name, () => mesh(fishGeometry(ids, f)), {
tags: [f.rarity],
variants: { shiny: () => mesh(fishGeometry(ids, f, 'shiny')), golden: () => mesh(fishGeometry(ids, f, 'gold')) },
});
}
g.group('Rods', { scale: 'shared', ghost: 0.7 }); // one camera: true relative size, a ghost player
for (const r of RODS) g.add(r.name, () => mesh(rodGeometry(ids, r)));
g.add('Bobber', () => { // one that moves: a GalleryModel
const m = mesh(bobberGeometry(ids));
return { object: m, update: (t) => void (m.position.y = Math.sin(t * 3) * 0.05) };
}, { motionMs: 2000 });
g.textures('Pond textures', TEXTURES);
g.effects('Effects', EFFECTS); // vfx.ts: stills, and film strips with --motion
};
g. |
|
|---|---|
engine |
what create() gets as ctx.engine: mats (the game's voxel materials: actor, solid…), env, icon |
group(name, opts?) |
the assets added after it belong to it (before any: "Models") |
add(name, build, opts?) |
a model; its number (#37) stays the same run after run |
textures(name, defs) |
16×16 TexDefs: tiled 3×3 (seams show), with a cube for any block that uses them |
effect(name, effect, opts?) |
a visual effect (an Effect from vfx.ts): its busy moment on a dark plate, its film strip on the motion page |
effects(group, record, opts?) |
a group of effects, each one added: g.effects('Spells', EFFECTS) |
build returns an Object3D (a Mesh, a Group, an Avatar's root, an InstancedMesh), or a
GalleryModel { object, update?(t) } for one that moves by itself (an idle, a spin: t in seconds).
Animated materials (water, glow pulses) move by themselves; nothing to add.
Group options (GalleryGroupOptions):
scale:'fit'(default) fills each cell with the model, for detail.'shared'uses one camera for the whole group, so relative size is true, with a ghost player and a 1-unit grid on the ground. Use it for things that stand side by side in play: toys, units, buildings, bosses.ghost:falsefor none; a number is the players'Avatarscale in your game (default 1).ground(default true): the models stand on the ground, so their base should be at y = 0. False for held items (their origin is the grip), projectiles, pickups, flyers and effects.yaw,pitch: the three-quarter view (default 30° round, 25° up; 0 looks at the model's front, +z).tiny: how tall these are on screen in play, in px, for--tiny.pieces: how many separate pieces each model is meant to have (default 1; more is flagged as a part that floats free).'any'for a group of effects or scattered things.
Asset options (GalleryAssetOptions): tags (shown and filterable), variants (other looks,
{ name: build }: one row each on the variants pages), ground, tiny and pieces (override the
group's), motionMs (how long its motion strip spans, default 2000), and budget (its own triangle
budget: flagged above it, instead of being compared with its group).
Effects (GalleryEffectOptions): tags, color (play it in a team's colour), scale,
colors (more colours, one variant each: { red: '#e23b3b', blue: '#3b8ee2' }), loop (seconds a
looping effect runs before it's stopped, default 2.5) and at (the still's moment; default early
in its life, at most 0.4 s, or 1.2 s into a loop). An effect replays itself exactly to any moment,
so its pictures are the same every run. On the motion page its six frames bunch up early (1/36,
4/36, 9/36… of the way), since a burst is over in a blink and its smoke lingers. Beams go from
upper left to lower right; trails circle so they show. The model checks (ground, pivot, pieces,
triangles, look-alikes) don't apply to effects; checkEffect's findings are listed instead, and
an effect with nothing alive at its still moment is flagged. --bg dark suits glowing effects.
See vfx.md.
A part that's meant to float (a halo, sparkles, an orbiting shield, a spell's projectile) can also
be named: an object whose name starts with float or orbit (halo.name = 'orbitHalo') is left
out of the pieces check, with everything under it. Naming it in the model code keeps the reason
next to the part; pieces: 3 says it from gallery.ts (a squadron of three planes).
Your models already have a home in the game's code: export the builders (fishGeometry(ids, f))
from a models.ts and call them from both places. If the game puts a model together from parts
(a boss's body and wings), do it the same way in the builder.
2. Running it, and the pages
bunx vp gallery # overview (40 a page), variants, textures, the checks
bunx vp gallery --turnaround # + front, back, left, right, top and ¾ of each (8 a page)
bunx vp gallery --all --bg dark # every kind of page, on a dark background
It builds the game, opens it headless and muted (on its title card), runs gallery.ts inside the
game's frame, and writes PNGs to .vp/gallery/ with index.json (every asset: its number, name,
size, triangles, what the checks said, and which page and cell it's on) and report.json. Open
the pages and read them. Every page is at most about 1,536 px on its long side, so a reading
model sees it without shrinking it and every label stays legible.
| Page | Flag | What's on it |
|---|---|---|
overview-N.png |
always | 40 a page (8×5), a three-quarter perspective view each. --page N for N a page. |
variants-N.png |
when there are variants | one asset a row: its base look, then each variant |
textures-N.png |
when there are textures | 64 a page, each tiled 3×3, nearest-neighbour, a cube for block textures |
turnaround-N.png |
--turnaround |
8 a page: front, back, left, right, top (orthographic, one scale) and ¾ |
silhouette-N.png |
--silhouette |
solid black shapes: does each read by its outline alone? |
motion-N.png |
--motion |
6 frames over time of each thing that moves (animated materials, update) |
tiny-N.png |
--tiny [px] |
each at its size on screen in play (tiny, else 32 px), and the same pixels blown up |
diff-N.png |
--diff |
before and after, for what changed since the last run |
unregistered-N.png |
--scene |
models the game drew that gallery.ts doesn't declare |
Every cell has the asset's number (#37), its name, its size in units and voxels (a voxel model's
grid is worked out from its vertices), triangles, draw calls and tags. A red outline and a ⚠
badge mark a cell a check flagged. Numbers are kept in .vp/gallery/numbers.json: a new asset
gets the next free number, so "fix #37" means the same model tomorrow.
Backgrounds: --bg light (default), dark, or night (dark, with the light turned down): glowing
voxels only show on dark ones. --all draws every kind of page.
3. The checks
Warnings are printed (⚠ #22 Moth: it floats 0.08 u above y = 0…) and in report.json. A builder
that throws is a real failure (✖, and the exit code says so); the rest are warnings, and each is
worth fixing or deliberately answering.
- Broken: a builder that throws; no meshes; everything hidden or fully transparent; a picture that came out empty (inside-out faces, zero size); NaN positions; a pivot off to one side (it turns and scales round a point away from the model); floating above y = 0, or sunk below it, in a group that stands on the ground.
- Loose parts: "#12 Scythe: 2 separate pieces (a part floats free?): besides the biggest,
0.4×0.4×0.1 u at (-0.45, 0.6, 0)". Each model's triangles are joined into connected pieces:
triangles that share a corner or touch (voxels meeting face to face, edge to edge or corner to
corner, two meshes in contact), and a piece shut inside another (an eye set in a head). More
pieces than the model's
pieces(default 1) is flagged, with the loose ones' size and where they are, and the cell's badge says "⚠ 2 pieces". A blade a voxel off its handle, wheels beside a body, a fin that doesn't reach the fish. Specks (under a tenth of the model's size and 2% of its surface) don't count; hidden objects and parts namedfloat…ororbit…are left out; anInstancedMesh's instances each count. Meant to be apart:pieces: nor'any', or name the part. - Near-duplicates: "#41 and #88 look 94% alike": silhouettes compared by perceptual hash and overlap, and colours cell by cell. Groups of look-alikes are listed together. The most useful check for libraries built in code: 54 weapons from one function drift into twins. Models with the same silhouette in other colours are listed once as recolours (a note, not a ⚠): fine for team colours on purpose, flat for a rarity ladder where each tier should look new.
- Budget outliers: triangles judged by size: the group's median triangles per unit² of
bounding box, times this model's box, is what it's expected to have, and 4× that (and 1,000
more) is flagged. A 6-unit log beside lily pads is fine; a 50k-triangle pebble isn't. An asset
with a
budgetis held to that instead. Draw calls at 3× the group's median (and 4 more). Fix: merge the faces (meshVolumedoes), fewer voxels, or the parts into one geometry. - Style: textures that aren't 16×16, framed pictures not marked
decal: true, a block texture with see-through pixels that isn'tcutout, more than 160 textures (a game's limit); a model that nearly vanishes against the background; one that's a speck at its size in play (--tiny).
vp check runs the gallery (overview, textures and the checks) whenever there's a gallery.ts,
and suggests one when a game clearly makes many models without one.
4. Iterating: --diff, filters, --scene
--diff: compares with the last run and draws before/after pages of only the assets that changed (new geometry, a new material, or a picture that no longer looks the same), were added or were removed. Run the gallery once, change the art, runvp gallery --diff.--group <glob>,--only <glob>(a name),--tag <tag>: just some of them, with their usual numbers.vp gallery --only "Sword*" --turnaroundto work on one family. Textures are left out of filtered runs unless you add--textures.--scene: plays the game on autopilot for 30 seconds, collects every distinct model it drew, and lists the onesgallery.tsdoesn't declare ("14 models are in the game but not in your gallery"), with a page of them. Players' bodies, particles and anything bigger than 20 units (the level) are left out.
5. Browsing: vp dev ?gallery
With bunx vp dev running, open http://127.0.0.1:5180/?gallery: the whole library over the
game, in a grid with search (names, groups, #37) and tag filters. Click one to turn it (drag) and
zoom (wheel), with animated materials moving live; N switches day and night, C (or "Compare
with…") puts a second one beside it, Esc goes back. It rebuilds on save like the game, and
uses the numbers of your last vp gallery run.
6. Pitfalls
import type { Gallery } from '@voxelparty/sdk/test': types only. The test entry is for bun tests, so a value import from it fails in the game's frame.- "a builder returned nothing": return the object (
() => mesh(geo)), not a statement block that forgets to. - Everything floats or is sunk: your models aren't built with their base at y = 0 (the voxel
origin:
meshVolume(v, { voxel, origin: [sx / 2, 0, sz / 2] })), or they're not meant to stand (ground: false). - Shared-scale cells look empty: thin or small things (weapons, coins) are specks at true size
next to a player. Use
'fit'for those,'shared'for things that stand side by side. - "2 separate pieces" on a model that's meant to be apart (formations, orbiting bits, a
boss's floating hands): say so with
pieces: 2(or'any') on the asset or its group, or name the partfloat…/orbit…. If it isn't meant, move the part a voxel closer: in play it floats too. - A builder that depends on the game running (reads
ctx, the world, the players) won't work here: builders get onlyg.engine. Pass what they need, as the game does to its model code. - Glow only shows against dark: check glowing things with
--bg darkor--bg night.
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 <= 2andmax >= 4(a party can be 2, 3 or 4, with CPUs in empty seats).maxcan be higher for sessions; in a party it's never more than 4.inputmust be[]: the board stays pick-up-and-play, so move +actiononly (no mouse aiming, no extra keys; a left click still counts asaction).
What a board minigame must do
Play every seat as a CPU. Parties fill empty seats with CPUs, and
vp checkplays with CPUs only (link.youis null,seats.youis -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 andNaNfor the rest. A round the server's hard stop has to end is a bug (vp checkfails it).
- host-authoritative and discrete games: the host calls
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. UsingSeatsindices 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.isHostturns 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 (
HostSyncdoes:vp docs netcode), runs every rule the old host ran, and reports the scores.vp checkplays 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 checkall ✔ with no ⚠, screenshots looked at, well under 1 MB, 60 fps.