--- title: "feat: Asymmetric Grid CTF Board" type: feat date: 2026-02-04 brainstorm: docs/brainstorms/2026-02-04-asymmetric-grid-ctf-brainstorm.md --- # feat: Asymmetric Grid CTF Board ## Overview Replace the current real-time continuous movement CTF game with a turn-based grid system featuring asymmetric movement mechanics. Each team's 3 pieces move differently based on which zone they occupy: orthogonal (Manhattan) in offensive zones, diagonal (Chebyshev) in defensive zones. **Key changes from current implementation:** - Continuous free-form movement → Discrete grid-based movement - Real-time gameplay → Alternating turn-based - Uniform movement → Zone-dependent movement rules ## Problem Statement The current implementation is a real-time CTF game where: - Units move continuously along drawn paths - All units have the same movement capabilities - The defense has no inherent advantage The game design analysis (see `docs/CAPTURE THE FLAG.pdf`) shows that asymmetric movement creates interesting strategic depth: - Defense moving diagonally covers more ground (Chebyshev distance) - Offense moving orthogonally is predictable (Manhattan distance) - With 75% defense speed nerf, this becomes a balanced mixed-strategy game ## Proposed Solution Build a 20x50 grid-based board with three zones and alternating turn-based gameplay. ### Board Layout ``` ┌────────────────────────────────────────────────────────┐ │ TEAM B DEFENSE │ Y: 30-49 │ (Team A Offense) │ (20 rows) │ [Diagonal Movement] │ │ 🚩 Flag B │ ├────────────────────────────────────────────────────────┤ │ NEUTRAL ZONE │ Y: 20-29 │ (Both teams on offense) │ (10 rows) │ [Orthogonal Movement] │ ├────────────────────────────────────────────────────────┤ │ TEAM A DEFENSE │ Y: 0-19 │ (Team B Offense) │ (20 rows) │ [Diagonal Movement] │ │ 🚩 Flag A │ └────────────────────────────────────────────────────────┘ X: 0 ←────────── 20 cells ──────────→ 19 ``` ### Movement Rules | Zone (for piece) | Movement Type | Speed | |------------------|---------------|-------| | Own Defense | Diagonal (8 directions) | 75% (skip every 4th turn) | | Enemy Defense (Offense) | Orthogonal (4 directions: N/S/E/W) | 100% | | Neutral | Orthogonal (4 directions) | 100% | ### Turn Flow (Alternating) ``` ┌─────────────────┐ │ PLAYER TURN │ Click unit → click destination → unit moves └────────┬────────┘ ▼ ┌─────────────────┐ │ RESOLVE │ Check collision, flag pickup, scoring └────────┬────────┘ ▼ ┌─────────────────┐ │ AI TURN │ AI selects and executes move └────────┬────────┘ ▼ ┌─────────────────┐ │ RESOLVE │ Check collision, flag pickup, scoring └────────┬────────┘ ▼ Next turn ``` **Note:** Start with alternating turns for simplicity. Simultaneous turns can be added later if alternating feels too simple. ## Technical Approach ### Architecture (3 Classes) ``` Assets/Scripts/ ├── Grid/ │ ├── GridBoard.cs # Board state, zones, rendering, input, turn logic │ ├── GridUnit.cs # Unit data struct │ └── GridAI.cs # AI decision-making (Phase 3) ├── Game.cs # Bootstrap, update for grid ├── Flag.cs # Adapt for grid coordinates └── CameraController.cs # Keep as-is ``` ### Zone Boundaries (Constants) ```csharp public static class ZoneBoundaries { public const int TeamADefenseEnd = 20; // Y < 20 is Team A defense public const int NeutralEnd = 30; // Y < 30 is neutral (if >= TeamADefenseEnd) public const int BoardWidth = 20; public const int BoardHeight = 50; } ``` ### Data Structures ```csharp public enum Team { Blue, Red } // Canonical names used everywhere public enum ZoneOwner { Blue, Neutral, Red } // GridUnit.cs - minimal data struct public class GridUnit { public int UnitId; // Stable identifier for determinism public Team Team; public Vector2Int GridPosition; public int ConsecutiveDefenseMoves; // Reset when leaving defense zone public bool IsTaggedOut; public int RespawnTurnsRemaining; // 0 when active // Note: HasFlag NOT stored here - query Flag.CarriedBy instead } // GridBoard.cs - all game logic in one place public class GridBoard : MonoBehaviour { // Board is a 2D array of unit references (null = empty) GridUnit[,] cellOccupants = new GridUnit[BoardWidth, BoardHeight]; // Canonical position storage - single source of truth Dictionary unitPositions = new(); public ZoneOwner GetZoneOwner(int y) => y switch { < ZoneBoundaries.TeamADefenseEnd => ZoneOwner.Blue, < ZoneBoundaries.NeutralEnd => ZoneOwner.Neutral, _ => ZoneOwner.Red }; public bool IsDefending(GridUnit unit) => (unit.Team == Team.Blue && GetZoneOwner(unit.GridPosition.y) == ZoneOwner.Blue) || (unit.Team == Team.Red && GetZoneOwner(unit.GridPosition.y) == ZoneOwner.Red); // Atomic move that keeps both data structures in sync public void MoveUnit(GridUnit unit, Vector2Int to) { var from = unitPositions[unit]; cellOccupants[from.x, from.y] = null; cellOccupants[to.x, to.y] = unit; unitPositions[unit] = to; unit.GridPosition = to; } } ``` ### Collision Resolution (Deterministic) Collisions are resolved deterministically by unit ID (lower ID wins ties): ```csharp void ResolveCollision(GridUnit unitA, GridUnit unitB, Vector2Int cell) { // Same team = no collision (can share cell temporarily) if (unitA.Team == unitB.Team) return; var zoneOwner = GetZoneOwner(cell.y); // Defender in their zone always wins if (unitA.Team == Team.Blue && zoneOwner == ZoneOwner.Blue) TagOut(unitB); else if (unitB.Team == Team.Blue && zoneOwner == ZoneOwner.Blue) TagOut(unitA); else if (unitA.Team == Team.Red && zoneOwner == ZoneOwner.Red) TagOut(unitB); else if (unitB.Team == Team.Red && zoneOwner == ZoneOwner.Red) TagOut(unitA); else { // Neutral zone: lower UnitId wins (deterministic) if (unitA.UnitId < unitB.UnitId) TagOut(unitB); else TagOut(unitA); } } ``` ### Turn Execution Order Each turn resolves in this exact order: 1. **Speed nerf check** - If unit is defending and `ConsecutiveDefenseMoves % 4 == 3`, skip move 2. **Execute move** - Update position 3. **Collision check** - Tag losing unit if two enemies on same cell 4. **Flag pickup** - Unit on enemy flag cell picks it up 5. **Flag drop** - Tagged unit drops flag at current position 6. **Score check** - Flag carrier in own base scores 7. **Respawn tick** - Decrement `RespawnTurnsRemaining`, respawn if 0 8. **Update visibility** - Toggle enemy sprite visibility (Phase 2) ## Implementation Phases ### Phase 1: Playable Grid **Goal:** Two humans can play hot-seat CTF on a grid. **Tasks:** - [x] Create `GridBoard.cs`: - Render 20x50 grid using existing `CreateSprite()` helper - Color zones (blue tint for Blue defense, gray for neutral, red tint for Red defense) - Handle mouse input (click unit, click destination) - Implement `GetValidMoves()` with zone-aware movement - Implement `MoveUnit()` with atomic position update - Implement collision resolution - [x] Create `GridUnit.cs` as minimal data struct - [x] Adapt `Flag.cs` for grid coordinates: - Flag pickup on entering cell - Flag drop on tag (stays at cell) - Flag return when friendly touches it - Score when carrier reaches own base - [x] Modify `Game.cs` to instantiate `GridBoard` instead of current map - [x] Alternating turns: `bool isPlayerTurn`, swap after each move - [x] Win at 3 points, use existing `gameOverText` **Files to create:** - `Assets/Scripts/Grid/GridBoard.cs` - `Assets/Scripts/Grid/GridUnit.cs` **Files to modify:** - `Assets/Scripts/Game.cs` - `Assets/Scripts/Flag.cs` **Verification:** ``` Run game → See colored grid with 6 pieces and 2 flags Click unit → Valid moves highlight (4 or 8 based on zone) Click valid cell → Unit moves, turn swaps Move to enemy flag → Pick up flag Return to base with flag → Score point Move onto enemy in your zone → Enemy respawns First to 3 → "You Win" / "You Lose" ``` ### Phase 2: Asymmetric Mechanics **Goal:** Add the mechanics that create strategic depth. **Tasks:** - [x] Add fog of war: - Each unit sees 3 cells (Chebyshev distance) - Toggle enemy sprite `enabled` based on visibility - Recalculate after each move - [x] Add defense speed nerf: - Track `ConsecutiveDefenseMoves` on GridUnit - Skip every 4th move when defending - Reset counter when leaving defense zone - Visual indicator (dim sprite) when next move will be skipped **Fog visibility (method in GridBoard):** ```csharp HashSet visibleToBlue = new(); void RecalculateVisibility() { visibleToBlue.Clear(); foreach (var unit in blueUnits) { if (unit.IsTaggedOut) continue; for (int dx = -3; dx <= 3; dx++) for (int dy = -3; dy <= 3; dy++) { var cell = unit.GridPosition + new Vector2Int(dx, dy); if (IsInBounds(cell)) visibleToBlue.Add(cell); } } foreach (var enemy in redUnits) enemy.Sprite.enabled = visibleToBlue.Contains(enemy.GridPosition); } ``` **Verification:** ``` Start game → Red units partially hidden Move Blue unit → Fog reveals new cells Defender moves 3 times → 4th move skipped (unit dims beforehand) Defender leaves zone → Counter resets ``` ### Phase 3: AI Opponent **Goal:** Single-player mode. **Tasks:** - [x] Create `GridAI.cs`: - AI respects fog (only sees what its units see) - Simple strategy: chase visible flag carrier, else advance toward flag - AI takes turn after player - [x] Delete `Assets/Scripts/SimpleAI.cs` **AI decision (single difficulty):** ```csharp Vector2Int? DecideMove(GridUnit unit) { var validMoves = board.GetValidMoves(unit); if (validMoves.Count == 0) return null; // Priority 1: Chase visible flag carrier var carrier = GetVisibleEnemyFlagCarrier(); if (carrier != null) return validMoves.OrderBy(m => Distance(m, carrier.GridPosition)).First(); // Priority 2: Advance toward enemy flag var flagPos = enemyFlag.GridPosition; return validMoves.OrderBy(m => Distance(m, flagPos)).First(); } ``` **Files to create:** - `Assets/Scripts/Grid/GridAI.cs` **Files to delete:** - `Assets/Scripts/SimpleAI.cs` **Verification:** ``` Start game → AI takes turns after player AI chases if it sees flag carrier AI advances toward flag otherwise AI doesn't react to units outside fog Full game completes without errors ``` ## Deferred (Not MVP) These features are explicitly deferred until the core loop is validated: - Simultaneous turns (adds: planned moves storage, submit button, ghost indicators, conflict resolution) - Turn timer - Sound effects - Camera auto-zoom - Zone labels - Win/lose screen (use `Debug.Log` or existing `gameOverText`) - Multiple AI difficulties - Respawn delay (use instant respawn) - Flag auto-return timer (flags stay where dropped) ## Edge Cases to Handle | Edge Case | Resolution | |-----------|------------| | Three+ units on same cell | Process collisions pairwise by UnitId order | | Zone crossing during move | Speed nerf based on starting position | | Flag dropped in neutral | Can be "returned" by either team touching it | | Both carriers tagged same turn | Both flags drop (alternating turns makes this impossible) | | Tagged while about to score | Tag resolves before score check | | Skip turn with flag | Carrier stays in place, keeps flag | | Respawn location occupied | Respawn at nearest empty cell in base | ## Acceptance Criteria ### Functional (Phase 1 - MVP) - [x] 20x50 grid renders with three colored zones - [x] 6 pieces (3 per team) at starting positions - [x] Orthogonal movement in offense zones, diagonal in defense - [x] Alternating turns - [x] Collision: defender wins in their zone - [x] Flags: pickup, drop on tag, return on touch, score on base - [x] First to 3 wins ### Functional (Phase 2) - [x] 3-cell vision radius per unit - [x] Enemy units hidden outside fog - [x] Defenders skip every 4th move ### Functional (Phase 3) - [x] AI plays Red team - [x] AI respects fog - [x] Full game completes ## Success Metrics 1. **Playable:** Full game loop from start to victory works 2. **Fun check:** Does asymmetric movement feel strategically interesting? Balance testing and engagement metrics deferred until core loop is validated. ## References - Brainstorm: `docs/brainstorms/2026-02-04-asymmetric-grid-ctf-brainstorm.md` - Game design PDF: `docs/CAPTURE THE FLAG.pdf` - Current movement: `Assets/Scripts/Unit.cs:42` - Bootstrap pattern: `Assets/Scripts/Game.cs:15`