# 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:

```sh
bunx --package https://cdn.voxelparty.io/sdk/voxelparty-sdk-latest.tgz vp init my-blocks --blocks
```

It's a bridge duel: two islands over the void, a goal hole on each, build across and drop into
theirs, with CPUs that bridge and break, and every piece below wired together. Grow it into what
you want: combat (`vp docs fps`), a shop (`vp docs menus`), bigger maps, more teams.

## Contents
1. The pieces
2. The world: `World`
3. Blocks and their rules
4. Edits: `apply`, `allow`, damage, `onEdit`
5. Drawing: `WorldView`, `BlockCursor`, `BlockFx`
6. Building: `aimBlock`, reach, bridging
7. Netcode: `WorldSync`
8. Inside `Lockstep`
9. Structures, maps and saves
10. Performance
11. Testing
12. Pitfalls

---

## 1. The pieces

| Piece | Where | What |
|---|---|---|
| `World` | `/core` | The blocks: a `VoxelGrid` (collision, rays, `sees`, `groundBelow`, `fpsStep`, `NavGrid` all work on it) with rules, edits, damage, a hash, structures and compact forms |
| `WorldSync` | `/core` | Everyone's edits in the host's order: your own shown at once, joiners and host changes handled |
| `aimBlock`, `cellReach`, `cellHitsBody`, `canFill` | `/core` | What you point at, where a block would go, reach, bridging, "not inside a player" |
| `blockIds`, `B`, `BlockRule` | `/core` | Your blocks' ids headless (the same as `useGameAssets` gives), the built-in blocks, their rules |
| `packStructure`, `unpackStructure`, `compareStructures`, `readVox` | `/core` | Structures as strings, how alike two are, and MagicaVoxel models |
| `WorldView` | `@voxelparty/sdk` | The world on screen in three draw calls: chunks re-meshed within a few ms a frame, nearest first; block light |
| `BlockCursor` | `@voxelparty/sdk` | The outline on the block you aim at, a ghost where yours would go, cracks on blocks being broken |
| `BlockFx` | `@voxelparty/sdk` | Bits of the block when it's placed, hit or broken (in its own colour), and the sounds |

Everything in `/core` runs headless: rules, bots and netcode test under `bun test`.

## 2. The world: `World`

