Files
restitution/TEAM_MANAGER_SPEC.md
kaykayyali 8fc45968b5 M2.3 + M2.4 + TeamManager integration: projectile sprites, death handling, build menu, production panel, building placer
- ProjectileSprite.js: physics arcade sprite, faction tint, off-screen culling
- CombatSystem: refactored enemy selection to use TeamManager instead of legacy containers
- Death handling: DYING alpha tween (500ms), smoke puff (300ms), unit:killed event, cleanup
- TeamManager: centralized team registry replacing goodGuys/badGuys containers
- HealthBarSystem, ResourceBar, CaptureProgressUI, BuildMenu, BuildingPlacer, BuildingRenderer, ProductionPanel
- Map_Player: wired new subsystems, removed legacy container creation
- Tests: ProjectileSprite (4), DeathHandling (13), CombatSystem updated

47 tests passed at dev time (M2.3), 158/158 at dev time (M2.4)
2026-06-01 05:18:33 +00:00

11 KiB

TeamManager — Architecture Spec

Motivation

Replace the hardcoded binary goodGuys/badGuys Phaser Containers with a proper multi-team system supporting up to 4 players in free-for-all mode. Each unit, building, and control point tracks teamId instead of being sorted into one of two named buckets.

Design Principles

  1. Minimal surface area — change only what's necessary. Don't refactor for refactoring's sake.
  2. Per-team economyEconomySystem already tracks per-player. TeamManager routes team→owner for income attribution.
  3. Combat is team-awareisEnemy(unitA, unitB) replaces container-name string matching.
  4. UI follows team color — SelectionSystem, BuildingRenderer, ResourceBar pull from TeamManager not hardcoded constants.
  5. Backward compatible with ColyseusplayerId maps to teamId 1:1 for now. Alliances split this later.

TeamManager API

// src/systems/TeamManager.js

export default class TeamManager {
  constructor(scene)
  
  // -- Team lifecycle --
  createTeam(teamId, color, name)      Team
  getTeam(teamId)                      Team | undefined
  getTeams()                           Map<string, Team>
  getTeamCount()                       number
  
  // -- Player mapping --
  setPlayerTeam(playerId, teamId)      void
  getPlayerTeam(playerId)              string   // returns teamId
  getTeamPlayers(teamId)               Set<string>
  
  // -- Entity ownership --
  addUnit(unit, teamId)                void     // calls unit.setData('teamId', teamId)
  removeUnit(unit, teamId)             void
  getUnitTeam(unit)                    string | null
  getTeamUnits(teamId)                 Set<Unit>
  getAllUnits()                        Array<Unit>  // for CombatSystem iteration
  getAllUnitsGrouped()                 Map<string, Set<Unit>>  // for per-team processing
  
  // -- Building ownership --
  addBuilding(building, teamId)        void
  removeBuilding(building, teamId)     void
  getBuildingTeam(building)            string | null
  getTeamBuildings(teamId)             Set<Building>
  
  // -- Queries --
  isEnemy(entityA, entityB)            boolean  // resolves team from entity data
  isSameTeam(entityA, entityB)         boolean
  getEnemyUnits(teamId)                Set<Unit>
  getEntityTeam(entity)                string | null  // works for Unit, Building, anything with getData('teamId')
  
  // -- Team info --
  getTeamColor(teamId)                 number
  getTeamName(teamId)                  string
  
  // -- Cleanup --
  destroy()                            void
}

// Team value object
class Team {
  constructor(id, color, name)
  id: string
  color: number
  name: string
  players: Set<string>
  units: Set<Unit>
  buildings: Set<Building>
}

Migration Table

