Skip to content

Build a tiny Gather ​

gather-lite is a Gather-style world — walking avatars, proximity chat, emotes, a shared door — where every movement, message, and interaction is an onchain transaction. The whole realtime integration is about 40 lines. This guide walks through them.

1. Design the presence payload ​

An avatar is position + facing + emote + name. As JSON that's ~70 bytes per broadcast at 10Hz; as a binary struct it's ~23:

ts
import { structCodec } from "solsocket";

interface Avatar {
  x: number; y: number;      // tile coords
  facing: number;            // 0-3
  emote: number;             // index into the emote table
  name: string;              // display name
}

const avatarCodec = structCodec<Avatar>([
  ["x", "u16"],
  ["y", "u16"],
  ["facing", "u8"],
  ["emote", "u8"],
  ["name", "string"],
]);

2. Create or join the world ​

The invite link carries the room address — and pins cluster and region, because a room lives on the ER it was delegated to:

ts
const sock = SolSocket.connect({ wallet, cluster, region });
const opts = { presenceCodec: avatarCodec, initialState: { door: false } };

const room = inviteAddress
  ? await sock.joinRoom<WorldState, Avatar, Msg>(new PublicKey(inviteAddress), opts)
  : await sock.createRoom<WorldState, Avatar, Msg>(opts);

history.replaceState(null, "", `?room=${room.address.toBase58()}&region=${region}`);

A lobby browser is one call — listRooms() returns every live world, busiest first:

ts
const worlds = (await sock.listRooms()).filter((r) => r.players > 0).slice(0, 4);

3. Movement: predict locally, broadcast at 10Hz, render at 60fps ​

Move your own avatar instantly on keypress (local prediction), and broadcast the position ~10 times a second while moving. smoothPresence turns those 10Hz samples from other players into interpolated 60fps motion:

ts
// in the input loop, throttled to ~10Hz while keys are held:
void room.broadcast({ x, y, facing, emote: 0, name });

// remote players: entity interpolation, no visible stutter
smoothPresence(room, (players) => { remotes = players; });

Broadcasts are fire-and-forget by default — the websocket subscription is the source of truth, and round-trip echo (~50ms on a nearby region) is your proof the write landed.

4. Proximity chat and emotes: events, not state ​

Chat doesn't belong in an account — it's ephemeral. emit writes an event into transaction logs with no state write, and every client applies its own proximity rule on receipt:

ts
await room.emit("chat", { text });
room.onMessage("chat", ({ player, data }) => {
  if (distance(player) <= 4) showBubble(player, data.text); // heard within 4 tiles
});

await room.emit("emote", { kind: 2 });
room.onMessage("emote", ({ player, data }) => flashEmote(player, data.kind));

5. The shared door: room state ​

One object the whole room agrees on. Press E near the door:

ts
await room.setState({ door: !doorOpen });
room.onStateChange(({ state }) => setDoorOpen(state.door));

6. Prove it's onchain ​

gather-lite keeps a writes counter and an explorer link on screen. Every broadcast, chat bubble, and door toggle is a signed, fee-zero transaction on the ER — and when the creator calls closeToBase(), the final world state is an ordinary account on the base layer.

That's the entire integration: one codec, one join call, three channels, two helpers. The rest of gather-lite is a tile map and a <canvas>.

MIT Licensed