Skip to content
Work in progress. These docs describe Spawnite at launch, and some parts are still being built.

Teams

teams() puts each player on a team. List it in the game’s plugins, and import its names from @spawnite/engine/teams. The engine’s own team names, the Team type, findTeam, isTeammate, isAlly, readInstigator, isFriendlyFireEnabled, TeamChangeCause, TeamChangedEvent and DamageRefusal, come from @spawnite/engine/core, and from @spawnite/engine beside the views. The plugin’s handles hang on its factory, as every plugin’s do: teams.commands.switch and teams.systems.takeSwitches. The plugin balances each joining player onto a team, takes her request to switch, and answers the team question the engine asks: damage, weapons, chase, abilities and nameplates all follow the teams without a line of your own.

With no teams() listed, every player is on every other player’s side, as in a co-op game: one player’s shot passes through another, and a monster with no team fights them all. A game with a free-for-all says so, as A free-for-all shows.

Roblox’s Teams service is the closest match: teams a game declares, a join to the smallest team marked AutoAssignable, and Team:GetPlayers(). Counter-Strike and Team Fortress 2 add the rest of what this plugin does: a switch the server may refuse, a cap on the gap between teams, and friendly fire off by default.

Declare each team at module scope with defineTeam, and list the teams in teams(). Each player who joins goes to the team with the fewest players; a tie goes to the team declared first:

packages/room/test/outside/teams/twoTeams.ts
import { characters, type Authority } from "@spawnite/engine/core";
import { defineTeam, setTeamSwitching, teams } from "@spawnite/engine/teams";
// Two teams with auto-balance: each player who joins goes to the smaller
// team, a player may switch between rounds, and a teammate's shot passes
// through her.
export const red = defineTeam("red", { name: "Red", color: "#e5484d" });
export const blue = defineTeam("blue", { name: "Blue", color: "#3e63dd" });
/** Players who watch: never balanced, never auto-assigned. */
export const spectators = defineTeam("spectators", {
balanced: false,
autoAssignable: false,
});
export const plugins = [
characters(),
teams({
teams: [red, blue, spectators],
switching: { cooldownSeconds: 10, maxPlayerDifference: 1 },
}),
];
/** Switching only between rounds: off as a round starts, on as it ends. */
export function startRound(authority: Authority) {
setTeamSwitching(authority, false);
}
export function endRound(authority: Authority) {
setTeamSwitching(authority, true);
}

The game’s own round system calls startRound as a round begins and endRound as it ends, as Systems shows a game’s system. Keep the time between rounds longer than cooldownSeconds, so a player who switches there can switch back. As a round ends, call balanceTeams first and turn switching on after it, so players switch from even teams.

defineTeam(key, options) returns a Team, a handle that every call names. Two handles are two teams even when their keys match, so always pass the handle your game declared. A call that names a team your game did not list throws in development, naming the call, and is refused with a logged reason in a published game. A team takes the following options:

Option Default What it does
name The key What a scoreboard and a nameplate show
color A colour from the key A CSS colour, which nameplates wear; a key such as "red" or "blue" takes that colour
maxPlayers No cap but the room’s The most players the team holds; balance and a switch respect it, setTeam does not
autoAssignable true Whether balance on join may put a player here
balanced true Whether the team counts when balance and a switch compare team sizes; a spectator team sets false
selectable true Whether a player may switch to it; an infection game’s zombies set false

When every team that balance may fill is full, a joining player gets no team, and she is an ally of nobody until the game or her switch gives her one. Today characters() spawns a character for a player on any team, a spectator team’s and no team’s alike, so a game keeps a spectator out of its objectives itself, such as by checking findTeam before a flag counts a touch.

The defaults and the setting that changes each

Section titled “The defaults and the setting that changes each”

Each row below is a default a player feels, and the setting that changes it:

Behaviour Default The setting
Teams None: every player is on one side teams({ teams: [red, blue] }), or teams({ freeForAll: true })
Team sizes No cap but the room’s defineTeam("hunter", { maxPlayers: 1 })
Balance on join The smallest autoAssignable team autoAssignable: false on each team, and the game’s own setTeam
Switching On, a 10-second cooldown, at most one player of difference switching: false, or switching: { cooldownSeconds, maxPlayerDifference }
Switching during play Follows switching setTeamSwitching(authority, false) as a round starts, true as it ends
Friendly fire Off: a hit between allies applies nothing teams({ friendlyFire: true }), setFriendlyFire in play, or a weapon’s friendlyFire
Nameplates A plate wears its team’s colour teamColors: TeamColorMode.Off in the nameplate setting
Two teams on one side Two teams are allies only when they are one team isTeamAlly: (world, first, second) => ...

