# Blokus Gym Harness — Design & Implementation Plan

## 1. Overview

A Gymnasium-compatible RL environment for the board game **Blokus**, supporting both **single-agent** (agent vs. configurable bots) and **multi-agent** (PettingZoo-compatible) modes. The harness uses **numpy** for maximum training speed and broad framework compatibility, with **matplotlib** as an optional rendering backend.

## 2. File Structure

```
blokus/
├── pyproject.toml              # Package config, dependencies, entry points
├── README.md
├── LICENSE
├── PLAN.md                     # This file
├── src/blokus_gym/
│   ├── __init__.py             # Package exports
│   ├── envs/
│   │   ├── __init__.py         # Env exports
│   │   ├── blokus_env.py       # Main gymnasium.Env implementation
│   │   ├── multiagent.py       # PettingZoo-compatible multi-agent wrapper
│   │   └── registration.py     # gymnasium.register() calls for presets
│   ├── core/
│   │   ├── __init__.py
│   │   ├── pieces.py           # Piece definitions, orientation generation, custom piece sets
│   │   ├── board.py            # Board state (numpy array), placement validation, corner tracking
│   │   ├── game.py             # Game rules: turn order, move validation, scoring, game-over detection
│   │   └── bots.py             # Bot implementations: Random, Greedy, Minimax
│   ├── wrappers/
│   │   ├── __init__.py
│   │   └── action_mask.py      # Optional wrapper to expose action masks as part of observation
│   └── utils/
│       ├── __init__.py
│       └── render.py           # Text/ANSI and matplotlib rendering
├── tests/
│   ├── __init__.py
│   ├── conftest.py             # Shared fixtures
│   ├── test_pieces.py
│   ├── test_board.py
│   ├── test_game.py
│   ├── test_env.py
│   ├── test_multiagent.py
│   └── test_registration.py
└── examples/
    ├── basic_usage.py
    ├── train_sb3.py            # Stable-Baselines3 training example
    ├── train_rllib.py          # Ray RLlib training example
    └── multiagent_example.py
```

## 3. Core Design Decisions

### 3.1 Action Space: Discrete + Action Mask

**Design**: Pre-compute all unique `(piece, orientation, position)` combinations into a flat `Discrete(N)` action space. At each step, provide an action mask in `info["action_mask"]` indicating which of the N actions are currently valid.

**Why**:
- Works with any RL algorithm (masking-aware libs like SB3, RLlib use the mask; others can ignore it)
- Simple, standard API
- Action index is stable across episodes (important for policy networks)

**Action space size** (standard 20×20 board, 21 pieces): ~25,000 discrete actions. Smaller boards scale down proportionally.

**Action generation algorithm** (at `__init__` time):
1. For each piece in the piece set, generate all unique orientations (rotations × flips, deduplicated)
2. For each orientation, enumerate all positions where the piece fits on the board
3. Assign each `(piece, orientation, x, y)` a unique integer index
4. Store as a list of `Move` objects (piece_id, orientation_id, x, y) for O(1) lookup

**Action mask computation** (at each `step`):
- Start with all-False mask
- For each action index, check if the move is valid (piece available, in bounds, no overlap, corner rule satisfied)
- Set valid indices to True

### 3.2 Observation Space: Dict with Board, Pieces, and Corners

```python
observation_space = spaces.Dict({
    "board":       spaces.Box(0, num_players, (board_size, board_size), dtype=np.int8),
    "pieces":      spaces.MultiBinary(num_pieces),           # 1 = still available
    "my_turn":     spaces.Discrete(1),                        # Always 1 (agent's perspective)
    "corners":     spaces.Box(0, board_size, (board_size, board_size), dtype=bool),  # Valid corner cells
})
```

**Info dict**:
```python
info = {
    "action_mask": np.array([bool] * num_actions),  # Valid actions
    "current_player": int,
    "players_with_moves": [int, ...],  # Who still has valid moves
    "step_count": int,
}
```

### 3.3 Reward Design

**Single-agent mode** (agent is player 0):
- **Step reward**: `0` by default (sparse). Optional shaping: `+0.01 * piece_size` for each placed piece
- **Terminal reward**: Based on final score comparison
  - `+1.0` if agent wins (higher score than all opponents)
  - `0.0` if tie
  - `-1.0` if agent loses
  - Alternative: normalized score difference `agent_score / max_possible_score`

**Multi-agent mode** (PettingZoo):
- Each agent gets `reward = placed_squares / total_squares` at game end
- Penalty for unplaced squares: `-unplaced_squares / total_squares`
- `+15/total_squares` bonus for emptying all pieces
- `+5/total_squares` bonus for last piece being monomino

### 3.4 Dependencies

