Files
Backyard-CTF/docs/plans/2026-02-04-feat-asymmetric-grid-ctf-board-plan.md
John Lamb e4ac24f989 feat(grid): Replace real-time CTF with turn-based grid system
Replace continuous free-form movement with discrete 20x50 grid-based
gameplay featuring asymmetric movement mechanics:

- Blue/Red teams with 3 units each
- Zone-based movement: orthogonal (4 dir) in offense, diagonal (8 dir) in defense
- Alternating turns with click-to-select, click-to-move input
- Fog of war: 3-cell Chebyshev vision radius per unit
- Defense speed nerf: skip every 4th move in own zone
- AI opponent that chases flag carriers and advances toward enemy flag
- Collision resolution: defender wins in their zone, lower ID wins in neutral

Implements all 3 phases from the plan:
- Phase 1: Playable grid with hot-seat two-player
- Phase 2: Fog of war + defense speed nerf
- Phase 3: AI opponent

Deleted obsolete files: Flag.cs, Unit.cs, RouteDrawer.cs, SimpleAI.cs, Visibility.cs

Co-Authored-By: Claude <noreply@anthropic.com>
2026-02-07 17:05:44 -06:00

404 lines
14 KiB
Markdown

---
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<GridUnit, Vector2Int> 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<Vector2Int> 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`