Social

Teams

Server-authoritative in-match sides per game-defined scope: opaque team ids, members, a rankable score + opaque blob, team-scoped broadcast, optional policy-gated self-service joins.

Server
services.AddCrossplayTeams();
Unity package
com.crossplay.teams
Depends on
core

Seams you implement 1

Crossplay calls these; your game supplies them. Each ships an inert or permissive default, so register yours before services.AddCrossplayTeams(); and it wins.

ITeamPolicy game SPI

Game-implemented join rules for self-service (TeamJoinRequest) joins — balance caps, faction locks, "no switching after the round starts". Consulted only on the CLIENT join path; the trusted server path (TryAssign) bypasses it. The default policy allows everything the TeamOptions caps allow.

// Max 1-player difference between sides:
public bool CanJoin(ITeamReader teams, long scope, ushort team, ISession s)
    => teams.MemberCountOf(scope, team) <= MinCountOfOtherTeams(teams, scope, team);
  • bool CanJoin(ITeamReader teams, long scopeId, ushort teamId, ISession session)

    True to allow the session to join teamId in scopeId. Runs after the option caps passed; returning false refuses the join with JoinNotAllowed.

Services you call 2

Crossplay implements these. Resolve them from DI and call them from your own systems.

ITeamReader

Read-only view of the team state — the surface handed to ITeamPolicy so a game's join rules can SEE what they judge (team sizes for balance, current assignment for switch rules) without being able to mutate anything.

  • int MemberCountOf(long scopeId, ushort teamId)

    The number of members on a team (0 for an unknown team) — the cheap size probe a balance policy wants.

  • IReadOnlyList<long> MembersOf(long scopeId, ushort teamId)

    The current member account ids of a team (empty for an unknown team). The returned list is live piece state — read it synchronously, never cache or mutate it.

  • long ScoreOf(long scopeId, ushort teamId)

    The team's current score (0 for an unknown team).

  • ushort TeamOf(long scopeId, long accountId)

    The team the account is on in the scope, or 0 when unassigned.

  • IReadOnlyCollection<ushort> TeamsOf(long scopeId)

    The team ids currently existing in a scope (empty for an unknown scope). Live piece state — read synchronously, never cache or mutate.

ITeamService

Server-authoritative in-match sides: opaque team ids with members, a rankable score, and an opaque blob, keyed by a game-supplied scope id the piece never interprets (a match id, a lobby id, 1 for "the whole server"). Not a Party (a social invite group that outlives matches) and not a bare Core room (no identity/score semantics) — a team is a side inside one scope.

  • void Broadcast<T>(long scopeId, ushort teamId, T message, bool reliable = true)

    Sends the game's own message to every online member of one team — the "only my side hears this" channel (serialize-once fan-out).

  • event Action<long, ushort, long, bool> MembershipChanged

    Raised on every membership change: (scopeId, teamId, accountId, joined). The server-side glue seam — e.g. bind a Chat channel or Voice channel per team, with zero piece-to-piece references.

  • void ReleaseScope(long scopeId)

    Drops the whole scope: pushes an empty roster (so clients clear their team UI), fires MembershipChanged for each member, and forgets the state. Call it when the match / lobby the scope mirrored is over.

  • void SetScore(long scopeId, ushort teamId, long score, byte[] data = null)

    Sets a team's score and (optionally) its opaque blob, then pushes a light TeamScore to the scope. Server-only — there is deliberately no wire path, so scores are unforgeable. Creates the team if needed (a side can exist, with a score, before anyone picks it).

  • TeamResult TryAssign(long scopeId, ISession session, ushort teamId)

    Puts the session's account on a team — the TRUSTED path for matchmaking / lobby / game glue. Bypasses AllowClientJoin and the ITeamPolicy, but still respects the structural caps. Moving an account between teams is allowed.

  • TeamResult TryJoin(ISession session, long scopeId, ushort teamId)

    Self-service join/switch — the wire path (TeamJoinRequest). Refused entirely unless AllowClientJoin is on; switching additionally needs AllowClientSwitch; then the caps and the game's ITeamPolicy must pass.

  • TeamResult TryRemove(long scopeId, long accountId)

    Removes an account from its team in the scope (works for offline members too).

