Skip to content

Room recipes

Balance teams automatically

Put new players on the smaller team, rebalance when someone leaves and start matches when teams are ready.

This room keeps red and blue within one player of each other, parks extra players as spectators when teams are full, and starts a match as soon as both teams have a player.

host.mjs
import { createRoom } from 'ball2d/node';

const TEAM_SIZE = 3;

const room = await createRoom({
  apiKey: process.env.BALL2D_API_KEY,
  roomName: '3v3 balanced',
  noPlayer: true,
  maxPlayers: 10,
  public: true,
});

const team = (id) =>
  room.getPlayerList().filter((p) => p.team === id);

async function balance() {
  // Fill the smaller team from the spectators, oldest first.
  for (const spectator of team(0)) {
    const [red, blue] = [team(1).length, team(2).length];
    if (Math.min(red, blue) >= TEAM_SIZE) break;
    await room.setPlayerTeam(spectator.id, red <= blue ? 1 : 2);
  }
  // Move one player across if the teams differ by two or more.
  const [red, blue] = [team(1), team(2)];
  if (Math.abs(red.length - blue.length) >= 2) {
    const larger = red.length > blue.length ? red : blue;
    await room.setPlayerTeam(
      larger.at(-1).id,
      larger === red ? 2 : 1,
    );
  }
  if (
    team(1).length > 0 &&
    team(2).length > 0 &&
    room.getScores() === null
  )
    await room.startGame();
}

let queue = Promise.resolve();
const rebalance = () => {
  queue = queue
    .then(balance)
    .catch((error) => console.error('Balance failed:', error));
};

room.onPlayerJoin = rebalance;
room.onPlayerLeave = rebalance;
room.onGameStop = rebalance;

await room.setTeamsLock(true);
await room.setDefaultStadium('Classic');
console.log(room.roomLink);

How it works

  • One balance at a time. Joins and leaves can arrive together. Chaining every run onto queue means each balance sees the result of the previous one.
  • Read after awaiting. team() reads the roster after each await, so every decision uses the current teams.
  • Teams are locked. setTeamsLock(true) stops players from undoing the balance by switching themselves. Your code can still move anyone.
  • The most recent mover goes. A player who changes team moves to the end of the roster, so larger.at(-1) is the player who joined that team last.

Variations

  • Balance by skill instead of count: keep a rating per player ID in a Map and assign each spectator to the team with the lower total.
  • Rotate the losing team out: in onTeamVictory, move the losers to spectators before the next balance().