Brickleaf Team System KB¶
Purpose¶
The Brickleaf team system gives players, tanks, minions, projectiles, targeting helpers, and rewind checks a shared primary-team and match-team model.
PrimaryTeamIdis retained as replicated player identity metadata.MatchTeamIdidentifies the combat/friendly-fire lane.
Players may share a MatchTeamId, but each player normally has a unique PrimaryTeamId.
Current projectile interaction, damage protection, rewind filtering, minion initialization, targeting, and
roster behavior use only MatchTeamId; PrimaryTeamId does not affect those mechanics.
UBrickleafTeamSubsystem is a UWorldSubsystem. Its counters and helper lifetime are scoped to the active gameplay world rather than the GameInstance.
Canonical replicated state¶
ATankPlayerState directly owns the two canonical replicated values:
PrimaryTeamId, usingOnRep_PrimaryTeamId;MatchTeamId, usingOnRep_MatchTeamId.
The server changes them through UBrickleafTeamSubsystem::SetTeams() or the initialization helper InitMatchTeam(). SetTeams() resolves the supplied actor to its canonical PlayerState, writes both values on authority, calls ForceNetUpdate() on that PlayerState, and emits the server-side change notifications. InitMatchTeam() is idempotent: an already assigned PlayerState is unchanged. By default it places standalone/listen-host players on team 1 and balances joining players across 1..DefaultMatchTeamCount (default 2), randomizing equal-count ties. Its optional legacy flag instead selects the next unused MatchTeam ID. CurrentMatchTeamCount is maintained locally as the number of non-empty MatchTeam IDs in the subsystem index; it is derived and is not replicated.
Each client receives the values through normal PlayerState replication. OnRep_PrimaryTeamId and OnRep_MatchTeamId are Blueprint-native PlayerState events for consumers that need to react locally. There is no replicated GameState team array or replicated team-state component; the subsystem lookup cache and its change notifications are derived locally from PlayerState replication.
The team cache is the sole source of ChangeTeamPresentation requests. Every cache update enrolls its PlayerState with the team lifecycle adapter; existing cached entries are replayed when the adapter attaches late. Pawn/UI lifecycle events only wake or rebind these enrolled requests. Presentation waits for both the target and local-viewer character-init gates, valid processed MatchTeam assignments, and ready widget controllers. The adapter tracks Pawn-to-PlayerState relationships to handle late joiners, reverse-reference races, and respawns without a new team assignment. No manual post-character-init call is needed. HandleMatchTeamPresentationChanged is now a deprecated no-op retained so existing Blueprint assets compile; remove its obsolete calls when convenient.
After readiness passes, the team subsystem compares the last-applied target pawn, target PlayerState/team, and local PlayerState/team. Unchanged requests do not invoke ICombatInterface::ChangeTeamPresentation; new pawns and changed team pairs do. The snapshot is recorded before invoking Blueprint to suppress reentrant duplicates. All enrolled players are reconsidered when local teams/readiness change, with unchanged presentations filtered out. Changes received before readiness coalesce to the latest state. Character-init readiness means the synchronous gate-open dispatch returned, not completion of arbitrary latent Blueprint work.
Crossing the cached-PlayerState-count boundary of <=1 versus >1 broadcasts the separate Blueprint-assignable OnMultiplayerStatusChange(IsMultiplayer) delegate after the cache changes. IsMultiplayer is true for >1 cached players and false for <=1, independently of net mode or match-team count. Both additions and removals are covered; changes that stay on the same side of the boundary do not broadcast. This event requires no local PlayerState or pawn, is not replicated, and no longer calls ChangeTeamPresentation; consumers choose how to respond.
The subsystem's Blueprint-assignable OnTeamChanged(PlayerState, MatchId, IsLocalPlayer) remains a separate UI-readiness-gated notification for initial valid MatchTeam assignments or changed MatchTeam IDs. It does not require a ready pawn and is not broadcast on dedicated servers. Repeated caching of the same MatchTeam, primary-only changes, and cache removal do not broadcast. The delegate itself is not replicated: each world observes its own cache changes, coalescing pending changes until UI readiness. IsLocalPlayer reflects ownership at broadcast time.
Player and tank lookup¶
UBrickleafTeamSubsystem::GetTeams() first resolves the queried actor to its canonical ATankPlayerState:
- an
ATankPlayerStateresolves to itself; - a player pawn resolves through
Pawn->GetPlayerState<ATankPlayerState>(); - a player controller resolves through
Controller->GetPlayerState<ATankPlayerState>(); - an owned actor resolves through Unreal's native
AActor::GetOwner(), then through that owner's player state.
After resolution, GetTeams() reads a derived subsystem index keyed by that PlayerState. The index is updated by authority writes, PlayerState RepNotifies, and GameState player add/remove events. The replicated PlayerState fields remain canonical and provide a lifecycle-edge fallback if an entry has not been indexed yet. A second derived index maps MatchTeamId to the active PlayerStates on that team for constant-time roster lookup.
Owned actors¶
Owned actors do not copy or replicate team IDs, and the subsystem does not store a second ownership relation. GetTeams() follows the actor's native GetOwner() pointer and resolves that owner to its canonical PlayerState.
A player match-team change therefore changes all owned actors immediately without rewriting or replicating one entry per actor. Actor destruction requires no team-array or subsystem-map cleanup because no per-owned-actor team state exists.
Minions and the owning-client dummy¶
The minion's native actor owner is its tank character on the authoritative minion, remote proxy, and local prediction dummy. OnRep_Owner() retries team initialization if the replicated owner arrives after BeginPlay().
Minion initialization calls:
TeamSubsystem->GetTeams(this, PrimaryTeam, MatchTeam);
The same path covers:
- the server's authoritative minion;
- remote clients' replicated minion proxy;
- the owning client's local prediction dummy.
The authoritative minion is intentionally not network-relevant to its owning controller. The owning client keeps a distinct local dummy instead. Native owner lookup avoids that split: both real and dummy minions resolve through their local actor owner to the same replicated PlayerState values.
Dynamic team changes and delegates¶
When a player's primary or match team changes:
- The server updates the canonical values on that player's
ATankPlayerState. - The PlayerState replicates the changed property to clients.
- The corresponding PlayerState RepNotify runs on that client.
Direct GetTeams() calls always read the latest replicated PlayerState values. Rewind snapshots resolve the current teams when each frame is recorded, so they require no team cache or change delegate.
Projectiles¶
Projectiles are snapshots rather than derived owned actors. When spawned, FireProjectileSpell reads and copies
the firing avatar's current MatchTeamId onto the projectile. ATankProjectile replicates that ID so the
projectile preserves the firing combat side used by impact and prediction logic.
Projectile interaction checks retrieve the other actor's current MatchTeam through UBrickleafTeamSubsystem::GetTeams() and apply only the configured same-MatchTeam impact/damage rules.
Runtime flows¶
Player initialization¶
- Authority runs
ATankPlayerState::BeginPlay(). - It always calls
InitPrimaryTeam(this). - An LS or standalone game calls
InitMatchTeam(this). WhenGameDefaults.TeamMode == true, standalone/listen-host players use team 1 and joining players are balanced across the configured teams. WhenTeamMode == false,bAssignNextAvailableMatchTeamIdis enabled so every player receives a distinct MatchTeam ID. - A DS with
GameDefaults.TeamMode == falsealso enablesbAssignNextAvailableMatchTeamId, making every player its own MatchTeam. - A DS with
GameDefaults.TeamMode == trueleaves MatchTeam unset until deployment selection bootstrap applies the immutable House assignment. - Those assignments write the canonical replicated fields on the PlayerState.
- Clients receive the values through normal PlayerState replication.
LS -> DS match-team transport¶
FGameDefaults::TeamMode defaults to true and is carried in Edgegap GameDefaults metadata.
- The House LS remains authoritative for pre-match team setup. Once every member is selection-ready, the freeze action calls
UBrickleafTeamSubsystem::GetTeams()for every House PlayerState. begin_house_freezesends a complete{eos_puid, match_team_id}mapping alongside the House ID.- Nakama validates that the mapping covers the authoritative frozen roster exactly once and stores each MatchTeam in the versioned freeze attempt. Later client confirmations cannot replace it.
- Aggregate freeze completion writes
match_team_idinto each player entry in the schema-v2 frozen/deployment selection snapshot. sv_get_deployment_selection_by_eosreturns the field during DS join bootstrap.- In team mode, the DS applies the transported explicit ID through
SetTeams()before spawning/possessing the bootstrap pawn. A missing or invalid field blocks that player's bootstrap. - Outside team mode, the transported field is ignored and the DS assigns each player the next unused MatchTeam ID.
The transport always runs, including non-team matches. This keeps snapshot format and launch behavior consistent; GameDefaults.TeamMode is the final authority switch deciding whether the DS consumes the transported value or uses its local unique-team allocator.
Minion initialization¶
- The minion receives its native actor owner, either at local spawn or through replication.
- Its team queries resolve
GetOwner() -> ATankPlayerState. - Initialization waits only for the resolved PlayerState's valid
MatchTeamId. - Destruction needs no team-specific cleanup.
Player match-team change¶
- Authority calls
SetTeams()for an explicit reassignment, or idempotentInitMatchTeam()for initial balanced/unique assignment. - The initialization helper preserves the player's primary team and changes only the match team.
- The PlayerState's
MatchTeamIdis updated and replicated. - Real minions, remote proxies, and local dummies derive the new match team through their owner.
Invariants and caveats¶
- Only authority may mutate the canonical PlayerState team IDs, through
UBrickleafTeamSubsystem. - An owned actor's native
GetOwner()must resolve to anATankPlayerState. OnRep_Owner()handles late native-owner arrival for replicated minions.- Local prediction dummies must be spawned with the tank character as their native actor owner.
GetGenericTeamId()consumers cast match-team IDs touint8; IDs above 255 would truncate.- Internal team ID counters reset per world. Persistent identity across travel requires reassignment or explicit carry-over.
- A House freeze requires every ready roster member to have a valid canonical MatchTeam and EOS identity.
Quick mental model¶
PlayerState team IDs = canonical replicated team data
PlayerState = canonical player identity
Tank = resolves to PlayerState
Minion/dummy = resolves through GetOwner(), then PlayerState
Projectile = replicated snapshot of firing MatchTeam
PrimaryTeamId is retained identity metadata and does not currently drive combat behavior.
MatchTeamId answers "which combat side is this on?"