Touchpoint Current New
Map_Player.js L109-114, 155-158 Creates this.goodGuys/this.badGuys Phaser Containers Creates this.teamManager = new TeamManager(this) then createTeam('team-A', 0x1d7196, 'Alpha') and createTeam('team-B', 0xd94f4f, 'Bravo')
Map_Player.js L158 this.unitFactory = new UnitFactory(this) this.unitFactory = new UnitFactory(this, this.teamManager)
Map_Player.js L160-164 this.orchestrator.systems.combat.registerUnitContainers(this.goodGuys, this.badGuys) Remove — TeamManager is passed via constructor injection
Map_Player.js L191 this.orchestrator.systems.controlPointManager.registerUnitContainers(this.goodGuys, this.badGuys) Remove — CPs query TeamManager directly
Map_Player.js L207-211 orchestrator.systems.combat.registerUnitContainers(this.goodGuys, this.badGuys) Remove
Map_Player.js L244-265 Camera centers on this.goodGuys.list[0] this.teamManager.getAllUnits()[0]
UnitFactory.js new UnitFactory(scene)spawnInfantry(tile, team="player")scene.goodGuys.add() or scene.badGuys.add() Takes teamManager in constructor. spawnInfantry(tile, teamId)this.teamManager.addUnit(unit, teamId). teamId replaces binary team string.
Unit.js L142-168 getEnemyContainer() / getFriendlyContainer() — string match on container name Removed. Replaced by getEnemyUnits() / getFriendlyUnits() delegating to TeamManager
Unit.js L322-343 select() — binary team === 'enemy' red/green tint Uses this.getData('teamId')this.scene.teamManager.getTeamColor(teamId). isSelf = teamId === this.scene.localPlayerTeam
CombatSystem.js registerUnitContainers(goodGuys, enemies)this._goodGuys/this._enemies Constructor takes teamManager. _processCombatGroup iterates teamManager.getAllUnitsGrouped(). acquireTarget uses teamManager.isEnemy().
SystemOrchestrator.js L191-196 this.scene.goodGuys && this.scene.badGuys → passes to CP manager Removed. TeamManager handles it.
ControlPointManager.js registerUnitContainers(goodGuys, enemies) Removed. Uses scene.teamManager.getAllUnitsGrouped() for counting.
ControlPointStateMachine.js Counts units in goodGuys/enemies containers by name Counts per-team using teamManager.getTeamUnits(teamId) for each team. Owner becomes first team to reach majority.
OwnerComponent.js `team: 'good' 'enemy', isEnemy(otherTeam)`
EconomySystem.js Per-player Map. No API change needed. initPlayer(playerId, teamId?) — optional teamId stored. TeamManager routes income to correct team.
WinCondition.js Per-player victory threshold No direct change — queries economy by playerId which maps to team via TeamManager
BuildingStateMachine.js playerId field teamId added alongside playerId
ProductionPanel.js Creates units via UnitFactory Needs TeamManager injection to pass teamId through production queue
ResourceBar.js Shows economy per-player Per-player display unchanged. Could show team summary later.
SelectionSystem.js Binary red/green selection Uses TeamManager color map

UnitFactory Migration

// Old
spawnInfantry(tile, team = "player") {
  const Skin = team === "player" ? Ukrainian_Rifle : Russian_Rifle;
  const unit = new Skin(this.scene, tile);
  if (team === "player") this.scene.goodGuys.add(unit);
  else this.scene.badGuys.add(unit);
  return unit;
}

// New
spawnInfantry(tile, teamId) {
  const team = this.teamManager.getTeam(teamId);
  // Pick skin based on team index: team 0 = Ukrainian, team 1+ = Russian
  const skinIndex = Array.from(this.teamManager.getTeams().keys()).indexOf(teamId);
  const Skin = skinIndex === 0 ? Ukrainian_Rifle : Russian_Rifle;
  const unit = new Skin(this.scene, tile);
  this.teamManager.addUnit(unit, teamId);
  return unit;
}

Test Plan (TDD)

T1: TeamManager unit tests

File: test/systems/TeamManager.test.js

