458 lines
18 KiB
Markdown
458 lines
18 KiB
Markdown
# 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 |
|