| Dependency | Purpose | Required? |
|---|---|---|
| `numpy` | Board state, coordinate math, action masks | Yes |
| `gymnasium` | RL environment API | Yes |
| `pettingzoo` | Multi-agent wrapper | Yes (for multi-agent mode) |
| `matplotlib` | Visual rendering | No (optional) |

**Rationale**: numpy is the universal standard for array operations in Python RL. Every framework (SB3, RLlib, Tianshou, etc.) consumes numpy arrays natively. No torch/tensorflow dependency means faster installation and broader compatibility.

## 4. Component Designs

### 4.1 Pieces (`core/pieces.py`)

**Piece definition**: A piece is a set of `(x, y)` integer coordinates relative to a reference point.

```python
@dataclass
class Piece:
    name: str
    squares: frozenset[tuple[int, int]]  # Relative coordinates
    size: int                             # Number of squares

@dataclass
class OrientedPiece:
    piece_id: int
    orientation_id: int
    squares: list[tuple[int, int]]        # Absolute coordinates on board
    corners: list[tuple[int, int]]        # Corner cells for placement rule
```

**Standard piece set** (`STANDARD_PIECES`): All 21 free polyominoes (1 monomino, 1 domino, 2 trominoes, 5 tetrominoes, 12 pentominoes).

**Orientation generation**:
1. Start with original coordinates
2. Apply 4 rotations (0°, 90°, 180°, 270°) using rotation matrix `(x, y) → (y, -x)`
3. For each rotation, apply 2 flips: original and horizontal-flip `(x, y) → (-x, y)`
4. Normalize each result (shift so min x = 0, min y = 0)
5. Deduplicate by comparing sorted coordinate sets

**Custom piece sets**:
```python
# Option 1: Define custom pieces
custom_pieces = [
    Piece("A", frozenset([(0,0), (1,0), (0,1)])),  # L-tromino
    Piece("B", frozenset([(0,0), (1,0), (2,0)])),  # I-tromino
]

# Option 2: Use a preset
from blokus_gym.core.pieces import STANDARD_PIECES, DUO_PIECES, JUNIOR_PIECES

# Option 3: Load from JSON
pieces = PieceSet.from_json("my_pieces.json")

# Option 4: Filter standard set
pentominoes_only = [p for p in STANDARD_PIECES if p.size == 5]
```

### 4.2 Board (`core/board.py`)

```python
class Board:
    def __init__(self, size: int):
        self.size = size
        self.grid = np.zeros((size, size), dtype=np.int8)  # 0 = empty, 1-4 = player
    
    def place(self, player_idx: int, squares: list[tuple[int, int]]) -> None
    def is_empty(self, x: int, y: int) -> bool
    def in_bounds(self, x: int, y: int) -> bool
    def has_overlap(self, squares: list[tuple[int, int]]) -> bool
    def get_player_corners(self, player_idx: int) -> set[tuple[int, int]]
```

**Key methods**:
- `place()`: Sets grid cells to player index
- `has_overlap()`: Checks if any square is already occupied
- `get_player_corners()`: Returns all corner cells adjacent to player's pieces (for placement rule)

### 4.3 Game Logic (`core/game.py`)

```python
class BlokusGame:
    def __init__(self, board: Board, pieces: list[Piece], num_players: int = 4):
        self.board = board
        self.pieces = pieces
        self.num_players = num_players
        self.current_player = 0
        self.player_pieces: list[set[str]]  # Available piece names per player
        self.player_corners: list[set[tuple[int, int]]]
        self.rounds = 0
        self.game_over = False
    
    def valid_move(self, player_idx: int, move: Move) -> bool
    def apply_move(self, player_idx: int, move: Move) -> None
    def get_valid_moves(self, player_idx: int) -> list[int]  # Returns action indices
    def get_action_mask(self, player_idx: int) -> np.ndarray
    def next_player(self) -> int  # Cycles to next player with moves
    def is_game_over(self) -> bool
    def get_scores(self) -> list[int]  # Negative = unplaced squares
    def get_winners(self) -> list[int]
```

**Move validation rules**:
1. Piece must be in player's available pieces
2. All squares must be in bounds
3. No overlap with existing pieces
4. **Corner rule**: At least one square of the placed piece must touch a same-color piece at a corner (diagonal adjacency)
5. **No edge adjacency**: No square of the placed piece may share an edge with a same-color piece
6. **First move**: Must be placed in a corner (if `corner_rule=True`)

### 4.4 Bots (`core/bots.py`)