A player asks for a team with sendTeamSwitch(world, team) from her page, as Counter-Strike’s jointeam asks. The room answers in the order the requests arrive. The promise settles with a result whose status is CommandStatus.Accepted, or CommandStatus.Refused with a TeamSwitchRefusal as its reason and her team unchanged, so a picker shows a line for each reason. A picker greys its buttons while switching is off with isTeamSwitchingAllowed(world), or useIsTeamSwitchingAllowed() in a view.

A switch to the team she is on is accepted and changes nothing, so a double click is not an error. Any other switch meets these refusals, in this order, each a member of TeamSwitchRefusal, such as TeamSwitchRefusal.SwitchingOff for switching-off:

  1. switching-off: switching is false, or the game called setTeamSwitching(authority, false).
  2. not-selectable: the team is declared selectable: false.
  3. team-full: the team holds its maxPlayers.
  4. unbalanced: the move would leave the largest and the smallest balanced team more than maxPlayerDifference apart, and does not even them. A move evens the teams when it goes to a balanced team at least two players smaller than hers. A move to a team that is not balanced, such as the spectators, is never refused unbalanced.
  5. too-soon: her cooldown has not ended, or a switch of hers already changed her team this tick. Each change of her team starts the cooldown but her first team at her join, so she may join a friend at once. A move that evens the teams skips the cooldown.

The switch is not predicted: her page shows her new team when the room’s answer arrives, one round trip later. Read the pending result with useLatestCommandResult(teams.commands.switch), and the seconds left of her cooldown with useTeamCooldownSeconds(), so a picker can show “switch in 6 s”. A game whose rule differs, such as a switch only to a party’s team, replaces the answering system with a system of its own whose entry says replaces: teams.systems.takeSwitches, as Systems says, reads teams.commands.switch there, and moves the player with setTeam(step, player, team, { cause: TeamChangeCause.Switched }).

Today a switch, and a move by balanceTeams, changes her team at once and leaves her character where it stands. A game that sends her home reads TeamChangedEvent in a system and moves her character itself for the causes Switched and Balanced. A switch that downs her and respawns her on her new side comes with characters()’s team settings.

The writes take the room’s authority, a system’s step or requireAuthority(world):

  • setTeam(authority, entity, team) puts a player, an NPC or an object, such as a flag or a capture point, on a team, or on none with null, past every switching rule and maxPlayers, since it is the game’s own decision, and returns whether it applied. A join hook passes its own step, so a game’s picker can place a joining player itself. A living player stays where she stands, as an infection game’s bitten player turns in place. An object on a team is that team’s ally: its shots pass through it.
  • setTeamSwitching(authority, isAllowed) turns players’ switching on or off for the room.
  • setFriendlyFire(authority, isEnabled) turns friendly fire on or off, for a warm-up or a hardcore round.
  • balanceTeams(authority) evens the balanced teams now: it moves the player who joined her team last, from the largest team to the smallest that has room, while the move evens the teams. It returns { moved, gap }, the players it moved and the gap left. The plugin never calls it on its own; call it between rounds, as Counter-Strike balances at a round’s start.

A player who drops keeps her team for the room’s rejoin hold. One who comes back after it joins as a new player, and balance places her again. The room’s checkpoint keeps every team, cooldown and setting.

The engine asks one question, and teams() answers it. The reads in the first rows below come from @spawnite/engine, so a system of your own reads teams the way damage and nameplates do:

Question In simulation code In a view
Which teams are in play readTeams(world) useTeams()
Which team is this entity on findTeam(world, entity) useTeam(entity)
Which team is this page’s player on findTeam(world, readLocalPlayer(world)) useTeam()
Are these two on one team isTeammate(world, a, b) useIsTeammate(a, b)
Are these two on one side isAlly(world, a, b) useIsAlly(a, b)
Who is on a team readTeamPlayers(world, red) useTeamPlayers(red)
Which team a key names findTeamByKey(world, key) none
How long until she may switch readTeamCooldownSeconds(world, player) useTeamCooldownSeconds()
Whether players may switch now isTeamSwitchingAllowed(world) useIsTeamSwitchingAllowed()