```ts
import { B, World, blockIds } from '@voxelparty/sdk/core';
import { BLOCKS } from './textures';     // your GameBlockDefs (section 3)

const ids = blockIds(BLOCKS);            // { WOOL: 128, BED: 129, … }: what useGameAssets(TEXTURES, BLOCKS) returns
const world = new World({ size: [64, 32, 64], blocks: BLOCKS, rules: { [B.STONE]: { hp: 3 } }, palette: ['#e23b3b', '#2f6fed'] });
world.fill(0, 0, 0, 64, 4, 64, B.STONE);  // cells here (cell 1, origin 0): from the low corner up to, not including, the high one
world.set(10, 4, 10, ids.BED, 1);         // one cell: a block and its meta
world.get(10, 4, 10);                     // the block (0 is air, and outside the world)
world.metaAt(10, 4, 10);                  // its meta
world.markBase();                         // the map is done: what players change is measured from here
```
- **Cells.** A cell holds a block id (0 air) and a meta byte (a tint block's colour, a turning
  top's direction). `size` is in cells; `cell` is a cell's size in world units (default 1: a
  block a unit, like an `Island`) and `origin` where cell (0, 0, 0)'s low corner sits (default
  the world's origin). With the defaults, cell (x, y, z) is the unit box from (x, y, z).
- **It's a `VoxelGrid`.** `fpsStep(world, body, …)`, `new NavGrid(world)`, `world.ray`,
  `world.sees`, `world.groundBelow`, `world.boxHits` work as they do on a grid, and bump into
  solid blocks only: water and plants (and blocks with `solid: false`) aren't in the way. `sees`
  looks past blocks that aren't `opaque` (glass) and not past ones that are (a bush you walk through).
- **Build the same map on every client**: from `link.seed`, or from a packed map you ship
  (section 9). That's the **base**: `markBase()` remembers it (`WorldSync` calls it when it starts),
  and everything after is what players changed: `placed(x, y, z)`, `reset()` (back to the map, for
  a new round) and `saveDiff()` (what a joiner is sent) all measure from it.
- **`world.hash()`** fingerprints every block and meta, kept up to date as cells change, so it
  costs nothing: compare clients in tests, and it's a `Lockstep` world's `hash`.
- Storage is one array in `VoxelGrid` order (x fastest, then z, then y): any cell is one index
  away (`world.cells[world.idx(x, y, z)]`). Chunks of 16³ are the unit of meshing and packing;
  `world.version[chunk]` goes up whenever a chunk (or a cell next to it) changes.
- Memory: 2 bytes a cell (4 with a base): a 256 × 64 × 256 world is 16 MB.

## 3. Blocks and their rules

A block's look is its `GameBlockDef` (`vp docs art` §7); its behaviour in a world is its **rule**,
in the same long form:
```ts
export const BLOCKS = {
  WOOL: { top: 'bb_wool', tint: true, hp: 2 },                    // a team's colour from the meta
  BED: { top: 'bb_bed_top', side: 'bb_bed_side', hp: 4, placedOnly: true, drop: 0 },
  END: { top: 'bb_end', hp: 6 },
  BEDROCK: { top: 'bb_bedrock', unbreakable: true },
  GLASS: { top: 'bb_glass', opaque: false },                      // you bump into it, and see through it
  BUSH: { top: 'bb_bush', solid: false, opaque: true },          // you walk through it, and hide in it
} satisfies Record<string, GameBlockDef>;
// game.ts: const ids = useGameAssets(TEXTURES, BLOCKS);   rules.ts: const ids = blockIds(BLOCKS);
// both: new World({ size, blocks: BLOCKS, rules: { [B.STONE]: { hp: 3 }, [B.GRASS]: { hp: 1 } }, palette })
```
| Rule | Default | Meaning |
|---|---|---|
| `hp` | 1 | Damage it takes to break: a `hit` does 1 (or its `dmg`). 1 breaks in one hit. |
| `drop` | itself | The block it drops (`WorldEdit.drop` on a break): give it to the breaker's inventory. 0 for nothing. |
| `unbreakable` | false | Nothing breaks it: bedrock, the map's frame. |
| `placedOnly` | false | Only a block a player placed breaks: the map's own stay (the teams' home islands), what players build doesn't. |
| `tint` | false | Its colour comes from the cell's meta: meta 1 is `palette[0]`, 2 `palette[1]`… (0: untinted). Paint the texture light (near white): the colour multiplies it. |
| `solid` | true (water, plants: false) | You bump into it and rays stop at it. |
| `opaque` | the same as `solid` | It blocks sight (`sees`): glass is `opaque: false`, a bush you hide in `solid: false, opaque: true`. |

- `rules` in `WorldOptions` sets any block's rule by id, the built-in ones too; it wins over `blocks`.
- `world.rule(id)` is the rule as the world has it.
- Team colours: one `WOOL` block with `tint: true` and `palette: teams.map((t) => t.color)` beats
  a block per team (blocks are limited to 128 a game).
- Glowing blocks (`light` in the GameBlockDef) light the world when `WorldView` has `light` (section 5).
- **Your own plants**: `WEED: { top: 'mg_weed', plant: true, tint: true }` (a `cutout` texture) is
  drawn as two crossed quads that sway, like the built-in tall grass, and you walk and see through
  it; `plant: 'hanging'` hangs from the block above (vines, roots). Paint the picture growing from
  the bottom edge of the tile.

## 4. Edits: `apply`, `allow`, damage, `onEdit`

