New

dot-team

Sides that outlive the match — definitions carrying colour and friendly-fire rules, a join, switch and lock policy, autobalance that is deterministic and explainable, and the spectating half over a duck-typed dot-spectate.

dot-team holds the sides a server has, rather than the sides a match has. dot-match’s teams are match-scoped and reset with it, which is right for a deathmatch and wrong for a server where somebody joins blue, plays four maps and expects to still be blue.

Requires dot-core. dot-spectate is optional and is never named in the source.

Usage

var teams := DotTeamRoster.new()
teams.teams  = DotTeamSet.standard_pair()        # blue, red, unassigned, spectator
teams.policy = DotTeamPolicy.competitive()
teams.alive_fn = func(key): return roster.get_record(key).alive
add_child(teams)
teams.setup()

teams.add("ada")                                  # onto the smaller side
teams.request_switch("ada", &"red", tick)         # enforces the whole policy
teams.rebalance(tick)                             # between rounds

if teams.are_enemies(shooter, victim):
    apply_damage()

Every refusal carries a sentence and fires switch_refused — “You switched recently. 22 seconds to go.” — because “nothing happened when I clicked” is the worst available answer.

Four entries, not two

standard_pair() is blue, red, unassigned and spectator. A player who has connected and not chosen is neither on a team nor watching, and a set with no id for that state represents it as the empty string, which then matches nothing or everything depending on which comparison runs.

Free-for-all is one team with friendly fire on, not “no teams” — modelling it as no teams makes every consumer branch on whether teams exist.

Autobalance is deterministic and explainable

Being moved is being punished for the server’s arithmetic, so every function in DotTeamBalance is static and takes counts rather than a roster:

DotTeamBalance.moves_to_balance({&"blue": 6, &"red": 2}, order, 1)
# [ {from: blue, to: red}, {from: blue, to: red}, {from: blue, to: red} ]

DotTeamBalance.explain(&"blue", &"red", "ada", 6)
# "ada moved from blue to red: the sides were 6 apart"

A decision computed from a dictionary can be printed, logged, replayed and argued with. Who gets moved is a policy choice — newest, lowest_score, random, oldest — and newest is the default because whoever joined most recently has the least invested in the side they are on. Nobody alive is moved unless the server says so.

It drives dot-match rather than competing with it

teams.bind_match(match_node.teams)

Duck-typed: it calls assign(key, index) if the object has one and does nothing otherwise. The direction matters — this is the authority and the match is told. Binding late pushes everybody already assigned, so it is not a silent half-configuration.

The spectating half

DotTeamSpectate bridges named sides to dot-spectate’s numbered ones and holds the watch policy in rounds and teams.

spectators_see_everybody On. Somebody who deliberately left to watch is not coming back this round.
dead_see_enemies Off. A dead player calling out positions is the oldest problem in competitive multiplayer.
open_after_round On. The round is decided; there is nothing left to give away.
fall_back_when_team_gone On. A camera with nothing to point at reads as the game having frozen.

The index is zero-based over the playing sides, so the spectator side and the unassigned holding pen both come out as zero and the force-camera rule works without either addon knowing about the other. An off-by-one here does not crash: it inverts who may watch whom.

dot-spectate’s own force_camera is applied first and this cannot relax it — an addon layered on top does not overrule the operator. join_spectators moves the player and starts them watching, because a spectator staring at a black screen is indistinguishable from a crash.

Who takes a role this round

DotTeamRotation.pick(keys, want, mode, history, seed_value, wishes, may_repeat, rest_first) chooses who takes a role (the hunters, the activators, the drivers) and returns who and why, so a console can answer “why am I hunter again”. It is pure and static. RANDOM is a seeded draw that passes over last round’s holders, QUEUE gives everybody an equal share of the rounds they played, and SWAP hands the role to whoever did not have it. A wish comes first in every mode: a player who asked for the role is ahead of everybody, one who asked to stay out is picked only when the round would otherwise be short.

rest_first changes that for RANDOM: last round’s holders rest even ahead of their wish and return only when the round is otherwise short. A trap course wants it, since an activator who volunteers every round would otherwise never let anybody else press a button. The draw comes from dot-core’s DotRandomStream (a child stream named by the key), not a hash of the key, so the same seed gives the same side across engine versions.