readTeams, findTeam, isTeammate, isAlly, readInstigator and isFriendlyFireEnabled are the engine’s; the rest, and every hook, are @spawnite/engine/teams’s.

An entity’s team comes from the player behind it: the player who controls it, else the one player whose presence it is, else its own team. A tank a red player drives fights for red, and goes back to its own team once she lets go.

TeamChangedEvent lands on a player’s entity each time her team changes, with from and to as team keys, null for none, since an event crosses the network as plain values; findTeamByKey turns a key back into its handle, and a cause: Joined, Switched, Set or Balanced. A change and its reversal within one tick are no change. To send an event to one team alone, name its players: emitEvent(step, TeamPingEvent, { at }, { to: readTeamPlayers(world, red) }).

A hit between allies applies nothing while friendly fire is off: no damage, no slow, no effect. dealDamage returns DamageRefusal.FriendlyFire for it, and a weapon’s shot passes through an ally to what stands behind her. A hit is judged by its instigator, the player and the team behind it as it was made, so a grenade that lands after its thrower switched teams is judged by the team she threw it for. Self-damage always applies, her own rocket after her respawn too.

A weapon’s friendlyFire overrides the world’s: true for a weapon that hurts teammates where friendly fire is off, false for one that never does. Left out, the weapon follows the world.

Abilities strike only a target marked TargetableTrait({ faction: Faction.Hostile }), and only one the strike would not spare. characters() marks no character, so a team game whose players cast at each other adds TargetableTrait({ faction: Faction.Hostile }) to each character, and the ally rule spares the caster’s teammates.

A free-for-all lists teams({ freeForAll: true }): no teams, and every player fights every other:

packages/room/test/outside/teams/freeForAll.ts
import { characters } from "@spawnite/engine/core";
import { teams } from "@spawnite/engine/teams";
// A free-for-all: no teams, and every player hurts every other.
export const plugins = [characters(), teams({ freeForAll: true })];

A co-op game needs no teams(): players are on one side, and a monster with no team fights them. A game that adds an NPC on the players’ side declares the one team and puts the NPC on it. A scene places one with <TeamMember team={defenders} /> inside its <Entity>, and a system calls setTeam:

packages/room/test/outside/teams/defenders.ts
import type { Entity } from "koota";
import { characters, type Authority } from "@spawnite/engine/core";
import { defineTeam, setTeam, teams } from "@spawnite/engine/teams";
// One team against NPCs: every player is a defender, a guard fights on
// their side, and a monster with no team fights them all.
export const defenders = defineTeam("defenders", { name: "Defenders" });
export const plugins = [
characters(),
teams({ teams: [defenders], switching: false }),
];
/** Puts a guard the game spawned on the players' side. */
export function enlistGuard(authority: Authority, guard: Entity) {
setTeam(authority, guard, defenders);
}

Players’ shots pass through the guard, and a chaser on the defenders’ team chases none of them.

  • A team’s secrets, such as a marker only red sees, wait for audiences after launch: every page reads every player’s team.
  • A team layout per level: a game declares every team it uses, and leaves out of balance the ones a level does not.
  • Neutral between two teams: two teams are allies or not.
  • A switch that downs the switcher, spawn points by team, and a round scored per team come with characters()’s and rounds()’s team settings. Until then, a game keeps each team’s score in a trait of its own: today’s rounds() scores one round from coins, with nothing per team.

teams() is a readable plugin: the engine’s package ships its TypeScript source under node_modules/@spawnite/engine/readable/teams/. Each file reaches the engine through its public entries alone, so a kit could rebuild it, and every system that reads teams would follow the copy. The folder holds the following:

  • teams.ts: the plugin, its options and its answer to the team question.
  • declare.ts: defineTeam and a team’s settings.
  • membership.ts: each entity’s team, which only the plugin’s verbs write, and the room’s settings.
  • switching.ts: the switch command, its refusals, balance on join and balanceTeams.
  • reads.ts and hooks.ts: the reads, the hooks and sendTeamSwitch.
  • TeamMember.tsx: the scene’s component.
  • index.ts: the names @spawnite/engine/teams exports.