A player's change is a `BlockEdit`:
```ts
{ 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:
```ts
world.check(e, by);            // why not: '' if it can happen, or 'outside' 'taken' 'empty' 'unbreakable' 'placedOnly' 'tint' 'bad' 'denied'
world.apply(e, by, now);       // do it by the rules: the WorldEdit, or null. `now`: the clock damage heals by
world.allow = (e, by, w) => inReach(by, e) && !blocksAPlayer(e) && myTurn(by);   // your rules on top of the blocks'
```
- **`allow`** is where the game's rules go: reach, teams, a build phase, cooldowns, an inventory,
  "not inside a player". `WorldSync` asks it on the host (the host's answer counts) and on your
  own client before it shows your edit, so write it from what every client knows.
- **Damage** adds up across hits and players: `world.damage(x, y, z)` is 0..1 (for cracks), and it
  heals when nobody hits the block for a while (`WorldSync`'s `healMs`, default 5 s; `world.heal(now, ms)`
  yourself otherwise). Replacing or breaking a block clears it.
- **`world.onEdit((e) => …)`** hears every edit: yours as you make it, everyone else's as the host
  sends it, and `undo` when the host turned yours down. `e.kind` is `place`, `break`, `hit`
  (damaged, not broken), `blast` or `undo`; `e.id` / `e.was` the block now and before, `e.meta`
  (a tint block's team), `e.by` (a player id or null), `e.drop`, `e.damage`. Effects, sounds and
  game logic ("red's bed is gone") go here, so they happen exactly once on every client.
- `world.onChange((x, y, z, id, was) => …)` hears every cell that changes, however: edits, and
  cells set directly (`set`, `fill`, `stamp`, `reset`, loads), which aren't edits and are quiet in
  `onEdit` on every client. For your own caches.
- **`world.blast(x, y, z, r, by)`** breaks everything breakable within `r` cells (by the rules:
  `unbreakable` and `placedOnly` hold): TNT, a meteor, a sinkhole. One change, every cell in it an
  `onEdit` with `kind: 'blast'`.

## 5. Drawing: `WorldView`, `BlockCursor`, `BlockFx`

```ts
import { BlockCursor, BlockFx, WorldView, useGameAssets } from '@voxelparty/sdk';

const ids = useGameAssets(TEXTURES, BLOCKS);              // first: the blocks' textures
this.worldView = new WorldView(world, engine.mats, { light: true });   // options below
this.view.scene.add(this.worldView.group);
this.cursor = new BlockCursor(world);
this.view.scene.add(this.cursor.group);
this.bits = new BlockFx(world, engine.mats.actor, { sfx: this.sfx, sounds: { place: SOUNDS.place, break: SOUNDS.crunch, hit: SOUNDS.tap } });
this.view.scene.add(this.bits.mesh);
world.onEdit((e) => this.bits.burst(e));

