Skip to content

Lobbies: rooms of up to N players ​

You'll learn: what a lobby is, who lands in which one, what's shared and what isn't, and how to run rounds. You'll need this when: your game has a player limit, rounds or parties.

Status: Built (headless, and in the plots dev stack): lobbies, the platform's lobby choice, locking, the start countdown, siloing, suspend and resume, the cross-lobby calls, the featured lobby and the fade at the boundary. Not in the live world yet.

A lobby is a room, not a waiting room ​

A lobby is a siloed room of up to lobbies.size players over your plot: a layer over the same physical bounds, not a separate world. It has no game state of its own: no "waiting", no "running". It's just a room. A puzzle adventure for four is a lobby of 4 that anyone can walk into; 1,000 players at a Bedwars plot with lobbies of 16 are 63 lobbies, all in the same place, each seeing only its own.

One plot, many lobbies: inside the plot's bounds, the shared base everyone sees, then lobby l1, lobby l2 and so on up to l63, each seen only by its own players and the onlookers whose lobby it is

A runtime declares only the size in plot.json:

json
{ "kind": "runtime", "lobbies": { "size": 4 } }

area (optional, relative to the plot's min corner) is where a new lobby's players start: { "size": 16, "area": { "min": [0, 64, 0], "max": [16, 72, 16] } }. "lobbies": null (or none) means no lobbies: the plot is part of the world's own lobby ("main"), which can hold 1,000 or more players.

Who lands where ​

The platform does it, so you don't have to:

  1. As a player approaches (about 2 chunks out), the platform picks a lobby for them: the fullest lobby that isn't full or locked, or a new one if there's none. They see that lobby's players before they walk in.
  2. While they're outside, the choice can change (that lobby fills, or locks).
  3. When they cross the boundary, it's fixed. Players from other lobbies fade out over 0.5 s.

A lobby is full when players.length >= size. You don't manage lobby creation, filling or idling.

To choose yourself, a runtime exports chooseLobby(player, lobbies): return a lobby id, or null for a new one. A choice that's full or locked falls back to the platform's.

ts
// keep friends together: the lobby a player's party is already in, else the platform's usual choice
export function chooseLobby(player: PlayerId, lobbies: Lobby[]): LobbyId | null {
  const party = storage.getPlayerProfile<LobbyId | null>(player, "partyLobby");
  const open = lobbies.filter((l) => !l.locked && l.players.length < l.size);
  return open.find((l) => l.id === party)?.id ?? open.sort((a, b) => b.players.length - a.players.length)[0]?.id ?? null;
}

Locked lobbies ​

lobbies.lock() (runtime only, from a lobby hook) closes the current lobby to newcomers, even if it has room; lobbies.unlock() opens it again. Use it for:

  • A round in progress: lock when the round starts, unlock when it's over, so the next players can join.
  • A fixed party: lock once the group has set off (a story dungeon after the first door), so nobody walks into the middle of their story.
ts
const l = lobbies.current();               // { id, players, size, locked, countdownEndsAt?, suspended, ... }
if (l.players.length >= l.size && !l.locked) lobbies.lock();

The start countdown (optional) ​

Some rounds should start fair: everyone arrives, nobody can be hit early, and everyone sees the same "3, 2, 1". lobbies.startCountdown(opts?) (runtime only) does that:

  • The base game draws the countdown (a plot can't fake it, and the plot can't draw over it).
  • It runs until the newest member's 10 s entry protection ends, or opts.minSeconds pass, whichever is later. Then your onCountdownEnd() runs and the round is yours.
  • By default, only players still in their own entry protection are protected; those who've been in for a while aren't protected again. { protectEveryone: true } protects every member until it ends: an equal start.
  • If someone leaves meanwhile, onCountdownPlayerLeft(p) runs: startCountdown() again, cancelCountdown(), or let it run.
ts
export function onPlayerEnter(p: PlayerId) {
  const l = lobbies.current();
  if (l.players.length >= l.size && !l.locked) {
    lobbies.lock();                                      // no newcomers mid-round
    lobbies.startCountdown({ protectEveryone: true });   // an equal start
  }
}
export function onCountdownEnd() { /* teleport, kits, go */ }

You don't need it. A puzzle room for four that anyone can walk into has no countdown and no lock: players arrive, their entry protection ends (onPlayerProtectionEnd), and they play.

Everything is siloed ​

Players, mobs and entities, block edits, mesh-terrain changes, storage, timers, audio, UI, visibility sets and chat all belong to one lobby. Nothing crosses unless your plot-level code moves it. What players break and place under the base game's rules, and the items they drop, are their lobby's too.

ts
// in a lobby hook: acts on this lobby only
world.setBlock([8, 70, 8], STONE);        // this lobby's edit layer
storage.set("score", 7);                  // this lobby's storage
ui.chat("lobby", "gg");                   // this lobby's players

Lobbies are containers, "like a file" ​

A lobby has a stable id and keeps its data until you delete it. Your plot owns its data:

ts
// plot level, runtime only
const id = lobbies.create({ size: 8, seed: 42 });        // a lobby made by you (its own world-gen seed)
lobbies.runWithin(id, () => storage.get("score"));       // read or write inside it
const data = lobbies.exportData(id);                     // { id, lobby, storage, edits, entities }
lobbies.movePlayers(["p1", "p2"], id);                   // move players (refused while they're protected)
lobbies.setFeatured(id);                                 // the lobby distant viewers, the map and the feed see
lobbies.delete(id);                                      // refused with "lobbyNotEmpty" while players are in it

Merging lobbies, a global leaderboard, a persistent world: all yours to build from these. The platform doesn't merge anything for you.

Empty lobbies are suspended. No players and no due timers: the lobby stops ticking and costs only storage (suspended: true). It's loaded again (onLobbyLoad) when a player is put in it or a timer falls due. Pass neverSuspend: true to lobbies.create to keep one running within your CPU budget.

Write code for one lobby ​

One sandbox runs all your lobbies. @inventery/lobby keeps one state object per lobby so you can write as if there were only one:

ts
import { defineLobbyGame } from "@inventery/lobby";

const game = defineLobbyGame({
  setup: () => ({ round: 0, alive: [] as string[] }),
  onPlayerEnter(s, p) { s.alive.push(p); },
  tickLobby(s, dt) { /* ... */ },
});
export const { onLobbyLoad, onPlayerEnter, tickAllLobbies } = game;

The state lives in the lobby's storage, so it survives restarts. See the reference.

The draft lobby ​

Every plot also has a private lobby, draft, for you and invited testers, where new builds hot-reload. See Drafts and publishing.

Next ​

World generation: seed plus edits.