Configuration 1

Every tunable lives in an options object — there are no magic numbers to hunt for.

TeamOptions

Configurable team rules, passed to AddCrossplayTeams. Defaults live here, never inline in handlers/services (the no-hardcoded-values rule).

  • bool AllowClientJoin { get; set; }

    When true, clients may pick their own team over the wire (TeamJoinRequest), subject to the caps and the game's ITeamPolicy — the CoD-style "choose your side" screen. When false (the default) teams are server-assigned only (ITeamService.TryAssign from matchmaking/lobby/game glue) and every wire join is refused.

  • bool AllowClientSwitch { get; set; }

    When true, a client already on a team may use TeamJoinRequest to move to another team (still cap- and policy-gated). Irrelevant while AllowClientJoin is off.

  • int MaxDataBytes { get; set; }

    Maximum size of a team's opaque Data blob, in bytes. Larger blobs passed to SetScore are dropped (logged) — team blobs are meant to be tiny descriptors.

  • int MaxMembersPerTeam { get; set; }

    Maximum members per team; 0 means unlimited. Past the cap a join/assign is rejected with TeamFull.

  • int MaxTeamsPerScope { get; set; }

    Maximum distinct teams per scope. Assignments/joins to a new team past this cap are rejected with TooManyTeams.

  • bool RemoveOnDisconnect { get; set; }

    When true, a disconnecting member is removed from their team immediately (the Party rule). When false (the default) membership is account-keyed and survives the disconnect, so a reconnecting player is still on their squad; the game removes members explicitly (TryRemove) or drops the whole scope (ReleaseScope).

Wire messages 4

The protocol this piece speaks. Ids are allocated per piece so they can never collide.

4701 TeamJoinRequest

Client → server: join (or switch to) team TeamId inside ScopeId. Only honoured when the game enables self-service joins (TeamOptions.AllowClientJoin); the server-assigned path (ITeamService.TryAssign) never uses this message. Both ids are opaque to the framework — the game decides what a scope (a match, a lobby, the whole server) and a team (axis/allies, red/blue, squad 3) mean.

4702 TeamJoinResponse

Server → client: result of a TeamJoinRequest. On failure ReasonCode carries a TeamNotificationCodes value (the same code is also pushed through the Core notification service).

4703 TeamRoster

Server → scope members: the current teams of a scope. Sent to every member of the scope on every membership change (and as the confirmation after a successful join). Team ids, scores, and blobs are the game's vocabulary — one game renders this as axis/allies side-picker, another as squad cards, another as a plain text list; the piece cannot tell the difference.

4704 TeamScore

Server → scope members: one team's score and/or opaque blob changed. A lighter push than a full TeamRoster so score ticks (a capture, a kill, a qualification) don't re-send every member list. Server-set only (ITeamService.SetScore) — clients cannot forge it.

Unity services you inject 1

Crossplay binds these in the client context. Inject and call them from your own MonoBehaviours and presenters.

ITeamClient

Data-layer teams service (no UI): roster + score push events, cached last-known rosters per scope, and an async self-service join RPC (honoured only when the server enables client joins). Self-registers under the Crossplay client context.

  • event Action<TeamRoster> RosterChanged

    Raised whenever a scope's team roster changes (server push on every membership change). An empty roster means the scope was released — clear any team UI.

  • event Action<TeamScore> ScoreChanged

    Raised when one team's score/blob changes (the light push, no member lists).

  • TeamRoster RosterOf(long scopeId)

    The last roster received for the scope, or null before any push (or after release).

  • UniTask<TeamJoinResponse> JoinAsync(long scopeId, ushort teamId, CancellationToken ct = default)

    Joins (or switches to) a team — the self-service path. The server refuses it unless the game enabled client joins; the roster push is the state confirmation.

UI views you can replace 1

Each ships a working uGUI panel you can drag into a scene. Substitute your own view to keep the logic and change the look — the presenter never knows.

ICrossplayTeamsView

Narrow contract the presenter drives. UI-only: render calls in, user intents out.