diff --git a/examples/minesweeper/README.md b/examples/minesweeper/README.md new file mode 100644 index 000000000..327b3a360 --- /dev/null +++ b/examples/minesweeper/README.md @@ -0,0 +1,80 @@ +## Minesweeper + +This example demonstrates a **step-based, autonomous Minesweeper model** implemented using the Mesa framework. + +![minesweeper_screenshot.png](screenshot.png) + +--- + +### Overview + +Minesweeper is a classic grid-based puzzle game in which cells may contain hidden mines, and each revealed cell displays the number of neighboring mines. + +In this example, Minesweeper is implemented as a **cellular process** that reveals itself over time without user input. The model uses local rules and neighborhood interactions to propagate information across the grid. + +--- + +### Key features + +- **Step-based dynamics:** + The model works step by step, revealing nearby cells as it expands. + +- **One agent per cell:** + Each grid cell is represented by a `MineCell` agent that stores its revealed state and number of neighboring mines. + +- **Frontier-based propagation:** + A *frontier* tracks which cells are actively being processed at each step, allowing wave-like expansion similar to flood-fill algorithms. + +- **Automatic reseeding:** + When a revealed region finishes expanding, the model automatically selects a new unrevealed safe cell and continues the process. + +- **Quantitative analysis:** + A `DataCollector` tracks the number of revealed cells and the size of the active frontier over time. + +- **Matplotlib visualization:** + The grid and time-series plots are rendered using Mesa’s Matplotlib-based visualization components. + +--- + +### Model behavior + +At each step: + +1. Cells in the current frontier are revealed. +2. If a revealed cell has zero neighboring mines, its neighbors are added to the next frontier. +3. Numbered cells form boundaries and do not propagate further. +4. When a region finishes expanding, a new safe cell is selected to start another reveal wave. +5. The model terminates when no unrevealed safe cells remain or a mine is revealed. + +--- + +### Visualization + +The Solara visualization includes: + +- **Grid view:** + - Green squares: unrevealed cells + - Gray squares: revealed empty cells + - Numbers: neighboring mine counts + - Red X: mine (if revealed) + +- **DataCollector plot:** + - **Revealed:** total number of revealed cells over time + - **Frontier:** number of active cells being processed per step + +--- + +### Getting Started + +#### Prerequisites + +- Python 3.10 or higher +- Mesa 3.0 or higher +- NumPy + +#### Running the Model + +Navigate to the example directory and run: + +```bash +solara run app.py diff --git a/examples/minesweeper/app.py b/examples/minesweeper/app.py new file mode 100644 index 000000000..429f6b1f7 --- /dev/null +++ b/examples/minesweeper/app.py @@ -0,0 +1,81 @@ +from mesa.visualization import SolaraViz +from mesa.visualization.components.matplotlib_components import ( + make_mpl_plot_component, + make_mpl_space_component, +) +from mesa.visualization.components.portrayal_components import AgentPortrayalStyle +from minesweeper.agents import MineCell +from minesweeper.model import MinesweeperModel + + +def agent_portrayal(agent: MineCell): + if not agent.revealed: + return AgentPortrayalStyle( + marker="s", + color="green", + size=80, + ) + + if agent.cell.mine: + return AgentPortrayalStyle( + marker="X", + color="red", + size=80, + ) + + if agent.neighbor_mines > 0: + return AgentPortrayalStyle( + marker=f"${agent.neighbor_mines}$", + color="black", + size=80, + ) + + return AgentPortrayalStyle( + marker="s", + color="lightgray", + size=80, + ) + + +model_params = { + "seed": {"type": "InputText", "value": 42}, + "mine_density": { + "type": "SliderFloat", + "value": 0.15, + "min": 0.05, + "max": 0.4, + "step": 0.05, + }, + "width": {"type": "SliderInt", "value": 10, "min": 5, "max": 40}, + "height": {"type": "SliderInt", "value": 10, "min": 5, "max": 40}, +} + + +model = MinesweeperModel() + + +def post_process(ax): + ax.set_aspect("equal") + ax.set_xticks([]) + ax.set_yticks([]) + + +space = make_mpl_space_component( + agent_portrayal=agent_portrayal, + post_process=post_process, + draw_grid=True, +) + +plot = make_mpl_plot_component( + { + "Revealed": "tab:blue", + "Frontier": "tab:orange", + } +) + +page = SolaraViz( + model, + components=[space, plot], + model_params=model_params, + name="Minesweeper (Step-based)", +) diff --git a/examples/minesweeper/minesweeper/__init__.py b/examples/minesweeper/minesweeper/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/examples/minesweeper/minesweeper/agents.py b/examples/minesweeper/minesweeper/agents.py new file mode 100644 index 000000000..713c8f014 --- /dev/null +++ b/examples/minesweeper/minesweeper/agents.py @@ -0,0 +1,12 @@ +from mesa.discrete_space import CellAgent + + +class MineCell(CellAgent): + def __init__(self, model, cell): + super().__init__(model) + self.cell = cell + self.revealed = False + self.neighbor_mines = 0 + + def step(self): + pass diff --git a/examples/minesweeper/minesweeper/model.py b/examples/minesweeper/minesweeper/model.py new file mode 100644 index 000000000..2a7b1b40f --- /dev/null +++ b/examples/minesweeper/minesweeper/model.py @@ -0,0 +1,91 @@ +from mesa import Model +from mesa.datacollection import DataCollector +from mesa.discrete_space import OrthogonalMooreGrid, PropertyLayer + +from .agents import MineCell + + +class MinesweeperModel(Model): + def __init__(self, width=10, height=10, mine_density=0.15, seed=42): + super().__init__(seed=seed) + + self.game_over = False + self.frontier = set() + + self.grid = OrthogonalMooreGrid( + (width, height), + torus=False, + random=self.random, + ) + + self.mine_layer = PropertyLayer( + "mine", + (width, height), + default_value=False, + dtype=bool, + ) + + self.mine_layer.data = self.rng.choice( + [True, False], + size=(width, height), + p=[mine_density, 1 - mine_density], + ) + + self.grid.add_property_layer(self.mine_layer) + + MineCell.create_agents( + model=self, + n=width * height, + cell=self.grid.all_cells.cells, + ) + + for cell in self.grid.all_cells: + cell.agents[0].neighbor_mines = sum(n.mine for n in cell.neighborhood) + + safe = [c for c in self.grid.all_cells if not c.mine] + self.frontier |= set(self.random.sample(safe, k=5)) + + self.datacollector = DataCollector( + model_reporters={ + "Revealed": lambda m: sum(a.revealed for a in m.agents), + "Frontier": lambda m: len(m.frontier), + } + ) + + self.datacollector.collect(self) + + def step(self): + if self.game_over: + return + + if not self.frontier: + hidden_safe = [ + c + for c in self.grid.all_cells + if not c.mine and not c.agents[0].revealed + ] + if not hidden_safe: + return + self.frontier.add(self.random.choice(hidden_safe)) + + next_frontier = set() + + for cell in self.frontier: + agent = cell.agents[0] + + if agent.revealed: + continue + + agent.revealed = True + + if cell.mine: + self.game_over = True + return + + if agent.neighbor_mines == 0: + for n in cell.neighborhood: + if not n.agents[0].revealed: + next_frontier.add(n) + + self.frontier = next_frontier + self.datacollector.collect(self) diff --git a/examples/minesweeper/screenshot.png b/examples/minesweeper/screenshot.png new file mode 100644 index 000000000..c9facf24f Binary files /dev/null and b/examples/minesweeper/screenshot.png differ