// every frame, after the camera moved:
this.worldView.update(this.view.camera);   // re-mesh what changed, nearest first, within the budget
this.cursor.show(aim, holdingABlock);      // section 6
this.cursor.update();                      // cracks follow the damage (everyone's)
this.bits.update(dt);
```
- **`WorldView`** meshes the whole world on its first `update` (or `build()`), then only the
  chunks that changed: `budgetMs` a frame (default 2; at least one chunk; real milliseconds, so
  `vp check --long` sees the frames players do), nearest the camera first. It looks exactly like `meshVolume` (ambient occlusion, textures tiling across blocks,
  swaying plants, animated water; seams match), on the shared materials, so x-ray, moods and
  shadows work as on an `Island`. However big the world, it's **three draw calls**: the chunks
  are batched (a `BatchedMesh` each for blocks, water and plants), each chunk culled by the camera
  on its own, and a re-meshed chunk rewrites and re-sends only its own part. `far` hides chunks
  further away. `shadows: false` for a world that doesn't need them. `stats` has the counts and
  timings. **`dispose()` when it's done**: the batches and the world's water field go with it, and
  nothing of the SDK keeps the World's blocks alive after, so a game that builds a new World every
  run (a dungeon, an expedition) frees the last one's by disposing its view.
- **Big worlds: `stream`.** A world too big to mesh whole (a 256 × 448-unit moor of half-unit
  cells would be 200 MB of vertices) streams round the player: `new WorldView(world, mats, {
  stream: 44 })` meshes only the chunks whose centre is within 44 units (across, not up) of the
  focus you pass each frame, `view.update(camera, player.position)` (default: the camera), nearest
  first within the budget, and frees chunks that fall a quarter further behind; they're meshed again,
  the same, when you come back. Size it to what the camera sees plus a margin. Its water's field
  (depths, edges, flow) is baked the same way, a column of chunks at a time as they come in, never
  the whole world's at once (that alone is a third of a second for the moor).
- **Block light** (`light: true`): glowing blocks and lights you `worldView.light.add({ x, y, z,
  color, reach })` light the world (players walking past too), and when a block is placed or
  broken only the lights that reach it are lit again, only over their reach, and only that box
  goes to the GPU. 20 bytes a cell: keep lit worlds under ~4 million cells. It replaces the
  stage's `blockLight` (one at a time).
- **Water**: water blocks are drawn as the SDK's water (`vp docs water`): depth, foam where they
  meet blocks, glints, caustics on the bed. The view bakes the world's water field on its first
  build and, when blocks change, again only under the chunks that changed, in the same budget, so
  a lake players dig or fill keeps its banks' foam. A water cell's **meta is its flow**: bits 0–2
  the direction (0 N = −z, clockwise), 3–5 the speed (0.3 units a second a level), bit 6 "falling
  water lands here": `world.set(x, y, z, B.WATER, flowMeta(2, 4))` flows east (`flowMeta`,
  `flowOf` in `/core`). Colours and the rest: `ctx.engine.water.set({ depthTint: 'tropical' })`.
- **`BlockCursor`**: an outline on the block you aim at, a translucent ghost where yours would go
  (`show(aim, placing)`; `ghost: false` for none), and five stages of cracks on every damaged
  block, drawn as 16×16 pixel art in five draw calls.
- **`BlockFx`**: bits of the block flying (its texture's colour, a tint block's team colour): a
  puff when placed, chips when hit, chunks when broken, two bits a cell for a blast. One draw call.
  With `sfx` and `sounds` it plays them at the block (`at3d`); a blast plays one sound, not a
  hundred.

## 6. Building: `aimBlock`, reach, bridging

```ts
import { aimBlock, cellHitsBody } from '@voxelparty/sdk/core';

const aim = aimBlock(world, eye, forward, feet, { reach: 5 }, this.aim);   // reuse one object a frame
if (aim.hit && breaking) ws.hit(aim.hit.x, aim.hit.y, aim.hit.z);        // the block you look at
if (aim.place && placing) ws.place(aim.place.x, aim.place.y, aim.place.z, ids.WOOL, team);
```
- `eye` and `forward` are `{ x, y, z }`s: the camera's (for a first-person body, `(x, y + eye, z)`
  and `forwardOf(yaw, pitch)`'s three numbers); `feet` is the body (for reach, bridging and not
  placing inside yourself), or null. A mouse game casts from the pointer: `input.ray(camera)`
  gives the origin and direction.
- `aim.hit` is the block (plants too, never water) with the face you look at (`nx, ny, nz`) and
  how far; `aim.place` is the empty cell in front of that face, within `reach` (eye to the
  block's nearest point, default 5), and never inside your body (`radius`, `height`: the FPS kit's
  by default). Looking at a plant, you'd replace it.
- **Bridging** (`bridge`, default on): look out past the edge you stand on into the air, or
  straight down at the block under you, and `place` is the cell beside the block you stand on, the
  way you face (`aim.bridged` is true). Walk backwards clicking and you've built a bridge, the way
  players of every block game do.
- **Not inside a player**: `aimBlock` keeps you out of your own way; for everyone else, check in
  `world.allow` with `cellHitsBody(world, e.x, e.y, e.z, body)` for each body (as the host sees
  them). `cellReach(world, eyeX, eyeY, eyeZ, x, y, z)` for a reach check there too.
- CPUs aim with the same function: from their eye along their facing.

## 7. Netcode: `WorldSync`

```ts
import { WorldSync } from '@voxelparty/sdk/core';