```python
class Bot(ABC):
    @abstractmethod
    def select_action(self, game: BlokusGame, player_idx: int, valid_actions: np.ndarray) -> int

class RandomBot(Bot):
    """Randomly selects from valid actions."""

class GreedyBot(Bot):
    """Selects the move that places the most squares (largest piece)."""

class GreedyCornersBot(Bot):
    """Greedy + prefers moves that open up more corners for future play."""

class MinimaxBot(Bot):
    """Minimax search with configurable depth. Primarily for 2-player games."""
```

### 4.5 Gymnasium Environment (`envs/blokus_env.py`)

```python
class BlokusEnv(gymnasium.Env):
    metadata = {"render_modes": ["human", "ansi", "rgb_array"]}
    
    def __init__(
        self,
        num_players: int = 4,
        board_size: int = 20,
        pieces: list[Piece] | None = None,  # Default: STANDARD_PIECES
        render_mode: str | None = None,
        bot_type: type[Bot] = RandomBot,
        bot_strength: int = 1,
        reward_shaping: bool = False,
        corner_rule: bool = True,
        max_steps: int | None = None,
        seed: int | None = None,
    ):
        super().__init__()
        # Initialize game, spaces, action lookup table
    
    def reset(self, seed=None, options=None) -> tuple[dict, dict]
    def step(self, action: int) -> tuple[dict, float, bool, bool, dict]
    def render(self) -> str | np.ndarray | None
    def close(self)
```

**Key implementation details**:
- `__init__` generates the full action lookup table (piece, orientation, position → index)
- `reset()` initializes a new game, places bots, optionally lets bots take first turns
- `step()` applies the agent's action, then lets bots play until it's the agent's turn again
- Returns `(observation, reward, terminated, truncated, info)` where `info` contains the action mask

### 4.6 Multi-Agent Wrapper (`envs/multiagent.py`)

PettingZoo-compatible wrapper that exposes all players as separate agents:

```python
class BlokusMultiAgentEnv(AECEnv):
    def __init__(self, **kwargs):
        # Same config as BlokusEnv, but wraps it
    
    def reset(self, seed=None, options=None) -> tuple[dict, dict]
    def step(self, action: int) -> None
    # PettingZoo API: observe(), close(), render(), etc.
    
    @property
    def agents(self) -> list[str]  # ["player_0", "player_1", ...]
    @property
    def agent_selection(self) -> str
```

**Behavior**:
- Cycles through players in order (0, 1, 2, 3)
- Each agent gets its own observation (board + own pieces + own corners + action mask)
- Rewards are per-agent: negative for unplaced squares, bonus for emptying pieces
- Agents with no valid moves are marked as done (truncated) but still receive final rewards

### 4.7 Registration (`envs/registration.py`)

Predefined environment configurations registered with `gymnasium.register()`:

```python
# Standard 4-player Blokus
"Blokus-v0":        num_players=4, board_size=20, pieces=STANDARD_PIECES

# Blokus Duo (2 players, 14x14)
"BlokusDuo-v0":     num_players=2, board_size=14, pieces=STANDARD_PIECES

# Blokus Junior (2 players, 14x14, simplified pieces)
"BlokusJunior-v0":  num_players=2, board_size=14, pieces=JUNIOR_PIECES

# Simple test env (2 players, 7x7, only pieces < 5 squares)
"BlokusSimple-v0":  num_players=2, board_size=7, pieces=[p for p in STANDARD_PIECES if p.size < 5]

# Greedy bot variants
"BlokusGreedy-v0":  bot_type=GreedyBot
"BlokusDuoGreedy-v0": bot_type=GreedyBot, num_players=2, board_size=14
```

### 4.8 Rendering (`utils/render.py`)

**Text/ANSI mode** (`render_mode="human"`):
```
  0 1 2 3 4 5 6 7 8 9 ...
0 . . . . . . . . . .
1 . . . . . . . . . .
2 . . . . . . . . . .
3 . . . . . . . . . .
4 . . . . . . . . . .
...
Player 0 (Red): I1 I2 I3 L4 O4 I4 S4 T4 I5 L5 N P T U V W X Y Z
Player 1 (Blue): I1 I2 I3 L4 O4 I4 S4 T4 I5 L5 N P T U V W X Y Z
Current player: 0
Valid moves: 152
```

**RGB array mode** (`render_mode="rgb_array"`):
- Matplotlib figure with colored squares (red, blue, yellow, green, grey)
- Grid lines
- Legend showing remaining pieces per player
- Valid move indicators (semi-transparent overlays)

### 4.9 Action Mask Wrapper (`wrappers/action_mask.py`)

Optional wrapper that moves the action mask from `info` into the observation space:

```python
class ActionMaskWrapper(Wrapper):
    def __init__(self, env):
        super().__init__(env)
        self.observation_space = spaces.Dict({
            "observation": env.observation_space,
            "action_mask": spaces.Box(0, 1, (env.action_space.n,), dtype=bool),
        })
    
    def step(self, action):
        obs, reward, terminated, truncated, info = self.env.step(action)
        return {
            "observation": obs,
            "action_mask": info["action_mask"],
        }, reward, terminated, truncated, info
```

## 5. Customization Options

All options are passed as keyword arguments to `BlokusEnv()` or via `gymnasium.make()`:

| Option | Type | Default | Description |
|---|---|---|---|
| `num_players` | int | 4 | Number of players (2-4) |
| `board_size` | int | 20 | Board dimension (standard: 20, Duo: 14) |
| `pieces` | list[Piece] | STANDARD_PIECES | Custom piece set |
| `bot_type` | type[Bot] | RandomBot | Opponent bot type |
| `bot_strength` | int | 1 | Bot search depth (for Minimax) |
| `reward_shaping` | bool | False | Enable intermediate rewards |
| `corner_rule` | bool | True | Require first move in corner |
| `max_steps` | int | None | Max steps before truncation |
| `render_mode` | str | None | "human", "ansi", "rgb_array" |
| `seed` | int | None | Random seed |
| `starting_corners` | list | [(0,0), (0,19), (19,0), (19,19)] | Custom starting corners |
| `scoring_mode` | str | "standard" | "standard", "coverage", "winloss" |
| `first_player` | int | 0 | Which player goes first (agent is always 0) |

## 6. Implementation Phases

### Phase 1: Core Game Logic (Days 1-3)
- [ ] `core/pieces.py` — Piece definitions, orientation generation, custom piece support
- [ ] `core/board.py` — Board state, placement validation, corner tracking
- [ ] `core/game.py` — Game rules, move validation, scoring, game-over detection
- [ ] Unit tests for all three modules

### Phase 2: Bots (Days 4-5)
- [ ] `core/bots.py` — Random, Greedy, GreedyCorners, Minimax bots
- [ ] Tests for bot behavior

### Phase 3: Gymnasium Environment (Days 6-8)
- [ ] `envs/blokus_env.py` — Main environment class
- [ ] Action space generation and masking
- [ ] Observation and reward design
- [ ] `envs/registration.py` — Preset registrations
- [ ] `wrappers/action_mask.py` — Optional action mask wrapper
- [ ] `gymnasium.utils.env_checker.check_env()` passes

### Phase 4: Multi-Agent (Days 9-10)
- [ ] `envs/multiagent.py` — PettingZoo AECEnv wrapper
- [ ] Per-agent observations and rewards
- [ ] Tests for multi-agent mode

### Phase 5: Rendering (Days 11-12)
- [ ] `utils/render.py` — Text/ANSI and matplotlib rendering
- [ ] Integration with environment

### Phase 6: Testing & Examples (Days 13-14)
- [ ] Comprehensive test suite (unit + integration)
- [ ] `examples/basic_usage.py` — Basic usage
- [ ] `examples/train_sb3.py` — SB3 training
- [ ] `examples/train_rllib.py` — RLlib training
- [ ] `examples/multiagent_example.py` — Multi-agent
- [ ] `pyproject.toml` with all dependencies
- [ ] `README.md` with full documentation

## 7. Testing Strategy

### Unit Tests
- **Pieces**: Orientation generation correctness, deduplication, custom piece loading
- **Board**: Placement, overlap detection, bounds checking, corner computation
- **Game**: Move validation (all rules), scoring, game-over detection, turn cycling
- **Bots**: Random bot returns valid moves, greedy bot prefers larger pieces

### Integration Tests
- **Environment**: `check_env()` passes, reset/step return correct types
- **Action mask**: All masked actions are valid, all valid actions are masked
- **Game flow**: Full game from start to end, correct winner detection
- **Multi-agent**: All agents receive observations, rewards, and termination signals

### Edge Cases
- Player with no valid moves (should be skipped)
- Game ends with all players blocked
- Last piece is monomino (bonus scoring)
- Custom piece sets with unusual shapes
- Small boards where pieces don't fit

## 8. Key Design Tradeoffs

| Decision | Choice | Rationale |
|---|---|---|
| Gym library | Gymnasium | Maintained fork, latest API |
| Agent mode | Single + Multi-agent | Covers both use cases |
| Action space | Discrete + mask | Broadest RL framework compatibility |
| Dependencies | numpy only | Fastest, broadest compatibility |
| Rendering | Text + matplotlib | No Pygame dependency, optional |
| Piece definition | Coordinate sets | Simple, flexible for custom pieces |
| Action pre-computation | At init time | Stable action indices, fast step() |
| Multi-agent | PettingZoo AECEnv | Standard API, compatible with RLlib/PettingZoo algorithms |
