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.
Two teams with auto-balance
Section titled “Two teams with auto-balance”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:
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) => ... |
Switching teams
Section titled “Switching teams”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:
switching-off:switchingisfalse, or the game calledsetTeamSwitching(authority, false).not-selectable: the team is declaredselectable: false.team-full: the team holds itsmaxPlayers.unbalanced: the move would leave the largest and the smallest balanced team more thanmaxPlayerDifferenceapart, 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 notbalanced, such as the spectators, is never refusedunbalanced.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.
Moving players from the game
Section titled “Moving players from the game”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 withnull, past every switching rule andmaxPlayers, 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.
Reading the teams
Section titled “Reading the teams”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) }).
Friendly fire
Section titled “Friendly fire”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
Section titled “A free-for-all”A free-for-all lists teams({ freeForAll: true }): no teams, and every player fights every other:
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 })];One team against NPCs
Section titled “One team against NPCs”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:
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.
What teams cannot do yet
Section titled “What teams cannot do yet”- 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 androunds()’s team settings. Until then, a game keeps each team’s score in a trait of its own: today’srounds()scores one round from coins, with nothing per team.
Read its source
Section titled “Read its source”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:defineTeamand 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 andbalanceTeams.reads.tsandhooks.ts: the reads, the hooks andsendTeamSwitch.TeamMember.tsx: the scene’s component.index.ts: the names@spawnite/engine/teamsexports.