initial commit

This commit is contained in:
mattlamb227@gmail.com
2026-08-05 16:42:57 -04:00
commit 4eb34bfeeb
46 changed files with 3287 additions and 0 deletions
+457
View File
@@ -0,0 +1,457 @@
# 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 |