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
+33
View File
@@ -0,0 +1,33 @@
Metadata-Version: 2.4
Name: blokus-gym
Version: 0.1.0
Summary: A Gymnasium-compatible RL environment for the board game Blokus
Author: Blokus Gym Contributors
License: MIT
Project-URL: Homepage, https://github.com/blokus-gym/blokus-gym
Project-URL: Documentation, https://github.com/blokus-gym/blokus-gym#readme
Keywords: reinforcement-learning,gymnasium,blokus,board-game,pettingzoo
Classifier: Development Status :: 3 - Alpha
Classifier: Intended Audience :: Science/Research
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
Requires-Python: >=3.10
Description-Content-Type: text/markdown
Requires-Dist: numpy>=1.24
Requires-Dist: gymnasium>=0.29
Requires-Dist: pettingzoo>=1.26
Provides-Extra: render
Requires-Dist: matplotlib>=3.7; extra == "render"
Provides-Extra: dev
Requires-Dist: pytest>=7.4; extra == "dev"
Requires-Dist: pytest-cov>=4.1; extra == "dev"
Requires-Dist: black>=23.7; extra == "dev"
Requires-Dist: ruff>=0.1.0; extra == "dev"
Requires-Dist: mypy>=1.5; extra == "dev"
Provides-Extra: train
Requires-Dist: stable-baselines3>=2.2; extra == "train"
Requires-Dist: torch>=2.0; extra == "train"
+19
View File
@@ -0,0 +1,19 @@
pyproject.toml
src/blokus_gym/__init__.py
src/blokus_gym.egg-info/PKG-INFO
src/blokus_gym.egg-info/SOURCES.txt
src/blokus_gym.egg-info/dependency_links.txt
src/blokus_gym.egg-info/requires.txt
src/blokus_gym.egg-info/top_level.txt
src/blokus_gym/core/__init__.py
src/blokus_gym/core/board.py
src/blokus_gym/core/bots.py
src/blokus_gym/core/game.py
src/blokus_gym/core/pieces.py
src/blokus_gym/envs/__init__.py
src/blokus_gym/envs/blokus_env.py
src/blokus_gym/envs/multiagent.py
src/blokus_gym/utils/__init__.py
src/blokus_gym/utils/render.py
src/blokus_gym/wrappers/__init__.py
src/blokus_gym/wrappers/action_mask.py
@@ -0,0 +1 @@
+17
View File
@@ -0,0 +1,17 @@
numpy>=1.24
gymnasium>=0.29
pettingzoo>=1.26
[dev]
pytest>=7.4
pytest-cov>=4.1
black>=23.7
ruff>=0.1.0
mypy>=1.5
[render]
matplotlib>=3.7
[train]
stable-baselines3>=2.2
torch>=2.0
+1
View File
@@ -0,0 +1 @@
blokus_gym
+39
View File
@@ -0,0 +1,39 @@
from blokus_gym.core.board import Board
from blokus_gym.core.bots import Bot, GreedyBot, GreedyCornersBot, MinimaxBot, RandomBot
from blokus_gym.core.game import BlokusGame, Move
from blokus_gym.core.pieces import (
DUO_PIECES,
JUNIOR_PIECES,
STANDARD_PIECES,
Piece,
PieceOrientation,
PieceSet,
generate_orientations,
)
from blokus_gym.envs.blokus_env import BlokusEnv
from blokus_gym.envs.multiagent import BlokusMultiAgentEnv
from blokus_gym.wrappers.action_mask import ActionMaskWrapper
__version__ = "0.1.0"
__all__ = [
"BlokusEnv",
"BlokusMultiAgentEnv",
"ActionMaskWrapper",
"Board",
"BlokusGame",
"Move",
"Piece",
"PieceOrientation",
"PieceSet",
"STANDARD_PIECES",
"DUO_PIECES",
"JUNIOR_PIECES",
"generate_orientations",
"Bot",
"RandomBot",
"GreedyBot",
"GreedyCornersBot",
"MinimaxBot",
"__version__",
]
Binary file not shown.
+30
View File
@@ -0,0 +1,30 @@
from blokus_gym.core.board import Board
from blokus_gym.core.bots import Bot, GreedyBot, GreedyCornersBot, MinimaxBot, RandomBot
from blokus_gym.core.game import BlokusGame, Move
from blokus_gym.core.pieces import (
DUO_PIECES,
JUNIOR_PIECES,
STANDARD_PIECES,
Piece,
PieceOrientation,
PieceSet,
generate_orientations,
)
__all__ = [
"Board",
"BlokusGame",
"Move",
"Piece",
"PieceOrientation",
"PieceSet",
"STANDARD_PIECES",
"DUO_PIECES",
"JUNIOR_PIECES",
"generate_orientations",
"Bot",
"RandomBot",
"GreedyBot",
"GreedyCornersBot",
"MinimaxBot",
]
Binary file not shown.
Binary file not shown.
+89
View File
@@ -0,0 +1,89 @@
from __future__ import annotations
import numpy as np
class Board:
"""A square game board represented as a numpy array.
Cell values:
0 = empty
1-4 = player index (1-based for consistency with Blokus colors)
"""
EMPTY = 0
def __init__(self, size: int):
self.size = size
self.grid = np.zeros((size, size), dtype=np.int8)
def place(self, player_idx: int, squares: list[tuple[int, int]]) -> None:
"""Place a player's piece on the board."""
for x, y in squares:
self.grid[y, x] = player_idx
def clear(self) -> None:
"""Reset the board to empty."""
self.grid.fill(0)
def is_empty(self, x: int, y: int) -> bool:
"""Check if a cell is empty."""
return self.grid[y, x] == 0
def in_bounds(self, x: int, y: int) -> bool:
"""Check if coordinates are within the board."""
return 0 <= x < self.size and 0 <= y < self.size
def has_overlap(self, squares: list[tuple[int, int]]) -> bool:
"""Check if any square in the list is already occupied."""
for x, y in squares:
if not self.in_bounds(x, y) or self.grid[y, x] != 0:
return True
return False
def get_cell(self, x: int, y: int) -> int:
"""Get the player index at a cell (0 = empty)."""
return self.grid[y, x]
def get_player_squares(self, player_idx: int) -> list[tuple[int, int]]:
"""Get all squares occupied by a player."""
ys, xs = np.where(self.grid == player_idx)
return [(int(x), int(y)) for x, y in zip(xs, ys, strict=True)]
def get_player_corners(self, player_idx: int) -> set[tuple[int, int]]:
"""Get all corner cells adjacent to a player's pieces.
Corner cells are the diagonal neighbors of a player's pieces.
These are the cells where the player can place new pieces
(corner-to-corner contact rule).
"""
corners: set[tuple[int, int]] = set()
player_squares = self.get_player_squares(player_idx)
for x, y in player_squares:
for dx, dy in [(-1, -1), (1, -1), (-1, 1), (1, 1)]:
cx, cy = x + dx, y + dy
if self.in_bounds(cx, cy) and self.grid[cy, cx] == 0:
corners.add((cx, cy))
return corners
def get_occupied(self) -> set[tuple[int, int]]:
"""Get all occupied cells."""
ys, xs = np.where(self.grid != 0)
return {(int(x), int(y)) for x, y in zip(xs, ys, strict=True)}
def is_full(self) -> bool:
"""Check if the board is completely full."""
return np.all(self.grid != 0)
def coverage(self) -> float:
"""Get the fraction of the board that is occupied."""
return float(np.count_nonzero(self.grid)) / (self.size * self.size)
def copy(self) -> Board:
"""Create a deep copy of the board."""
new_board = Board(self.size)
new_board.grid = self.grid.copy()
return new_board
def __repr__(self) -> str:
return f"Board(size={self.size}, coverage={self.coverage():.2%})"
+168
View File
@@ -0,0 +1,168 @@
from __future__ import annotations
import random
from abc import ABC, abstractmethod
import numpy as np
from blokus_gym.core.game import BlokusGame
class Bot(ABC):
"""Base class for Blokus bots."""
def __init__(self, player_idx: int, seed: int | None = None):
self.player_idx = player_idx
self.rng = random.Random(seed)
@abstractmethod
def select_action(self, game: BlokusGame, valid_actions: np.ndarray) -> int | None:
"""Select an action from the valid actions.
Args:
game: The current game state.
valid_actions: Boolean mask of valid actions.
Returns:
Action index, or None if no valid moves.
"""
...
def __repr__(self) -> str:
return f"{self.__class__.__name__}(player={self.player_idx})"
class RandomBot(Bot):
"""Randomly selects from valid actions."""
def select_action(self, game: BlokusGame, valid_actions: np.ndarray) -> int | None:
valid_indices = np.where(valid_actions)[0]
if len(valid_indices) == 0:
return None
return int(self.rng.choice(valid_indices))
class GreedyBot(Bot):
"""Selects the move that places the most squares (largest piece)."""
def select_action(self, game: BlokusGame, valid_actions: np.ndarray) -> int | None:
valid_indices = np.where(valid_actions)[0]
if len(valid_indices) == 0:
return None
best_action = -1
best_size = -1
for action in valid_indices:
move = game.get_move(int(action))
piece = game.piece_set.get_piece(move.piece_id)
if piece.size > best_size:
best_size = piece.size
best_action = int(action)
return best_action
class GreedyCornersBot(Bot):
"""Greedy bot that prefers moves opening more corners for future play."""
def select_action(self, game: BlokusGame, valid_actions: np.ndarray) -> int | None:
valid_indices = np.where(valid_actions)[0]
if len(valid_indices) == 0:
return None
best_action = -1
best_score = float("-inf")
for action in valid_indices:
move = game.get_move(int(action))
piece = game.piece_set.get_piece(move.piece_id)
placed_corners = game._get_placed_corners(move)
# Count how many new corners this move opens
new_corners = 0
for cx, cy in placed_corners:
if game.board.in_bounds(cx, cy) and game.board.is_empty(cx, cy):
new_corners += 1
# Score = piece size + corner bonus
score = piece.size + new_corners * 0.5
if score > best_score:
best_score = score
best_action = int(action)
return best_action
class MinimaxBot(Bot):
"""Minimax search bot (primarily for 2-player games).
Uses a depth-limited minimax search with a simple heuristic.
"""
def __init__(self, player_idx: int, depth: int = 2, seed: int | None = None):
super().__init__(player_idx, seed)
self.depth = depth
def select_action(self, game: BlokusGame, valid_actions: np.ndarray) -> int | None:
valid_indices = np.where(valid_actions)[0]
if len(valid_indices) == 0:
return None
if len(valid_indices) == 1:
return int(valid_indices[0])
best_action = -1
best_value = float("-inf")
for action in valid_indices:
if game.num_players != 2:
# For multi-player, just use greedy
return GreedyBot(self.player_idx, self.rng.randint(0, 2**31)).select_action(
game, valid_actions
)
cloned = game.copy()
success = cloned.play_move(self.player_idx, int(action))
if not success:
continue
value = self._minimax(cloned, self.depth - 1, False)
if value > best_value:
best_value = value
best_action = int(action)
return best_action if best_action >= 0 else int(valid_indices[0])
def _minimax(self, game: BlokusGame, depth: int, maximizing: bool) -> float:
if depth == 0 or game.is_game_over():
return self._evaluate(game)
if maximizing:
value = float("-inf")
valid = game.get_valid_actions(self.player_idx)
valid_indices = np.where(valid)[0]
for action in valid_indices:
cloned = game.copy()
if cloned.play_move(self.player_idx, int(action)):
val = self._minimax(cloned, depth - 1, False)
value = max(value, val)
return value
else:
# Opponent's turn
opp_idx = 1 - self.player_idx
value = float("inf")
valid = game.get_valid_actions(opp_idx)
valid_indices = np.where(valid)[0]
for action in valid_indices:
cloned = game.copy()
if cloned.play_move(opp_idx, int(action)):
val = self._minimax(cloned, depth - 1, True)
value = min(value, val)
return value
def _evaluate(self, game: BlokusGame) -> float:
"""Simple heuristic: difference in placed squares."""
my_score = game.players[self.player_idx].score
opp_idx = 1 - self.player_idx
opp_score = game.players[opp_idx].score if opp_idx < len(game.players) else 0
return float(my_score - opp_score)
+414
View File
@@ -0,0 +1,414 @@
from __future__ import annotations
from dataclasses import dataclass, field
import numpy as np
from blokus_gym.core.board import Board
from blokus_gym.core.pieces import STANDARD_PIECES, Move, PieceSet
@dataclass
class PlayerState:
"""Tracks the state of a single player in the game."""
idx: int # 0-based player index
available_pieces: set[str] = field(default_factory=set) # Piece names still available
score: int = 0 # Squares placed (positive = good)
corners: set[tuple[int, int]] = field(default_factory=set) # Valid placement corners
has_started: bool = False # Whether player has placed their first piece
can_move: bool = True # Whether player can still make any move
class BlokusGame:
"""Core Blokus game logic.
Manages the board state, player turns, move validation, and scoring.
This class is independent of the Gymnasium environment and can be
used standalone for game simulation or bot development.
"""
# Player indices are 1-based for board representation
# (0 = empty, 1 = player 1, etc.)
PLAYER_OFFSET = 1
def __init__(
self,
board_size: int = 20,
pieces: PieceSet | None = None,
num_players: int = 4,
corner_rule: bool = True,
):
self.board_size = board_size
self.piece_set = pieces or PieceSet(STANDARD_PIECES)
self.num_players = num_players
self.corner_rule = corner_rule
self.board = Board(board_size)
self.players: list[PlayerState] = []
self.current_player = 0
self.rounds = 0
self.game_over = False
# Pre-compute all possible moves (action lookup table)
self._action_moves: list[Move] = []
self._move_to_action: dict[tuple[int, int, int, int], int] = {}
self._generate_action_space()
# Starting corners for each player (standard Blokus layout)
# 4 players: all four corners
# 2 players: opposite corners (0,0) and (max, max)
# 3 players: three corners
max_idx = board_size - 1
self._starting_corners = [
(0, 0),
(0, max_idx),
(max_idx, 0),
(max_idx, max_idx),
]
if num_players == 2:
self._starting_corners = [(0, 0), (max_idx, max_idx)]
elif num_players == 3:
self._starting_corners = [(0, 0), (0, max_idx), (max_idx, 0)]
# ------------------------------------------------------------------
# Action space generation
# ------------------------------------------------------------------
def _generate_action_space(self) -> None:
"""Pre-compute all possible (piece, orientation, position) moves.
Each unique combination is assigned a stable integer action index.
"""
self._action_moves = []
self._move_to_action = {}
for piece_id in range(self.piece_set.num_pieces):
for orient_id, orient in enumerate(self.piece_set.get_orientations(piece_id)):
squares = orient.squares
# Compute bounding box of the piece
max_x = max(x for x, _ in squares)
max_y = max(y for _, y in squares)
for px in range(self.board_size - max_x):
for py in range(self.board_size - max_y):
move = Move(
piece_id=piece_id,
orientation_id=orient_id,
x=px,
y=py,
)
action_idx = len(self._action_moves)
self._action_moves.append(move)
self._move_to_action[(piece_id, orient_id, px, py)] = action_idx
@property
def num_actions(self) -> int:
return len(self._action_moves)
def get_move(self, action: int) -> Move:
"""Get the Move object for a given action index."""
return self._action_moves[action]
def get_action(self, move: Move) -> int:
"""Get the action index for a given Move."""
return self._move_to_action[(move.piece_id, move.orientation_id, move.x, move.y)]
# ------------------------------------------------------------------
# Game initialization
# ------------------------------------------------------------------
def reset(self) -> None:
"""Reset the game to its initial state."""
self.board = Board(self.board_size)
self.players = []
self.current_player = 0
self.rounds = 0
self.game_over = False
for i in range(self.num_players):
player = PlayerState(idx=i)
player.available_pieces = set(self.piece_set.piece_names)
if self.corner_rule and i < len(self._starting_corners):
player.corners = {self._starting_corners[i]}
else:
player.corners = set()
self.players.append(player)
# ------------------------------------------------------------------
# Move validation
# ------------------------------------------------------------------
def _get_placed_squares(self, move: Move) -> list[tuple[int, int]]:
"""Get the absolute board coordinates for a move."""
orient = self.piece_set.get_orientations(move.piece_id)[move.orientation_id]
return [(move.x + dx, move.y + dy) for dx, dy in orient.squares]
def _get_placed_corners(self, move: Move) -> list[tuple[int, int]]:
"""Get the absolute corner coordinates for a move."""
orient = self.piece_set.get_orientations(move.piece_id)[move.orientation_id]
return [(move.x + dx, move.y + dy) for dx, dy in orient.corners]
def valid_move(self, player_idx: int, move: Move) -> bool:
"""Check if a move is valid for the given player.
Validation rules:
1. Player must have the piece available
2. All squares must be in bounds
3. No overlap with existing pieces
4. Corner rule: piece must touch same-color piece at a corner
5. No edge adjacency with same-color pieces
6. First move must be in a corner (if corner_rule is enabled)
"""
player = self.players[player_idx]
piece = self.piece_set.get_piece(move.piece_id)
# Rule 1: Check if player has the piece
if piece.name not in player.available_pieces:
return False
placed_squares = self._get_placed_squares(move)
# Rule 2: Check bounds
for x, y in placed_squares:
if not self.board.in_bounds(x, y):
return False
# Rule 3: Check overlap
if self.board.has_overlap(placed_squares):
return False
player_board_idx = player_idx + self.PLAYER_OFFSET
# Rule 6: First move must be in a corner
if self.corner_rule and not player.has_started:
if len(self.players) <= len(self._starting_corners):
start_corner = self._starting_corners[player_idx]
if (move.x, move.y) != start_corner:
return False
# Rule 5: No edge adjacency with same-color pieces
for x, y in placed_squares:
for dx, dy in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
nx, ny = x + dx, y + dy
if self.board.in_bounds(nx, ny):
if self.board.get_cell(nx, ny) == player_board_idx:
return False
# Rule 4: Corner rule (must touch same-color at a corner)
if player.has_started:
# Must touch at least one same-color piece at a corner
placed_corners = self._get_placed_corners(move)
touches_corner = False
for cx, cy in placed_corners:
if self.board.in_bounds(cx, cy):
if self.board.get_cell(cx, cy) == player_board_idx:
touches_corner = True
break
if not touches_corner:
return False
# If not started yet, first move doesn't need to touch (it's the first piece)
return True
def get_valid_actions(self, player_idx: int) -> np.ndarray:
"""Get a boolean mask of valid actions for the given player."""
mask = np.zeros(self.num_actions, dtype=bool)
player = self.players[player_idx]
if not player.can_move:
return mask
for action_idx in range(self.num_actions):
move = self._action_moves[action_idx]
piece = self.piece_set.get_piece(move.piece_id)
if piece.name not in player.available_pieces:
continue
if self.valid_move(player_idx, move):
mask[action_idx] = True
return mask
def get_valid_action_indices(self, player_idx: int) -> list[int]:
"""Get a list of valid action indices for the given player."""
mask = self.get_valid_actions(player_idx)
return [int(i) for i in np.where(mask)[0]]
def has_valid_moves(self, player_idx: int) -> bool:
"""Check if a player has any valid moves."""
player = self.players[player_idx]
if not player.can_move:
return False
for action_idx in range(self.num_actions):
move = self._action_moves[action_idx]
piece = self.piece_set.get_piece(move.piece_id)
if piece.name in player.available_pieces:
if self.valid_move(player_idx, move):
return True
return False
# ------------------------------------------------------------------
# Move execution
# ------------------------------------------------------------------
def apply_move(self, player_idx: int, move: Move) -> None:
"""Apply a move to the game state. Does not validate."""
player = self.players[player_idx]
piece = self.piece_set.get_piece(move.piece_id)
placed_squares = self._get_placed_squares(move)
placed_corners = self._get_placed_corners(move)
# Place on board
player_board_idx = player_idx + self.PLAYER_OFFSET
self.board.place(player_board_idx, placed_squares)
# Update player state
player.score += piece.size
player.available_pieces.discard(piece.name)
player.has_started = True
# Update corners: remove covered squares, add new corners
for cx, cy in placed_corners:
if self.board.in_bounds(cx, cy) and self.board.is_empty(cx, cy):
player.corners.add((cx, cy))
# Remove corners that are now occupied
player.corners = {
(x, y) for x, y in player.corners if self.board.is_empty(x, y)
}
def play_move(self, player_idx: int, action: int) -> bool:
"""Validate and apply a move. Returns True if successful."""
move = self.get_move(action)
if not self.valid_move(player_idx, move):
return False
self.apply_move(player_idx, move)
return True
# ------------------------------------------------------------------
# Turn management
# ------------------------------------------------------------------
def next_player(self) -> int:
"""Advance to the next player who can still move.
If no players can move, the game is over.
"""
if self.game_over:
return self.current_player
for offset in range(1, self.num_players + 1):
next_idx = (self.current_player + offset) % self.num_players
next_player = self.players[next_idx]
if next_player.can_move and self.has_valid_moves(next_idx):
self.current_player = next_idx
return next_idx
# No one can move — game over
self.game_over = True
return self.current_player
def advance_turn(self) -> None:
"""Apply a move for the current player, then advance.
This is a convenience method for bot-driven games.
"""
self.next_player()
# ------------------------------------------------------------------
# Game state queries
# ------------------------------------------------------------------
def is_game_over(self) -> bool:
"""Check if the game is over (no player can make a move)."""
if self.game_over:
return True
# Check if any player can still move
for i in range(self.num_players):
if self.players[i].can_move and self.has_valid_moves(i):
return False
self.game_over = True
return True
def get_scores(self) -> list[int]:
"""Get the score for each player.
Score = squares placed (positive) - unplaced squares (negative).
A player who placed all pieces gets a +15 bonus.
If the last piece was the monomino, an additional +5 bonus.
"""
scores = []
for player in self.players:
# Sum sizes of remaining pieces
unplaced = sum(
self.piece_set.pieces[pid].size
for pid, piece in enumerate(self.piece_set.pieces)
if piece.name in player.available_pieces
)
score = player.score - unplaced
# Bonus for placing all pieces
if len(player.available_pieces) == 0:
score += 15
scores.append(score)
return scores
def get_winners(self) -> list[int] | None:
"""Get the winning player(s). Returns None if game is not over."""
if not self.is_game_over():
return None
scores = self.get_scores()
max_score = max(scores)
winners = [i for i, s in enumerate(scores) if s == max_score]
return winners
def get_current_observation(self) -> dict:
"""Get the current game state as an observation dict."""
player_idx = self.current_player
player = self.players[player_idx]
# Board as player indices (1-based)
board_obs = self.board.grid.copy()
# Available pieces as binary vector
pieces_obs = np.zeros(self.piece_set.num_pieces, dtype=bool)
for name in player.available_pieces:
pieces_obs[self.piece_set.get_piece_id(name)] = True
# Corners as boolean grid
corners_obs = np.zeros((self.board_size, self.board_size), dtype=bool)
for x, y in player.corners:
if self.board.in_bounds(x, y):
corners_obs[y, x] = True
return {
"board": board_obs.astype(np.int8),
"pieces": pieces_obs,
"corners": corners_obs,
}
def get_action_mask(self, player_idx: int) -> np.ndarray:
"""Get the action mask for a player."""
return self.get_valid_actions(player_idx)
def copy(self) -> BlokusGame:
"""Create a deep copy of the game state."""
import copy
new_game = BlokusGame.__new__(BlokusGame)
new_game.board_size = self.board_size
new_game.piece_set = self.piece_set
new_game.num_players = self.num_players
new_game.corner_rule = self.corner_rule
new_game.board = self.board.copy()
new_game.players = copy.deepcopy(self.players)
new_game.current_player = self.current_player
new_game.rounds = self.rounds
new_game.game_over = self.game_over
new_game._action_moves = self._action_moves
new_game._move_to_action = self._move_to_action
new_game._starting_corners = self._starting_corners
return new_game
+265
View File
@@ -0,0 +1,265 @@
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True)
class Piece:
"""A polyomino piece defined by a set of (x, y) coordinates.
Coordinates are relative to a reference point (typically the
top-left corner of the bounding box after normalization).
"""
name: str
squares: frozenset[tuple[int, int]]
@property
def size(self) -> int:
return len(self.squares)
def to_dict(self) -> dict:
return {
"name": self.name,
"squares": sorted(self.squares),
}
@classmethod
def from_dict(cls, data: dict) -> Piece:
return cls(
name=data["name"],
squares=frozenset(tuple(s) for s in data["squares"]),
)
@dataclass
class OrientedPiece:
"""A piece in a specific orientation placed at a specific position."""
piece_id: int
orientation_id: int
squares: list[tuple[int, int]]
corners: list[tuple[int, int]]
@dataclass
class Move:
"""A complete move: which piece, which orientation, where placed."""
piece_id: int
orientation_id: int
x: int
y: int
@dataclass
class PieceOrientation:
"""A piece in a specific orientation (before placement on board)."""
piece_id: int
orientation_id: int
squares: list[tuple[int, int]] # normalized relative coordinates
corners: list[tuple[int, int]] # corner cells relative to squares
def _normalize(squares: list[tuple[int, int]]) -> list[tuple[int, int]]:
"""Shift coordinates so the minimum x and y are 0."""
min_x = min(x for x, _ in squares)
min_y = min(y for _, y in squares)
return [(x - min_x, y - min_y) for x, y in squares]
def _rotate(squares: list[tuple[int, int]]) -> list[tuple[int, int]]:
"""Rotate 90 degrees clockwise: (x, y) -> (y, -x)."""
return [(y, -x) for x, y in squares]
def _flip(squares: list[tuple[int, int]]) -> list[tuple[int, int]]:
"""Flip horizontally: (x, y) -> (-x, y)."""
return [(-x, y) for x, y in squares]
def _compute_corners(squares: list[tuple[int, int]]) -> list[tuple[int, int]]:
"""Compute the corner cells for a piece.
Corner cells are the diagonal neighbors of each square that are not
edge-adjacent to any other square in the piece. These are the cells
where a same-color piece must touch (corner-to-corner).
"""
square_set = set(squares)
corners = set()
for x, y in squares:
for dx, dy in [(-1, -1), (1, -1), (-1, 1), (1, 1)]:
cx, cy = x + dx, y + dy
if (cx, cy) not in square_set:
# Check that this corner is not edge-adjacent to another square
edge_neighbors = [
(cx + 1, cy), (cx - 1, cy), (cx, cy + 1), (cx, cy - 1)
]
if not any(en in square_set for en in edge_neighbors):
corners.add((cx, cy))
return sorted(corners)
def generate_orientations(piece: Piece) -> list[PieceOrientation]:
"""Generate all unique orientations of a piece.
Applies 4 rotations and 2 flips, then deduplicates by comparing
normalized coordinate sets.
"""
orientations: list[PieceOrientation] = []
seen: set[tuple[tuple[int, int], ...]] = set()
squares = sorted(piece.squares)
for flip_count in range(2):
for rotation_count in range(4):
current = list(squares)
for _ in range(rotation_count):
current = _rotate(current)
for _ in range(flip_count):
current = _flip(current)
normalized = tuple(sorted(_normalize(current)))
if normalized not in seen:
seen.add(normalized)
corners = _compute_corners(list(normalized))
orientations.append(
PieceOrientation(
piece_id=-1, # Set by PieceSet
orientation_id=len(orientations),
squares=list(normalized),
corners=corners,
)
)
return orientations
# ---------------------------------------------------------------------------
# Standard Blokus piece sets
# ---------------------------------------------------------------------------
# The 21 free polyominoes of size 1-5
# Naming follows standard pentomino/tetromino conventions
_I1 = Piece("I1", frozenset([(0, 0)]))
_I2 = Piece("I2", frozenset([(0, 0), (0, 1)]))
_I3 = Piece("I3", frozenset([(0, 0), (0, 1), (0, 2)]))
_V3 = Piece("V3", frozenset([(0, 0), (1, 0), (0, 1)]))
_I4 = Piece("I4", frozenset([(0, 0), (0, 1), (0, 2), (0, 3)]))
_L4 = Piece("L4", frozenset([(0, 0), (0, 1), (0, 2), (1, 0)]))
_T4 = Piece("T4", frozenset([(0, 0), (1, 0), (2, 0), (1, 1)]))
_S4 = Piece("S4", frozenset([(0, 0), (1, 0), (1, 1), (2, 1)]))
_O4 = Piece("O4", frozenset([(0, 0), (1, 0), (0, 1), (1, 1)]))
_I5 = Piece("I5", frozenset([(0, 0), (0, 1), (0, 2), (0, 3), (0, 4)]))
_L5 = Piece("L5", frozenset([(0, 0), (0, 1), (0, 2), (0, 3), (1, 0)]))
_Y5 = Piece("Y5", frozenset([(0, 0), (0, 1), (0, 2), (0, 3), (1, 1)]))
_N5 = Piece("N5", frozenset([(0, 0), (1, 0), (2, 0), (2, 1), (3, 1)]))
_T5 = Piece("T5", frozenset([(0, 0), (1, 0), (2, 0), (1, 1), (1, 2)]))
_U5 = Piece("U5", frozenset([(0, 0), (2, 0), (0, 1), (1, 1), (2, 1)]))
_V5 = Piece("V5", frozenset([(0, 0), (0, 1), (0, 2), (1, 0), (2, 0)]))
_W5 = Piece("W5", frozenset([(0, 0), (0, 1), (1, 0), (1, 1), (2, 0)]))
_Z5 = Piece("Z5", frozenset([(0, 0), (1, 0), (1, 1), (1, 2), (2, 2)]))
_F5 = Piece("F5", frozenset([(0, 0), (1, 0), (1, 1), (2, 1), (1, 2)]))
_X5 = Piece("X5", frozenset([(0, 0), (1, 0), (2, 0), (1, 1), (1, -1)]))
_P5 = Piece("P5", frozenset([(0, 0), (1, 0), (0, 1), (1, 1), (0, 2)]))
STANDARD_PIECES: list[Piece] = [
_I1, _I2, _I3, _V3, _I4, _L4, _T4, _S4, _O4,
_I5, _L5, _Y5, _N5, _T5, _U5, _V5, _W5, _Z5, _F5, _X5, _P5,
]
# Blokus Duo uses the same pieces but on a 14x14 board
DUO_PIECES: list[Piece] = STANDARD_PIECES
# Blokus Junior: simplified pieces (only 12 unique pieces, 2 copies each)
# Uses only pieces with size <= 4 for simplicity
JUNIOR_PIECES: list[Piece] = [
_I1, _I2, _I3, _V3, _I4, _L4, _T4, _S4, _O4,
]
class PieceSet:
"""A collection of pieces with pre-computed orientations.
This class manages the piece set used in a Blokus game, including
all unique orientations for each piece and a lookup table for
generating moves.
"""
def __init__(self, pieces: list[Piece]):
self.pieces: list[Piece] = pieces
self.piece_names: list[str] = [p.name for p in pieces]
self.piece_id_map: dict[str, int] = {name: i for i, name in enumerate(self.piece_names)}
# Pre-compute all orientations for each piece
self.orientations: list[list[PieceOrientation]] = []
for piece_id, piece in enumerate(pieces):
orients = generate_orientations(piece)
for orient in orients:
orient.piece_id = piece_id
self.orientations.append(orients)
# Total number of (piece, orientation) combinations
self.num_orientations: int = sum(len(o) for o in self.orientations)
@property
def num_pieces(self) -> int:
return len(self.pieces)
def get_orientations(self, piece_id: int) -> list[PieceOrientation]:
return self.orientations[piece_id]
def get_piece(self, piece_id: int) -> Piece:
return self.pieces[piece_id]
def get_piece_id(self, name: str) -> int:
return self.piece_id_map[name]
def to_dict(self) -> dict:
return {
"pieces": [p.to_dict() for p in self.pieces],
}
@classmethod
def from_dict(cls, data: dict) -> PieceSet:
pieces = [Piece.from_dict(p) for p in data["pieces"]]
return cls(pieces)
@classmethod
def from_json(cls, path: str) -> PieceSet:
import json
with open(path) as f:
data = json.load(f)
return cls.from_dict(data)
def save_json(self, path: str) -> None:
import json
with open(path, "w") as f:
json.dump(self.to_dict(), f, indent=2)
def __len__(self) -> int:
return len(self.pieces)
def __repr__(self) -> str:
return f"PieceSet(num_pieces={self.num_pieces}, num_orientations={self.num_orientations})"
+97
View File
@@ -0,0 +1,97 @@
from blokus_gym.core.bots import GreedyBot, GreedyCornersBot, MinimaxBot, RandomBot
from blokus_gym.core.pieces import DUO_PIECES, JUNIOR_PIECES, STANDARD_PIECES
from blokus_gym.envs.blokus_env import BlokusEnv
from blokus_gym.envs.multiagent import BlokusMultiAgentEnv
__all__ = [
"BlokusEnv",
"BlokusMultiAgentEnv",
"STANDARD_PIECES",
"DUO_PIECES",
"JUNIOR_PIECES",
"RandomBot",
"GreedyBot",
"GreedyCornersBot",
"MinimaxBot",
]
def _register_envs():
"""Register all predefined environment configurations."""
import gymnasium as gym
# Standard 4-player Blokus (20x20)
gym.register(
id="Blokus-v0",
entry_point="blokus_gym.envs:BlokusEnv",
kwargs={
"num_players": 4,
"board_size": 20,
"pieces": STANDARD_PIECES,
},
max_episode_steps=500,
)
# Blokus Duo (2 players, 14x14)
gym.register(
id="BlokusDuo-v0",
entry_point="blokus_gym.envs:BlokusEnv",
kwargs={
"num_players": 2,
"board_size": 14,
"pieces": DUO_PIECES,
},
max_episode_steps=300,
)
# Blokus Junior (2 players, 14x14, simplified pieces)
gym.register(
id="BlokusJunior-v0",
entry_point="blokus_gym.envs:BlokusEnv",
kwargs={
"num_players": 2,
"board_size": 14,
"pieces": JUNIOR_PIECES,
},
max_episode_steps=200,
)
# Simple test env (2 players, 7x7, only small pieces)
gym.register(
id="BlokusSimple-v0",
entry_point="blokus_gym.envs:BlokusEnv",
kwargs={
"num_players": 2,
"board_size": 7,
"pieces": [p for p in STANDARD_PIECES if p.size < 5],
},
max_episode_steps=100,
)
# Greedy bot variants
gym.register(
id="BlokusGreedy-v0",
entry_point="blokus_gym.envs:BlokusEnv",
kwargs={
"num_players": 4,
"board_size": 20,
"pieces": STANDARD_PIECES,
"bot_type": GreedyBot,
},
max_episode_steps=500,
)
gym.register(
id="BlokusDuoGreedy-v0",
entry_point="blokus_gym.envs:BlokusEnv",
kwargs={
"num_players": 2,
"board_size": 14,
"pieces": DUO_PIECES,
"bot_type": GreedyBot,
},
max_episode_steps=300,
)
_register_envs()
+366
View File
@@ -0,0 +1,366 @@
from __future__ import annotations
from typing import Any
import gymnasium as gym
import numpy as np
from gymnasium import spaces
from blokus_gym.core.bots import Bot, RandomBot
from blokus_gym.core.game import BlokusGame
from blokus_gym.core.pieces import STANDARD_PIECES, Piece, PieceSet
from blokus_gym.utils.render import render_ansi_board, render_text_board
class BlokusEnv(gym.Env):
"""Gymnasium environment for the board game Blokus.
Supports 2-4 players on customizable board sizes with custom piece sets.
In single-agent mode, the agent controls player 0 and opponents are bots.
Action Space: Discrete(N) where N is the total number of possible
(piece, orientation, position) combinations. An action mask is provided
in the info dict to indicate valid actions.
Observation Space: Dict with:
- "board": (board_size, board_size) int8 array (0=empty, 1-4=player)
- "pieces": MultiBinary(num_pieces) - 1 if piece still available
- "corners": (board_size, board_size) bool array - valid corner cells
Example:
>>> import gymnasium as gym
>>> from blokus_gym import BlokusEnv
>>> env = BlokusEnv(num_players=4, board_size=20)
>>> obs, info = env.reset(seed=42)
>>> action = env.action_space.sample() # Use info["action_mask"] instead
>>> obs, reward, terminated, truncated, info = env.step(action)
"""
metadata = {"render_modes": ["human", "ansi", "rgb_array"]}
# Player colors for rendering (index 0 = empty)
PLAYER_COLORS = ["empty", "red", "blue", "yellow", "green"]
def __init__(
self,
num_players: int = 4,
board_size: int = 20,
pieces: list[Piece] | None = None,
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,
**kwargs: Any,
):
super().__init__()
# Validate parameters
assert 2 <= num_players <= 4, f"num_players must be 2-4, got {num_players}"
assert board_size >= 5, f"board_size must be >= 5, got {board_size}"
self.num_players = num_players
self.board_size = board_size
self.render_mode = render_mode
self.bot_type = bot_type
self.bot_strength = bot_strength
self.reward_shaping = reward_shaping
self.corner_rule = corner_rule
self.max_steps = max_steps
self._seed = seed
# Initialize piece set
self.piece_set = PieceSet(pieces or STANDARD_PIECES)
# Initialize game
self.game = BlokusGame(
board_size=board_size,
pieces=self.piece_set,
num_players=num_players,
corner_rule=corner_rule,
)
# Define spaces
self.observation_space = spaces.Dict({
"board": spaces.Box(0, num_players, (board_size, board_size), dtype=np.int8),
"pieces": spaces.MultiBinary(self.piece_set.num_pieces),
"corners": spaces.Box(0, 1, (board_size, board_size), dtype=bool),
})
self.action_space = spaces.Discrete(self.game.num_actions)
# Bot instances (created during reset)
self.bots: list[Bot | None] = [None] * num_players
# Episode tracking
self.current_step = 0
self._last_observation: dict | None = None
def _get_obs(self) -> dict:
"""Get the current observation from the agent's perspective (player 0)."""
obs = self.game.get_current_observation()
return {
"board": obs["board"],
"pieces": obs["pieces"],
"corners": obs["corners"],
}
def _get_info(self) -> dict:
"""Get auxiliary information."""
return {
"action_mask": self.game.get_action_mask(0),
"current_player": self.game.current_player,
"players_with_moves": [
i for i in range(self.num_players)
if self.game.players[i].can_move and self.game.has_valid_moves(i)
],
"step_count": self.current_step,
"scores": self.game.get_scores(),
}
def reset(
self,
seed: int | None = None,
options: dict | None = None,
) -> tuple[dict, dict]:
"""Reset the environment to a new episode.
Args:
seed: Random seed for reproducibility.
options: Additional options (unused).
Returns:
Tuple of (observation, info).
"""
super().reset(seed=seed)
# Re-initialize game
self.game = BlokusGame(
board_size=self.board_size,
pieces=self.piece_set,
num_players=self.num_players,
corner_rule=self.corner_rule,
)
self.game.reset()
# Create bots for opponents
bot_seed = seed or 0
for i in range(1, self.num_players):
self.bots[i] = self.bot_type(
player_idx=i,
seed=bot_seed + i * 1000,
)
self.current_step = 0
# Let bots play until it's the agent's turn
# The agent (player 0) starts first if corner_rule is enabled
self._play_bots_until_agent()
obs = self._get_obs()
info = self._get_info()
self._last_observation = obs
return obs, info
def _play_bots_until_agent(self) -> None:
"""Let bot players take their turns until it's the agent's turn."""
max_bot_turns = self.num_players * 10 # Safety limit
turns = 0
while self.game.current_player != 0 and not self.game.is_game_over():
if turns > max_bot_turns:
break
bot = self.bots[self.game.current_player]
if bot is None:
# Shouldn't happen, but safety
self.game.next_player()
turns += 1
continue
valid_actions = self.game.get_valid_actions(self.game.current_player)
if not np.any(valid_actions):
self.game.players[self.game.current_player].can_move = False
self.game.next_player()
turns += 1
continue
action = bot.select_action(self.game, valid_actions)
if action is not None:
self.game.play_move(self.game.current_player, action)
else:
self.game.players[self.game.current_player].can_move = False
self.game.next_player()
turns += 1
def step(self, action: int) -> tuple[dict, float, bool, bool, dict]:
"""Execute one step in the environment.
The agent (player 0) takes an action, then all bots play until
it's the agent's turn again.
Args:
action: Action index (piece, orientation, position).
Returns:
Tuple of (observation, reward, terminated, truncated, info).
"""
self.current_step += 1
# Apply agent's action
success = self.game.play_move(0, action)
if not success:
# Invalid action - penalize and end episode
reward = -10.0
obs = self._get_obs()
info = self._get_info()
info["invalid_action"] = True
self._last_observation = obs
return obs, reward, True, False, info
# Advance to next player
self.game.next_player()
# Let bots play until it's the agent's turn or game is over
self._play_bots_until_agent()
# Check if game is over
terminated = self.game.is_game_over()
truncated = False
if self.max_steps is not None and self.current_step >= self.max_steps:
truncated = True
# Compute reward
if terminated or truncated:
reward = self._compute_terminal_reward()
else:
reward = self._compute_step_reward()
obs = self._get_obs()
info = self._get_info()
self._last_observation = obs
return obs, reward, terminated, truncated, info
def _compute_step_reward(self) -> float:
"""Compute reward for a non-terminal step."""
if self.reward_shaping:
# Small reward for each square placed
return 0.01 * self.game.players[0].score
return 0.0
def _compute_terminal_reward(self) -> float:
"""Compute reward when the game ends."""
scores = self.game.get_scores()
agent_score = scores[0]
if self.reward_shaping:
# Normalized score: agent score / max possible score
max_possible = sum(p.size for p in self.piece_set.pieces)
return agent_score / max_possible
# Win/loss reward
max_score = max(scores)
if agent_score == max_score:
# Check if it's a tie
winners = [i for i, s in enumerate(scores) if s == max_score]
if len(winners) == 1:
return 1.0 # Win
else:
return 0.0 # Tie
else:
return -1.0 # Loss
def render(self) -> str | np.ndarray | None:
"""Render the environment.
Args:
No arguments - uses self.render_mode.
Returns:
For "human": None (prints to stdout)
For "ansi": string representation
For "rgb_array": numpy array of shape (H, W, 3)
"""
if self.render_mode is None:
return None
if self.render_mode == "human":
render_text_board(self.game)
return None
elif self.render_mode == "ansi":
return render_ansi_board(self.game)
elif self.render_mode == "rgb_array":
return self._render_rgb_array()
else:
raise ValueError(f"Unknown render_mode: {self.render_mode}")
def _render_rgb_array(self) -> np.ndarray:
"""Render the board as an RGB array using matplotlib."""
try:
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
except ImportError as exc:
raise ImportError(
"matplotlib is required for rgb_array rendering. "
"Install with: pip install matplotlib"
) from exc
fig, ax = plt.subplots(figsize=(8, 8))
# Color map: 0=empty(light grey), 1=red, 2=blue, 3=yellow, 4=green
colors = {
0: "#d3d3d3", # light grey
1: "#ff6b6b", # red
2: "#4dabf7", # blue
3: "#ffd43b", # yellow
4: "#51cf66", # green
}
grid = self.game.board.grid.astype(float)
colored = np.zeros((self.board_size, self.board_size, 3))
for i in range(self.board_size):
for j in range(self.board_size):
val = int(grid[i, j])
hex_color = colors.get(val, "#d3d3d3")
# Parse hex color
r = int(hex_color[1:3], 16) / 255
g = int(hex_color[3:5], 16) / 255
b = int(hex_color[5:7], 16) / 255
colored[i, j] = [r, g, b]
ax.imshow(colored, interpolation="nearest")
ax.set_xticks(np.arange(-0.5, self.board_size, 1), minor=True)
ax.set_yticks(np.arange(-0.5, self.board_size, 1), minor=True)
ax.grid(True, which="minor", color="black", linewidth=0.5)
ax.set_xticks([])
ax.set_yticks([])
# Title with current player and scores
scores = self.game.get_scores()
title = f"Current player: {self.game.current_player + 1}\n"
title += "Scores: " + " | ".join(f"P{i+1}: {s}" for i, s in enumerate(scores))
ax.set_title(title, fontsize=10)
plt.tight_layout()
fig.canvas.draw()
image = np.frombuffer(fig.canvas.tostring_rgb(), dtype=np.uint8)
image = image.reshape(fig.canvas.get_width_height()[::-1] + (3,))
plt.close(fig)
return image
def close(self) -> None:
"""Clean up resources."""
pass
def get_action_mask(self) -> np.ndarray:
"""Get the action mask for the current player (player 0)."""
return self.game.get_action_mask(0)
+254
View File
@@ -0,0 +1,254 @@
from __future__ import annotations
import numpy as np
from gymnasium import spaces
from blokus_gym.core.game import BlokusGame
from blokus_gym.core.pieces import STANDARD_PIECES, PieceSet
class BlokusMultiAgentEnv:
"""PettingZoo-style AEC (Actor-Environment-Cycle) environment for Blokus.
This wraps the BlokusGame core to provide a multi-agent interface where
each player acts independently. Compatible with PettingZoo's API and
RLlib's multi-agent RL.
Unlike the single-agent BlokusEnv, all players are controlled by the
caller (no built-in bots). This allows for self-play training and
evaluation of multi-agent policies.
Attributes:
num_players: Number of players (2-4).
board_size: Size of the square board.
agents: List of agent names (e.g., ["player_0", "player_1", ...]).
possible_agents: Same as agents (never changes).
agent_order: Order in which agents take turns.
Example:
>>> from blokus_gym import BlokusMultiAgentEnv
>>> env = BlokusMultiAgentEnv(num_players=4)
>>> obs, info = env.reset()
>>> for agent in env.agent_iter():
... obs, reward, terminated, truncated, info = env.last()
... action = env.action_space(agent).sample() # Use action mask
... env.step(action)
"""
metadata = {"render_modes": ["human", "ansi"]}
def __init__(
self,
num_players: int = 4,
board_size: int = 20,
pieces: list | None = None,
render_mode: str | None = None,
corner_rule: bool = True,
seed: int | None = None,
):
assert 2 <= num_players <= 4, f"num_players must be 2-4, got {num_players}"
assert board_size >= 5, f"board_size must be >= 5, got {board_size}"
self.num_players = num_players
self.board_size = board_size
self.render_mode = render_mode
self.corner_rule = corner_rule
self._seed = seed
self.piece_set = PieceSet(pieces or STANDARD_PIECES)
self.game: BlokusGame | None = None
self.agents = [f"player_{i}" for i in range(num_players)]
self.possible_agents = list(self.agents)
self.agent_order = list(self.agents)
# Observation and action spaces (same for all agents)
self.observation_space = spaces.Dict({
"board": spaces.Box(0, num_players, (board_size, board_size), dtype=np.int8),
"pieces": spaces.MultiBinary(self.piece_set.num_pieces),
"corners": spaces.Box(0, 1, (board_size, board_size), dtype=bool),
})
self._cur_step = 0
self._current_agent_idx = 0
self._agent_dones: dict[str, bool] = {}
def reset(self, seed: int | None = None, options: dict | None = None) -> tuple[dict, dict]:
"""Reset the environment.
Returns:
obs: Dict mapping agent name to observation.
info: Dict with auxiliary information.
"""
self.game = BlokusGame(
board_size=self.board_size,
pieces=self.piece_set,
num_players=self.num_players,
corner_rule=self.corner_rule,
)
self.game.reset()
self._cur_step = 0
self._current_agent_idx = 0
self._agent_dones = dict.fromkeys(self.agents, False)
# Get observation for the first agent
first_agent = self.agent_order[0]
obs = self._get_obs(first_agent)
info = self._get_info(first_agent)
return {first_agent: obs}, info
def step(self, action: int) -> tuple[dict, dict, dict, dict, dict]:
"""Execute one step. Must be called for the current agent in agent_iter().
Returns:
observations: Dict mapping agent -> observation
rewards: Dict mapping agent -> reward
terminations: Dict mapping agent -> bool
truncations: Dict mapping agent -> bool
infos: Dict mapping agent -> info
"""
self._cur_step += 1
agent = self.agent_order[self._current_agent_idx]
player_idx = int(agent.split("_")[1])
# Apply the action
success = self.game.play_move(player_idx, action)
# Compute rewards for this player
reward = 0.0
if not success:
reward = -10.0 # Invalid action penalty
self._agent_dones[agent] = True
# Check game over
game_over = self.game.is_game_over()
# Advance to next player
if not game_over:
self.game.next_player()
# Skip players who can't move
while not self.game.is_game_over():
player = self.game.current_player
agent_name = self.agents[player]
if self._agent_dones.get(agent_name, False):
self.game.next_player()
continue
if not self.game.has_valid_moves(player):
self.game.players[player].can_move = False
self.game.next_player()
continue
break
# Determine next agent
next_player = self.game.current_player
next_agent = self.agents[next_player]
# Check if game is over
if game_over:
# Final rewards based on scores
scores = self.game.get_scores()
max_score = max(scores)
terminations = {}
rewards = {}
for i, agent_name in enumerate(self.agents):
if scores[i] == max_score:
winners = [j for j, s in enumerate(scores) if s == max_score]
if len(winners) == 1:
rewards[agent_name] = 1.0
else:
rewards[agent_name] = 0.0
else:
rewards[agent_name] = -1.0
terminations[agent_name] = True
self._agent_dones[agent_name] = True
infos = {agent_name: {"step_count": self._cur_step} for agent_name in self.agents}
observations = {agent_name: self._get_obs(agent_name) for agent_name in self.agents}
return observations, rewards, terminations, dict.fromkeys(self.agents, False), infos
# Normal step
rewards = dict.fromkeys(self.agents, 0.0)
rewards[agent] = reward
terminations = {
agent_name: self._agent_dones.get(agent_name, False)
for agent_name in self.agents
}
truncations = dict.fromkeys(self.agents, False)
observations = {next_agent: self._get_obs(next_agent)}
infos = {next_agent: self._get_info(next_agent)}
# Advance current agent index
self._current_agent_idx = self.agents.index(next_agent)
return observations, rewards, terminations, truncations, infos
def observe(self, agent: str) -> dict:
"""Get observation for a specific agent."""
return self._get_obs(agent)
def get_action_mask(self, agent: str) -> np.ndarray:
"""Get valid action mask for a specific agent."""
player_idx = int(agent.split("_")[1])
return self.game.get_action_mask(player_idx)
def action_space(self, agent: str) -> spaces.Discrete:
"""Get action space for a specific agent."""
return spaces.Discrete(self.game.num_actions)
def observation_space(self, agent: str) -> spaces.Dict:
"""Get observation space for a specific agent."""
return self.observation_space
def render(self) -> str | None:
"""Render the current state."""
if self.render_mode is None:
return None
from blokus_gym.utils.render import render_ansi_board, render_text_board
if self.render_mode == "human":
print(render_text_board(self.game))
return None
elif self.render_mode == "ansi":
return render_ansi_board(self.game)
else:
raise ValueError(f"Unknown render_mode: {self.render_mode}")
def close(self) -> None:
"""Clean up resources."""
pass
def _get_obs(self, agent: str) -> dict:
"""Get observation from an agent's perspective."""
obs = self.game.get_current_observation()
return {
"board": obs["board"],
"pieces": obs["pieces"],
"corners": obs["corners"],
}
def _get_info(self, agent: str) -> dict:
"""Get auxiliary info for an agent."""
player_idx = int(agent.split("_")[1])
return {
"action_mask": self.game.get_action_mask(player_idx),
"current_player": self.game.current_player,
"step_count": self._cur_step,
"scores": self.game.get_scores(),
}
def agent_iter(self):
"""Iterate over agents in turn order."""
# This is a generator that yields the current agent
while True:
agent = self.agents[self.game.current_player]
if self._agent_dones.get(agent, False):
# Skip done agents
if all(self._agent_dones.values()):
break
continue
yield agent
+17
View File
@@ -0,0 +1,17 @@
from blokus_gym.utils.render import (
ANSI_COLORS,
ANSI_RESET,
PLAYER_NAMES,
print_board,
render_ansi_board,
render_text_board,
)
__all__ = [
"ANSI_COLORS",
"ANSI_RESET",
"PLAYER_NAMES",
"render_ansi_board",
"render_text_board",
"print_board",
]
+105
View File
@@ -0,0 +1,105 @@
from __future__ import annotations
from blokus_gym.core.game import BlokusGame
# ANSI color codes for terminal rendering
ANSI_COLORS = {
0: "\033[90m", # grey (empty)
1: "\033[91m", # red
2: "\033[94m", # blue
3: "\033[93m", # yellow
4: "\033[92m", # green
}
ANSI_RESET = "\033[0m"
# Player names
PLAYER_NAMES = {1: "Red", 2: "Blue", 3: "Yellow", 4: "Green"}
def render_text_board(game: BlokusGame) -> str:
"""Render the board as a text string (no ANSI colors).
Returns a string representation of the board.
"""
lines = []
size = game.board_size
# Column headers
header = " " + " ".join(str(i % 10) for i in range(size))
lines.append(header)
for y in range(size):
row = f"{y % 10} "
for x in range(size):
cell = game.board.get_cell(x, y)
if cell == 0:
row += ". "
else:
row += f"{cell} "
lines.append(row)
# Player info
lines.append("")
for i, player in enumerate(game.players):
remaining = len(player.available_pieces)
total = len(game.piece_set.pieces)
name = PLAYER_NAMES.get(i + 1, f"Player {i + 1}")
lines.append(f"{name}: {remaining}/{total} pieces remaining, score: {player.score}")
cur_name = PLAYER_NAMES.get(game.current_player + 1, game.current_player + 1)
lines.append(f"Current player: {cur_name}")
return "\n".join(lines)
def render_ansi_board(game: BlokusGame) -> str:
"""Render the board with ANSI color codes.
Returns a string with ANSI escape sequences for colored output.
"""
lines = []
size = game.board_size
# Column headers
header = " " + " ".join(str(i % 10) for i in range(size))
lines.append(header)
for y in range(size):
row = f"{y % 10} "
for x in range(size):
cell = game.board.get_cell(x, y)
color = ANSI_COLORS.get(cell, ANSI_RESET)
if cell == 0:
row += f"{color}. {ANSI_RESET}"
else:
row += f"{color}{cell} {ANSI_RESET}"
lines.append(row)
# Player info
lines.append("")
for i, player in enumerate(game.players):
remaining = len(player.available_pieces)
total = len(game.piece_set.pieces)
name = PLAYER_NAMES.get(i + 1, f"Player {i + 1}")
color = ANSI_COLORS.get(i + 1, ANSI_RESET)
lines.append(
f"{color}{name}{ANSI_RESET}: {remaining}/{total} pieces, "
f"score: {player.score}"
)
lines.append(
f"Current player: "
f"{ANSI_COLORS.get(game.current_player + 1, ANSI_RESET)}"
f"{PLAYER_NAMES.get(game.current_player + 1, game.current_player + 1)}"
f"{ANSI_RESET}"
)
return "\n".join(lines)
def print_board(game: BlokusGame, use_ansi: bool = True) -> None:
"""Print the board to stdout."""
if use_ansi:
print(render_ansi_board(game))
else:
print(render_text_board(game))
+3
View File
@@ -0,0 +1,3 @@
from blokus_gym.wrappers.action_mask import ActionMaskWrapper
__all__ = ["ActionMaskWrapper"]
+48
View File
@@ -0,0 +1,48 @@
from __future__ import annotations
import numpy as np
from gymnasium import spaces
from gymnasium.core import Env, Wrapper
class ActionMaskWrapper(Wrapper):
"""Wrapper that moves the action mask from info into the observation space.
This is useful for RL frameworks that expect the action mask to be part
of the observation (e.g., Stable-Baselines3 with custom policies,
RLlib, etc.).
After wrapping, the observation becomes a Dict with:
- "observation": the original observation
- "action_mask": boolean array of valid actions
Example:
>>> from blokus_gym import BlokusEnv
>>> from blokus_gym.wrappers import ActionMaskWrapper
>>> env = BlokusEnv()
>>> env = ActionMaskWrapper(env)
>>> obs, info = env.reset()
>>> obs["action_mask"] # Boolean mask of valid actions
"""
def __init__(self, env: Env):
super().__init__(env)
# Build new observation space
original_obs_space = env.observation_space
action_dim = env.action_space.n
self.observation_space = spaces.Dict({
"observation": original_obs_space,
"action_mask": spaces.Box(0, 1, (action_dim,), dtype=bool),
})
def reset(self, *, seed=None, options=None):
obs, info = self.env.reset(seed=seed, options=options)
action_mask = info.get("action_mask", np.zeros(self.env.action_space.n, dtype=bool))
return {"observation": obs, "action_mask": action_mask}, info
def step(self, action):
obs, reward, terminated, truncated, info = self.env.step(action)
action_mask = info.get("action_mask", np.zeros(self.env.action_space.n, dtype=bool))
return {"observation": obs, "action_mask": action_mask}, reward, terminated, truncated, info