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>
This commit is contained in:
403
docs/plans/2026-02-04-feat-asymmetric-grid-ctf-board-plan.md
Normal file
403
docs/plans/2026-02-04-feat-asymmetric-grid-ctf-board-plan.md
Normal file
@@ -0,0 +1,403 @@
|
||||
---
|
||||
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`
|
||||
Reference in New Issue
Block a user