const world = buildMap(link.seed);            // the same base on every client
world.allow = (e, by) => …;                    // section 4
const ws = new WorldSync(link, world);         // options: hz, healMs, keep, mayResume, keepWait, maxRate, channel
// your edits (false: not allowed, or not ready yet):
ws.place(x, y, z, ids.WOOL, team);   ws.hit(x, y, z);   ws.break(x, y, z);   ws.edit(e);
// the host, for a CPU:
ws.place(x, y, z, ids.WOOL, team, cpuPid);
// every frame:
ws.update();
// host: back to the map for everyone (a new round)
ws.reset();
```
How it plays out:
- **Your edits show at once** (and `onEdit` fires, so the effects are instant), and go to the host
  in one message every 50 ms. The host checks each against the rules and `allow` with its own view,
  applies what passes and answers. One it turns down is put back on your screen (`onEdit` with
  `kind: 'undo'`), the same moment you'd have seen it from anyone else.
- **The host sends results, not requests**: the new block in each changed cell, who did it,
  damage, and which of each player's edits it has answered, in numbered batches (at most `hz`,
  default 15, a second, and only while something changes), with its world's hash. Every client
  applies the same batches in the same order, so every world is the same. A client whose hash
  differs (it can't happen, but) asks for the whole world and carries on.
- **On the host, anything that changes the world goes out**: players' edits, the CPUs', and your
  own `world.set`, `fill`, `stamp` and `blast` (a bed coming back, a disaster). Clients never
  change the world themselves: only through `WorldSync`.
- **Joiners and reloads** build the map, then get what changed since (`saveDiff`: only the cells
  that differ, packed) sent to them alone in pieces of 12 KB, paced under the rate limit, while the
  host carries on; batches that come meanwhile wait and then apply. `ws.ready` is true once they
  have it (and on the host once it has started): don't let a player edit before, `edit` returns
  false anyway. A heavily edited 256 × 64 × 256 world (46 000 changed cells) is 117 KB: 11 pieces,
  about a second.
- **Host changes.** Everyone has the world, so the new host carries on from its own, in a new
  epoch that names the batch it follows on from. Edits the old host never answered are sent to it
  again; a batch the old host sent just before it went is taken in, not lost. A host that reloads
  asks the others (and the room's kept copy) for the world before it starts: that's `mayResume`
  and `keepWait`, as in `Lockstep`.
- **Kept with the room.** The host keeps the world with the room (`link.keep`) about once a second
  while it fits in 64 KB, for a host that reloads into an empty room. There is one kept world per
  room: if you keep your own with `HostSync`'s `keep`, pass `keep: false` here and put the world in
  yours:
  ```ts
  const ws = new WorldSync(link, world, { keep: false });
  const sync = new HostSync<Snap, Ev>(link, {
    valid: isSnap,
    keep: { save: () => ({ m: match.save(), w: ws.save() }), load: (d) => { match.load(d.m); ws.load(d.w); } },
  });
  ```
- **Budget.** A client sends at most 20 edit messages a second (while it edits), the host at most
  `hz` batches plus whole-world pieces at 20 a second while someone joins. Next to a `PlayerSync`
  (20) and a `HostSync` (15) that stays under the 60 a second a client may send. With 16 players
  placing 10 blocks a second each, a batch is under 300 bytes: about 14 bytes a change, and 30 an
  edit to send yours.
- `ws.pending` counts your edits the host hasn't answered; `ws.stats` has batches, changes,
  undone, rejected, whole worlds, hash checks and desyncs for your tests.
- A player can't flood the world: the host takes at most `maxRate` edits a second from each
  (default 40) and turns down the rest.

## 8. Inside `Lockstep`

In a `Lockstep` game (`vp docs netcode` §10) the world is part of the lockstep world and edits are
orders; don't use `WorldSync`. `World` is deterministic for it:
```ts
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