describe('TeamManager', () => {
  describe('createTeam', () => {
    test('creates team with id, color, name')
    test('returns same Team object on duplicate createTeam()')
    test('getTeam returns undefined for unknown teamId')
  })
  
  describe('setPlayerTeam / getPlayerTeam', () => {
    test('maps playerId to teamId')
    test('getPlayerTeam returns undefined for unmapped player')
    test('getTeamPlayers returns set of players in team')
  })
  
  describe('addUnit / removeUnit / getUnitTeam', () => {
    test('addUnit sets unit data and adds to Team.units')
    test('removeUnit removes from one team, re-add to another')
    test('getUnitTeam returns null for unregistered unit')
    test('getAllUnits returns flat array of all units')
    test('getAllUnitsGrouped returns Map<teamId, Set<Unit>>')
  })
  
  describe('addBuilding / removeBuilding / getBuildingTeam', () => {
    test('addBuilding adds to team.buildings')
    test('getTeamBuildings returns set')
    test('getEntityTeam works for buildings')
  })
  
  describe('isEnemy / isSameTeam', () => {
    test('same team → false')
    test('different team → true')
    test('unit without team → null, not enemy')
  })
  
  describe('getEnemyUnits', () => {
    test('returns all units NOT in given team')
  })
  
  describe('serialization', () => {
    test('serialize/deserialize round-trip')
    test('toJSON/fromJSON preserves teams and player mappings')
  })
})

T2: UnitFactory tests (updated)

File: test/systems/UnitFactory.test.js

describe('UnitFactory (with TeamManager)', () => {
  test('spawnInfantry adds unit to correct team via TeamManager')
  test('spawnTank adds unit to correct team via TeamManager')
  test('no longer touches scene.goodGuys or scene.badGuys')
  test('remapped enemy skins: team index 0 = Ukrainian, index 1 = Russian, index 2+ = Russian (fallback)')
})

T3: CombatSystem tests (multi-team)

File: test/systems/CombatSystem.test.js

describe('CombatSystem (multi-team)', () => {
  test('constructor accepts TeamManager, not containers')
  test('_processCombatGroup iterates all team groups')
  test('_checkOverlap checks all teams')
  test('acquireTarget returns enemy unit from any team')
  test('friendly fire prevented by team check')
  test('projectile fired by team-A unit hits team-B and team-C units')
  test('projectile from team-A unit does NOT hit team-A units')
})

T4: Unit tests (updated)

File: test/entities/Unit.test.js

describe('Unit (team-aware)', () => {
  test('getEnemyContainer removed — no longer exists')
  test('getFriendlyContainer removed — no longer exists')
  test('select() uses teamId for tint color')
  test('isEnemy/isSameTeam delegates to TeamManager')
  test('Unit created without team returns null team from getUnitTeam')
})

T5: ControlPoint tests (updated)

File: test/systems/ControlPointStateMachine.test.js

describe('ControlPointStateMachine (multi-team)', () => {
  test('counts units per team instead of goodGuys/badGuys')
  test('NEUTRAL → CONTESTED when units from multiple teams present')
  test('CONTESTED → CAPTURED when one team has majority')
  test('registerUnitContainers removed from public API')
})

T6: Map_Player E2E (updated)

File: test/scenes/Map_Player.test.js or Playwright

describe('Map_Player (multi-team)', () => {
  test('creates TeamManager, not goodGuys/badGuys containers')
  test('spawns 3-team FFA: each team gets different color')
  test('combat resolves correctly across all 3 teams')
  test('control point captures correctly with 3 teams')
  test('UI shows correct team colors for all 3 teams')
})

T7: Integration — 3-team FFA smoke test (Playwright)

describe('3-team FFA E2E', () => {
  test('create game, connect 3 players')
  test('each player is on different team')
  test('economy tracked per-team')
  test('buildings render in team color')
  test('units spawn in team color')
  test('combat resolves: team-A kills team-B, team-C unaffected')
  test('control point captures correctly')
})

Migration order

  1. Create src/systems/TeamManager.js + tests
  2. Update UnitFactory to accept TeamManager
  3. Rewire Map_Player to create TeamManager instead of containers
  4. Rewire CombatSystem for multi-team
  5. Rewire ControlPointManager/ControlPointStateMachine
  6. Update Unit.js (remove binary checks)
  7. Update OwnerComponent (teamId replaces team string)
  8. Integration + E2E