```ts
const house = world.capture(x, y, z, 7, 6, 7);          // a box of blocks (low corner, size)
world.stamp(house, x, y, z, turns);                      // back in, turned quarter turns (1: +x faces +z); { air: false } keeps what's there
world.match(house, x, y, z, turns);                      // { match, total, score }: how good a copy is (a copy-the-model race)
compareStructures(a, b);                                 // the same for two structures
const s = packStructure(house); unpackStructure(s);      // a string: ship targets and prefabs with the game
const map = world.pack(); other.unpack(map);             // a whole world as a string (runs of blocks): a map, a save
const d = world.saveDiff(); other.loadDiff(d);           // what changed since the base (same size and base)
```
- `match` counts cells that have a block in either (the build or the target): `score` is the share
  where they're the same block and meta. Air in both doesn't count, so a small target in a big plot
  scores fairly.
- Turning blocks (`turns: true` in the GameBlockDef) turn with the structure.
- **MagicaVoxel**: `readVox(bytes, (i, r, g, b, a) => blockId)` reads a `.vox` model into a
  structure (its z is up; each palette colour becomes the block you pick, 0 leaves it out). Build a
  map or a prefab there, then `stamp` it, or `packStructure` it once and ship the string.
- `pack` suits maps: runs of blocks, so a 256 × 64 × 256 terrain is ~330 KB and a small arena a few
  KB. Build maps in code from the seed where you can; ship a packed string when they're hand-made.
- `stamp` and `set` don't go through the rules or `onEdit`: they're the map's, or the host's.

## 10. Performance

Measured (under bun) on a 256 × 64 × 256 world of hills, water, trees and
grass (1024 chunks, 600 000 triangles):
- The first full mesh: ~150 ms. A busy surface chunk re-meshes in 0.08 ms; the worst possible
  chunk (a 3D checkerboard, 12 288 faces) in ~0.55 ms.
- 16 players placing and breaking 10 blocks a second each: `WorldView.update` p99 ~1.6 ms a
  frame, hardly a chunk ever waiting for the next frame.
- Three draw calls for the whole world: ~520 chunk parts in a first-person view, all 1 330 with
  the whole map in view, culled chunk by chunk.
- A ray (`target`) ~0.3 µs; an `fpsStep` ~0.6 µs.
- A late joiner to the same world with 46 000 cells changed: 117 KB, ready ~270 ms after joining
  at 80–120 ms latency.
- Block light: an edit next to a lantern ~1 ms; the first bake of 300 lanterns ~50 ms.

So: re-mesh as much as you like, but keep the world's size sensible (a few million cells), and use
`far` for big open worlds (a far chunk is still triangles to draw).

## 11. Testing

- Rules, bots and netcode are headless: build the world in a test, `apply` edits, check the rules
  (`check` says why not). `world.hash()` must equal `world.rehash()` (worked out from scratch).
- Netcode: a `FakeRoom` with one `WorldSync` per link, every client editing, then assert every
  live client's `world.hash()` is the same and `ws.pending` is 0 (every edit answered). Put it
  through joins, `leave` of the host, `reload`, `drop`/`rejoin` and latency up to 250 ms; count
  `onEdit` kinds, `ws.stats.undone` for turned-down guesses. The template's `rules.test.ts` does.
- `vp check` plays with CPUs; give them a way to build (the template's bot bridges with `aimBlock`).

## 12. Pitfalls

- **The same base everywhere.** Build the map from the seed (never `Math.random`), and don't
  change it after `WorldSync` starts except on the host. A client that changes its own world
  desyncs (it heals, at the cost of a whole world).
- **Clients edit through `WorldSync`.** `world.set` on a client isn't sent anywhere; on the host it
  is (everything the host changes goes out).
- **`allow` sees the world as this client has it.** On the host that's the truth; on a client it
  decides whether to show the edit at once. Keep it to what every client knows (positions as
  drawn, teams, the phase), and let the host's answer win.
- **Effects in `onEdit`**, never where you called `place`: then they happen once on every screen,
  undos included.
- **`useGameAssets` before `WorldView`**, so the view reads your blocks' textures.
- **One kept world a room**: `WorldSync`'s `keep` and `HostSync`'s `keep` overwrite each other.
  Use one (section 7).
- **Big blasts are big batches**: a radius-12 crater is ~6 000 cells, split into messages under 16
  KB, but every client re-meshes the chunks around it. Fine now and then; not every frame.
