Repository: projectmesa/mesa
Pull request #2246 (2024-08-25 13:40:39)

Resolve multiprocessing warning, state Python 3.13 support
This PR addresses a DeprecationWarning related to using `fork()` in multi-threaded processes, which can lead to deadlocks in the child processes when using Python's `multiprocessing` module.  The 'spawn' method avoids the issues that arise from forking in a multi-threaded environment by starting a new Python interpreter process for each worker, which is safer and more compatible with modern systems.

It also adds the Python 3.13 qualifier to the pyproject.toml.

EwoutH: Thanks! I notice there's a warnings that source directly from Python 3.13's multiprocessing, it might be nice to resolve that first.
```
tests/test_batch_run.py::test_batch_run_no_agent_reporters
  /opt/hostedtoolcache/Python/3.13.0-rc.1/x64/lib/python3.13/multiprocessing/popen_fork.py:67: DeprecationWarning: This process (pid=2042) is multi-threaded, use of fork() may lead to deadlocks in the child.
    self.pid = os.fork()
```

Patch:
diff --git a/.github/ISSUE_TEMPLATE/asking-help.md b/.github/ISSUE_TEMPLATE/asking-help.md
deleted file mode 100644
index 0a35b36a..00000000
--- a/.github/ISSUE_TEMPLATE/asking-help.md
+++ /dev/null
@@ -1,9 +0,0 @@
----
-name: Asking for help
-about: If you need help using Mesa, you should post in https://github.com/projectmesa/mesa/discussions
----
-
-<!--
-    ATTENTION: Don't raise an issue here!
-    If you need help, ask in https://github.com/projectmesa/mesa/discussions
--->
diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md
index 9ae0cda6..52b662e5 100644
--- a/.github/ISSUE_TEMPLATE/bug-report.md
+++ b/.github/ISSUE_TEMPLATE/bug-report.md
@@ -11,7 +11,8 @@ about: Let us know if something is broken on Mesa
 <!-- A clear and concise description of what you expected to happen -->
 
 **To Reproduce**
-<!-- Steps to reproduce the bug, or a link to a project where the bug is visible -->
+<!-- Steps to reproduce the bug, or a link to a project where the bug is visible
+Include a Minimal reproducible example: https://stackoverflow.com/help/minimal-reproducible-example -->
 
 **Additional context**
 <!--
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 00000000..473d9f11
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,5 @@
+blank_issues_enabled: true
+contact_links:
+  - name: Questions, ideas, showcases and more
+    url: https://github.com/projectmesa/mesa/discussions
+    about: Discuss Mesa, ask questions, share ideas, and showcase your projects
diff --git a/.github/PULL_REQUEST_TEMPLATE/bug.md b/.github/PULL_REQUEST_TEMPLATE/bug.md
new file mode 100644
index 00000000..36b2677d
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE/bug.md
@@ -0,0 +1,16 @@
+## Summary
+<!-- Provide a brief summary of the bug and its impact. -->
+
+## Bug / Issue
+<!-- Link to the related issue(s) and describe the bug. Include details like the context, what was expected, and what actually happened. -->
+
+## Implementation
+<!-- Describe the changes made to resolve the issue. Highlight any important parts of the code that were modified. -->
+
+## Testing
+<!-- Detail the testing performed to verify the fix. Include information on test cases, steps taken, and any relevant results.
+
+If you're fixing the visualisation, add before/after screenshots. -->
+
+## Additional Notes
+<!-- Add any additional information that may be relevant for the reviewers, such as potential side effects, dependencies, or related work.
diff --git a/.github/PULL_REQUEST_TEMPLATE/feature.md b/.github/PULL_REQUEST_TEMPLATE/feature.md
new file mode 100644
index 00000000..5d5c2114
--- /dev/null
+++ b/.github/PULL_REQUEST_TEMPLATE/feature.md
@@ -0,0 +1,16 @@
+## Summary
+<!-- Provide a concise summary of the feature and its purpose. -->
+
+## Motive
+<!-- Explain the reasoning behind this feature. Include details on the problem it addresses or the enhancement it provides. -->
+
+## Implementation
+<!-- Describe how the feature was implemented. Include details on the approach taken, important decisions made, and code changes. -->
+
+## Usage Examples
+<!-- Provide code snippets or examples demonstrating how to use the new feature. Highlight key scenarios where this feature will be beneficial.
+
+If you're modifying the visualisation, add before/after screenshots. -->
+
+## Additional Notes
+<!-- Add any additional information that may be relevant for the reviewers, such as potential side effects, dependencies, or related work. -->
diff --git a/.github/workflows/build_lint.yml b/.github/workflows/build_lint.yml
index ebd0258b..d407b178 100644
--- a/.github/workflows/build_lint.yml
+++ b/.github/workflows/build_lint.yml
@@ -77,7 +77,7 @@ jobs:
     - name: Install uv
       run: pip install uv
     - name: Install Mesa
-      run: uv pip install --system .[dev]
+      run: uv pip install --system .[examples]
     - name: Checkout mesa-examples
       uses: actions/checkout@v4
       with:
@@ -86,4 +86,4 @@ jobs:
     - name: Test examples
       run: |
         cd mesa-examples
-        pytest -rA -Werror test_examples.py
+        pytest -rA -Werror -Wdefault::FutureWarning test_examples.py
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index a8162a9b..b2c81407 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -4,7 +4,7 @@ ci:
 repos:
 - repo: https://github.com/astral-sh/ruff-pre-commit
   # Ruff version.
-  rev: v0.5.6
+  rev: v0.6.3
   hooks:
     # Run the linter.
     - id: ruff
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index ffc5d699..38899aba 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -36,6 +36,40 @@ discuss via [Matrix] OR via [an issue].
 - Describe the change w/ ticket number(s) that the code fixes.
 - Format your commit message as per [Tim Pope's guideline].
 
+## I have no idea where to start
+That's fine! Here's a rough outline where you could start, depending on your experience:
+
+### I'm a modeller (but not an experienced developer)
+You already know how to build Mesa models (if not skip below), and probably have found things Mesa can't do (elegantly). You want to improve that. Awesome!
+
+First step is to install some proper tools, if you haven't already.
+- A good IDE helps for code development, testing and formatting. [PyCharm](https://www.jetbrains.com/pycharm/) or [VSCode](https://code.visualstudio.com/) for example.
+- Dive into Git and GitHub. Watch some videos, this takes some time to click. [GitHub Desktop](https://desktop.github.com/) is great.
+- [`https://github.dev/projectmesa/mesa`](https://github.dev/projectmesa/mesa) is great for small changes (to docs).
+
+Learn the tools, talk to us about what you want to change, and open a small PR. Or update an [example model](https://github.com/projectmesa/mesa-examples) (check open [issues](https://github.com/projectmesa/mesa-examples/issues))!
+
+### I'm a developer (but not a modeller)
+Awesome! You have the basics of open-source software development (if not check above), but not much modelling experience.
+
+First step is to start thinking like a modeller. To understand the fine details about our library and contribute meaningfully, get some modelling experience:
+- Go though our [Introductory Tutorial](https://mesa.readthedocs.io/en/latest/tutorials/intro_tutorial.html) and [Visualization Tutorial](https://mesa.readthedocs.io/en/latest/tutorials/visualization_tutorial.html). While going through them, dive into the source code to really see what everything does.
+- Follow an ABM course (if possible). They might be a bit outdated programming language wise, but conceptual they're sound.
+  - This MOOC on ABM concepts: [Agent Based Modeling](https://ocw.tudelft.nl/course-lectures/agent-based-modeling/)
+  - This MOOC on practical ABM modelling: [Agent-Based Models with Python: An Introduction to Mesa](https://www.complexityexplorer.org/courses/172-agent-based-models-with-python-an-introduction-to-mesa)
+- Go though multiple of our [examples](https://github.com/projectmesa/mesa-examples). Play with them, modify things and get a feel for Mesa and ABMs.
+  - Check our open [issues](https://github.com/projectmesa/mesa-examples/issues) for the examples.
+  - If you see anything you want to improve, feel free to open a (small) PR!
+- If you have a feel for Mesa, check our [discussions](https://github.com/projectmesa/mesa/discussions) and [issues](https://github.com/projectmesa/mesa/issues).
+  - Also go thought our [release notes](https://github.com/projectmesa/mesa/releases) to see what we recently have been working on, and see some examples of successful PRs.
+- Once you found or thought of a nice idea, comment on the issue/discussion (or open a new one) and get to work!
+
+### I'm both
+That's great! You can just start working on things, reach out to us. Skim to the list above if you feel you're missing anything. Start small but don't be afraid to dream big!
+
+### I'm neither
+Start with creating your own models, for fun. Once you have some experience, move to the topics above.
+
 ## Testing and Code Standards
 
 ```{image} https://codecov.io/gh/projectmesa/mesa/branch/main/graph/badge.svg
diff --git a/HISTORY.md b/HISTORY.md
index 91d4c703..bdaf491f 100644
--- a/HISTORY.md
+++ b/HISTORY.md
@@ -1,6 +1,54 @@
 ---
 title: Release History
 ---
+# 3.0.0a3 (2024-08-30)
+## Highlights
+Developments toward Mesa 3.0 are steaming ahead, and our fourth alpha release is packed with features and updates - only 8 days after our third.
+
+Mesa 3.0.0a3 contains one breaking change: We now automatically increase the `steps` counter by one at the beginning of each `Model.steps()` call. That means increasing `steps` by hand isn't necessary anymore.
+
+The big new features is the experimental Voronoi grid that @vitorfrois implemented in #2084. It allows creating cells in a [Voronoi](https://en.wikipedia.org/wiki/Voronoi_diagram) layout as part of the experimental cell space. An example using it to model Cholera spread can be [found here](https://github.com/projectmesa/mesa-examples/pull/118).
+
+The AgentSet got a lot of love with two brand new methods: `.groupby()` to split in groups (#2220) and `.set()` to easily assign variables to all agents in that set (#2254). The `select()` method is improved by allowing to select at most a fraction of the agents (#2253), and we split the `do()` method in `do()` and `map()` to make a distinction between the return types (#2237).
+
+Furthermore, we improved the performance of accessing `Model.agents`, squashed a bug in SolaraViz, started testing on Python 3.13 and added a new benchmark model.
+
+Our example models also got more love: We removed the `RandomActivation` scheduler in 14 models and removed SimultaneousActivation in 3 models ([examples#183](https://github.com/projectmesa/mesa-examples/pull/183)). They now use the automatic step increase and AgentSet functionality. We started testing our GIS model in CI ([examples#171](https://github.com/projectmesa/mesa-examples/pull/171)) and resolved a lot of bugs in them ([examples#172](https://github.com/projectmesa/mesa-examples/issues/172), help appreciated!).
+
+Finally, we have two brand new examples: An Ant Colony Optimization model using an Ant System approach to the Traveling Salesman problem, a Mesa NetworkGrid, and a custom visualisation with SolaraViz ([examples#157](https://github.com/projectmesa/mesa-examples/pull/157) by @zjost). The first example using the `PropertyLayer` was added, a very fast implementation of Conway's Game of Life ([examples#182](https://github.com/projectmesa/mesa-examples/pull/182)).
+
+To help the transition to Mesa 3.0, we started writing a [migration guide](https://mesa.readthedocs.io/en/latest/migration_guide.html). Progress is tracked in #2233, feedback and help is appreciated! Finally, we also added a new section to our [contributor guide](https://github.com/projectmesa/mesa/blob/main/CONTRIBUTING.md#i-have-no-idea-where-to-start) to get new contributors up to speed.
+
+This pre-release can be installed as always with `pip install --pre mesa`
+
+## What's Changed
+### ⚠️ Breaking changes
+* model: Automatically increase `steps` counter by @EwoutH in https://github.com/projectmesa/mesa/pull/2223
+### 🧪 Experimental features
+* Voronoi Tessellation based Discrete Space by @vitorfrois in https://github.com/projectmesa/mesa/pull/2084
+### 🎉 New features added
+* Add AgentSet.groupby by @quaquel in https://github.com/projectmesa/mesa/pull/2220
+* AgentSet: Add `set` method by @EwoutH in https://github.com/projectmesa/mesa/pull/2254
+### 🛠 Enhancements made
+* Split AgentSet into map and do to separate return types by @quaquel in https://github.com/projectmesa/mesa/pull/2237
+* Performance enhancements for Model.agents by @quaquel in https://github.com/projectmesa/mesa/pull/2251
+* AgentSet: Allow selecting a fraction of agents in the AgentSet by @EwoutH in https://github.com/projectmesa/mesa/pull/2253
+### 🐛 Bugs fixed
+* SolaraViz: Reset components when params are changed by @rht in https://github.com/projectmesa/mesa/pull/2240
+### 📜 Documentation improvements
+* Contribution: Add "I have no idea where to start" section by @EwoutH in https://github.com/projectmesa/mesa/pull/2258
+* Write initial Mesa Migration guide by @EwoutH in https://github.com/projectmesa/mesa/pull/2257
+### 🔧 Maintenance
+* CI: Add test job for Python 3.13 by @EwoutH in https://github.com/projectmesa/mesa/pull/2173
+* Add pull request templates by @EwoutH in https://github.com/projectmesa/mesa/pull/2217
+* benchmarks: Add BoltzmannWealth model by @EwoutH in https://github.com/projectmesa/mesa/pull/2252
+* CI: Add optional dependency for examples by @EwoutH in https://github.com/projectmesa/mesa/pull/2261
+
+## New Contributors
+* @vitorfrois made their first contribution in https://github.com/projectmesa/mesa/pull/2084
+
+**Full Changelog**: https://github.com/projectmesa/mesa/compare/v3.0.0a2...v3.0.0a3
+
 # 3.0.0a2 (2024-08-21)
 ## Highlights
 In Mesa 3.0 alpha 2 (`v3.0.0a2`) we've done more clean-up in preparation for Mesa 3.0. We now [require](https://github.com/projectmesa/mesa/pull/2218) `super().__init__()`  to run on initializing a Mesa model subclass, `Model.agents` is now fully reserved for the Model's internal AgentSet and we fixed a bug in our Solara space_drawer.
diff --git a/README.md b/README.md
index 548e96de..d7dd0faf 100644
--- a/README.md
+++ b/README.md
@@ -52,15 +52,18 @@ Or any other (development) branch on this repo or your own fork:
 pip install -U -e git+https://github.com/YOUR_FORK/mesa@YOUR_BRANCH#egg=mesa
 ```
 
+## Resources
 For resources or help on using Mesa, check out the following:
 
 -   [Intro to Mesa Tutorial](http://mesa.readthedocs.org/en/stable/tutorials/intro_tutorial.html) (An introductory model, the Boltzmann
     Wealth Model, for beginners or those new to Mesa.)
+-   [Visualization Tutorial](https://mesa.readthedocs.io/en/stable/tutorials/visualization_tutorial.html) (An introduction into our Solara visualization)
 -   [Complexity Explorer Tutorial](https://www.complexityexplorer.org/courses/172-agent-based-models-with-python-an-introduction-to-mesa) (An advanced-beginner model,
     SugarScape with Traders, with instructional videos)
 -   [Mesa Examples](https://github.com/projectmesa/mesa-examples/tree/main/examples) (A repository of seminal ABMs using Mesa and
     examples of employing specific Mesa Features)
 -   [Docs](http://mesa.readthedocs.org/) (Mesa's documentation, API and useful snippets)
+    -   [Development version docs](https://mesa.readthedocs.io/en/latest/) (the latest version docs if you're using a pre-release Mesa version)
 -   [Discussions](https://github.com/projectmesa/mesa/discussions) (GitHub threaded discussions about Mesa)
 -   [Matrix Chat](https://matrix.to/#/#project-mesa:matrix.org) (Chat Forum via Matrix to talk about Mesa)
 
diff --git a/benchmarks/BoltzmannWealth/__init__.py b/benchmarks/BoltzmannWealth/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/benchmarks/BoltzmannWealth/boltzmann_wealth.py b/benchmarks/BoltzmannWealth/boltzmann_wealth.py
new file mode 100644
index 00000000..56443c6f
--- /dev/null
+++ b/benchmarks/BoltzmannWealth/boltzmann_wealth.py
@@ -0,0 +1,77 @@
+# https://github.com/projectmesa/mesa-examples/blob/main/examples/boltzmann_wealth_model_experimental/model.py
+import mesa
+
+
+def compute_gini(model):
+    agent_wealths = [agent.wealth for agent in model.agents]
+    x = sorted(agent_wealths)
+    n = model.num_agents
+    b = sum(xi * (n - i) for i, xi in enumerate(x)) / (n * sum(x))
+    return 1 + (1 / n) - 2 * b
+
+
+class BoltzmannWealth(mesa.Model):
+    """A simple model of an economy where agents exchange currency at random.
+
+    All the agents begin with one unit of currency, and each time step can give
+    a unit of currency to another agent. Note how, over time, this produces a
+    highly skewed distribution of wealth.
+    """
+
+    def __init__(self, seed=None, n=100, width=10, height=10):
+        super().__init__()
+        self.num_agents = n
+        self.grid = mesa.space.MultiGrid(width, height, True)
+        self.schedule = mesa.time.RandomActivation(self)
+        self.datacollector = mesa.DataCollector(
+            model_reporters={"Gini": compute_gini}, agent_reporters={"Wealth": "wealth"}
+        )
+        # Create agents
+        for _ in range(self.num_agents):
+            a = MoneyAgent(self)
+            # Add the agent to a random grid cell
+            x = self.random.randrange(self.grid.width)
+            y = self.random.randrange(self.grid.height)
+            self.grid.place_agent(a, (x, y))
+
+        self.running = True
+        self.datacollector.collect(self)
+
+    def step(self):
+        self.agents.shuffle().do("step")
+        # collect data
+        self.datacollector.collect(self)
+
+    def run_model(self, n):
+        for _i in range(n):
+            self.step()
+
+
+class MoneyAgent(mesa.Agent):
+    """An agent with fixed initial wealth."""
+
+    def __init__(self, model):
+        super().__init__(model)
+        self.wealth = 1
+
+    def move(self):
+        possible_steps = self.model.grid.get_neighborhood(
+            self.pos, moore=True, include_center=False
+        )
+        new_position = self.random.choice(possible_steps)
+        self.model.grid.move_agent(self, new_position)
+
+    def give_money(self):
+        cellmates = self.model.grid.get_cell_list_contents([self.pos])
+        cellmates.pop(
+            cellmates.index(self)
+        )  # Ensure agent is not giving money to itself
+        if len(cellmates) > 0:
+            other = self.random.choice(cellmates)
+            other.wealth += 1
+            self.wealth -= 1
+
+    def step(self):
+        self.move()
+        if self.wealth > 0:
+            self.give_money()
diff --git a/benchmarks/Flocking/flocking.py b/benchmarks/Flocking/flocking.py
index ff9f97c0..284d8318 100644
--- a/benchmarks/Flocking/flocking.py
+++ b/benchmarks/Flocking/flocking.py
@@ -27,7 +27,6 @@ class Boid(mesa.Agent):
 
     def __init__(
         self,
-        unique_id,
         model,
         speed,
         direction,
@@ -41,7 +40,6 @@ class Boid(mesa.Agent):
         Create a new Boid flocker agent.
 
         Args:
-            unique_id: Unique agent identifier.
             speed: Distance to move per step.
             direction: numpy vector for the Boid's direction of movement.
             vision: Radius to look around for nearby Boids.
@@ -51,7 +49,7 @@ class Boid(mesa.Agent):
             match: the relative importance of matching neighbors' directions
 
         """
-        super().__init__(unique_id, model)
+        super().__init__(model)
         self.speed = speed
         self.direction = direction
         self.vision = vision
@@ -131,13 +129,12 @@ class BoidFlockers(mesa.Model):
             "match": match,
         }
 
-        for i in range(self.population):
+        for _ in range(self.population):
             x = self.random.random() * self.space.x_max
             y = self.random.random() * self.space.y_max
             pos = np.array((x, y))
             direction = np.random.random(2) * 2 - 1
             boid = Boid(
-                unique_id=i,
                 model=self,
                 speed=speed,
                 direction=direction,
diff --git a/benchmarks/Schelling/schelling.py b/benchmarks/Schelling/schelling.py
index b7702a84..b582e7af 100644
--- a/benchmarks/Schelling/schelling.py
+++ b/benchmarks/Schelling/schelling.py
@@ -8,15 +8,14 @@ class SchellingAgent(CellAgent):
     Schelling segregation agent
     """
 
-    def __init__(self, unique_id, model, agent_type, radius, homophily):
+    def __init__(self, model, agent_type, radius, homophily):
         """
         Create a new Schelling agent.
         Args:
-           unique_id: Unique identifier for the agent.
            x, y: Agent initial location.
            agent_type: Indicator for the agent's type (minority=1, majority=0)
         """
-        super().__init__(unique_id, model)
+        super().__init__(model)
         self.type = agent_type
         self.radius = radius
         self.homophily = homophily
@@ -81,9 +80,7 @@ class Schelling(Model):
         for cell in self.grid:
             if self.random.random() < density:
                 agent_type = 1 if self.random.random() < self.minority_pc else 0
-                agent = SchellingAgent(
-                    self.next_id(), self, agent_type, radius, homophily
-                )
+                agent = SchellingAgent(self, agent_type, radius, homophily)
                 agent.move_to(cell)
                 self.schedule.add(agent)
 
diff --git a/benchmarks/WolfSheep/wolf_sheep.py b/benchmarks/WolfSheep/wolf_sheep.py
index ecb78594..ceb6efef 100644
--- a/benchmarks/WolfSheep/wolf_sheep.py
+++ b/benchmarks/WolfSheep/wolf_sheep.py
@@ -17,8 +17,8 @@ from mesa.experimental.devs import ABMSimulator
 
 
 class Animal(CellAgent):
-    def __init__(self, unique_id, model, energy, p_reproduce, energy_from_food):
-        super().__init__(unique_id, model)
+    def __init__(self, model, energy, p_reproduce, energy_from_food):
+        super().__init__(model)
         self.energy = energy
         self.p_reproduce = p_reproduce
         self.energy_from_food = energy_from_food
@@ -29,7 +29,6 @@ class Animal(CellAgent):
     def spawn_offspring(self):
         self.energy /= 2
         offspring = self.__class__(
-            self.model.next_id(),
             self.model,
             self.energy,
             self.p_reproduce,
@@ -107,7 +106,7 @@ class GrassPatch(CellAgent):
                 function_args=[self, "fully_grown", True],
             )
 
-    def __init__(self, unique_id, model, fully_grown, countdown, grass_regrowth_time):
+    def __init__(self, model, fully_grown, countdown, grass_regrowth_time):
         """
         TODO:: fully grown can just be an int --> so one less param (i.e. countdown)
 
@@ -119,7 +118,7 @@ class GrassPatch(CellAgent):
             grass_regrowth_time : time to fully regrow grass
             countdown : Time for the patch of grass to be fully regrown if fully grown is False
         """
-        super().__init__(unique_id, model)
+        super().__init__(model)
         self._fully_grown = fully_grown
         self.grass_regrowth_time = grass_regrowth_time
 
@@ -189,7 +188,6 @@ class WolfSheep(Model):
             )
             energy = self.random.randrange(2 * sheep_gain_from_food)
             sheep = Sheep(
-                self.next_id(),
                 self,
                 energy,
                 sheep_reproduce,
@@ -205,7 +203,6 @@ class WolfSheep(Model):
             )
             energy = self.random.randrange(2 * wolf_gain_from_food)
             wolf = Wolf(
-                self.next_id(),
                 self,
                 energy,
                 wolf_reproduce,
@@ -221,14 +218,12 @@ class WolfSheep(Model):
                 countdown = grass_regrowth_time
             else:
                 countdown = self.random.randrange(grass_regrowth_time)
-            patch = GrassPatch(
-                self.next_id(), self, fully_grown, countdown, grass_regrowth_time
-            )
+            patch = GrassPatch(self, fully_grown, countdown, grass_regrowth_time)
             patch.move_to(cell)
 
     def step(self):
-        self.get_agents_of_type(Sheep).shuffle(inplace=True).do("step")
-        self.get_agents_of_type(Wolf).shuffle(inplace=True).do("step")
+        self.agents_by_type[Sheep].shuffle(inplace=True).do("step")
+        self.agents_by_type[Wolf].shuffle(inplace=True).do("step")
 
 
 if __name__ == "__main__":
diff --git a/benchmarks/configurations.py b/benchmarks/configurations.py
index c42f69ad..6d80ef3a 100644
--- a/benchmarks/configurations.py
+++ b/benchmarks/configurations.py
@@ -1,8 +1,32 @@
+from BoltzmannWealth.boltzmann_wealth import BoltzmannWealth
 from Flocking.flocking import BoidFlockers
 from Schelling.schelling import Schelling
 from WolfSheep.wolf_sheep import WolfSheep
 
 configurations = {
+    # Schelling Model Configurations
+    BoltzmannWealth: {
+        "small": {
+            "seeds": 50,
+            "replications": 5,
+            "steps": 125,
+            "parameters": {
+                "n": 100,
+                "width": 10,
+                "height": 10,
+            },
+        },
+        "large": {
+            "seeds": 10,
+            "replications": 3,
+            "steps": 10,
+            "parameters": {
+                "n": 10000,
+                "width": 100,
+                "height": 100,
+            },
+        },
+    },
     # Schelling Model Configurations
     Schelling: {
         "small": {
@@ -35,7 +59,7 @@ configurations = {
         "small": {
             "seeds": 50,
             "replications": 5,
-            "steps": 40,
+            "steps": 80,
             "parameters": {
                 "height": 25,
                 "width": 25,
diff --git a/benchmarks/global_benchmark.py b/benchmarks/global_benchmark.py
index 539e1ca4..19187c21 100644
--- a/benchmarks/global_benchmark.py
+++ b/benchmarks/global_benchmark.py
@@ -16,18 +16,22 @@ sys.path.insert(0, os.path.abspath(".."))
 
 # Generic function to initialize and run a model
 def run_model(model_class, seed, parameters):
+    no_simulator = ["BoltzmannWealth"]
     start_init = timeit.default_timer()
-    simulator = ABMSimulator()
-    model = model_class(simulator=simulator, seed=seed, **parameters)
-    simulator.setup(model)
+    if model_class.__name__ in no_simulator:
+        model = model_class(seed=seed, **parameters)
+    else:
+        simulator = ABMSimulator()
+        model = model_class(simulator=simulator, seed=seed, **parameters)
+        simulator.setup(model)
 
     end_init_start_run = timeit.default_timer()
 
-    simulator.run_for(config["steps"])
+    if model_class.__name__ in no_simulator:
+        model.run_model(config["steps"])
+    else:
+        simulator.run_for(config["steps"])
 
-    # for _ in range(config["steps"]):
-    #     model.step()
-    #       time.sleep(0.0001)
     end_run = timeit.default_timer()
 
     return (end_init_start_run - start_init), (end_run - end_init_start_run)
diff --git a/docs/conf.py b/docs/conf.py
index 8eb7b462..ad23acab 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -113,6 +113,8 @@ pygments_style = "gruvbox-dark"
 
 nb_execution_timeout = 60
 nb_execution_mode = "cache"
+nb_execution_allow_errors = False
+nb_execution_raise_on_error = True
 
 # -- Options for HTML output ----------------------------------------------
 
diff --git a/docs/index.md b/docs/index.md
index 669f9294..042fc64d 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -81,6 +81,7 @@ ABM features users have shared that you may want to use in your model
 Mesa Overview <overview>
 tutorials/intro_tutorial
 tutorials/visualization_tutorial
+Migration guide <migration_guide>
 Best Practices <best-practices>
 How-to Guide <howto>
 API Documentation <apis/api_main>
@@ -98,7 +99,7 @@ Mesa Packages <packages>
 [github issue tracker]: https://github.com/projectmesa/mesa/issues
 [matrix chat room]: https://matrix.to/#/#project-mesa:matrix.org
 [mesa]: https://github.com/projectmesa/mesa/
-[mesa introductory tutorial]: tutorials/intro_tutorial.html
-[mesa visualization tutorial]: tutorials/visualization_tutorial.html
+[mesa introductory tutorial]: tutorials/intro_tutorial
+[mesa visualization tutorial]: tutorials/visualization_tutorial
 [pypi]: https://pypi.python.org/pypi/Mesa/
 [ticket]: https://github.com/projectmesa/mesa/issues
diff --git a/docs/migration_guide.md b/docs/migration_guide.md
new file mode 100644
index 00000000..9097ea2e
--- /dev/null
+++ b/docs/migration_guide.md
@@ -0,0 +1,67 @@
+# Mesa Migration guide
+This guide contains breaking changes between major Mesa versions and how to resolve them.
+
+Non-breaking changes aren't included, for those see our [Release history](https://github.com/projectmesa/mesa/releases).
+
+## Mesa 3.0
+<!-- TODO small introduction-->
+
+_This guide is a work in progress. The development of it is tracked in [Issue #2233](https://github.com/projectmesa/mesa/issues/2233)._
+
+### Reserved and private variables
+<!-- TODO: Update this section based on https://github.com/projectmesa/mesa/discussions/2230 -->
+
+#### Reserved variables
+Currently, we have reserved the following variables:
+  - Model: `agents`, `current_id`, `random`, `running`, `steps`, `time`.
+  - Agent: `unique_id`, `model`.
+
+You can use (read) any reserved variable, but Mesa may update them automatically and rely on them, so modify/update at your own risk.
+#### Private variables
+Any variables starting with an underscore (`_`) are considered private and for Mesa's internal use. We might use any of those. Modifying or overwriting any private variable is at your own risk.
+
+- Ref: [Discussion #2230](https://github.com/projectmesa/mesa/discussions/2230), [PR #2225](https://github.com/projectmesa/mesa/pull/2225)
+
+
+### Removal of `mesa.flat` namespace
+The `mesa.flat` namespace is removed. Use the full namespace for your imports.
+
+- Ref: [PR #2091](https://github.com/projectmesa/mesa/pull/2091)
+
+
+### Automatic assignment of `unique_id` to Agents
+<!-- TODO -->
+
+- Ref: [PR #2226](https://github.com/projectmesa/mesa/pull/2226)
+
+
+### AgentSet and `Model.agents`
+#### AgentSet
+<!-- TODO  -->
+
+#### `Model.agents`
+<!-- TODO  -->
+
+
+### Time and schedulers
+<!-- TODO general explanation-->
+
+#### Automatic increase of the `steps` counter
+The `steps` counter is now automatically increased. With each call to `Model.steps()` it's increased by 1, at the beginning of the step.
+
+You can access it by `Model.steps`, and it's internally in the datacollector, batchrunner and the visualisation.
+
+- Ref: [PR #2223](https://github.com/projectmesa/mesa/pull/2223), Mesa-examples [PR #161](https://github.com/projectmesa/mesa-examples/pull/161)
+
+#### Removal of `Model._time` and rename `._steps`
+- `Model._time` is removed. You can define your own time variable if needed.
+- `Model._steps` steps is renamed to `Model.steps`.
+
+#### Removal of `Model._advance_time()`
+- The `Model._advance_time()` method is removed. This now happens automatically.
+
+<!-- TODO deprecate all schedulers? -->
+
+
+### Visualisation
+<!-- TODO -->
diff --git a/docs/overview.md b/docs/overview.md
index de0bad3b..bb3e2e15 100644
--- a/docs/overview.md
+++ b/docs/overview.md
@@ -17,8 +17,8 @@ Mesa is modular, meaning that its modeling, analysis and visualization component
 Most models consist of one class to represent the model itself; one class (or more) for agents; a scheduler to handle time (what order the agents act in), and possibly a space for the agents to inhabit and move through. These are implemented in Mesa's modeling modules:
 
 - `mesa.Model`, `mesa.Agent`
-- [mesa.time](apis/time.html)
-- [mesa.space](apis/space.html)
+- [mesa.time](apis/time)
+- [mesa.space](apis/space)
 
 The skeleton of a model might look like this:
 
@@ -56,7 +56,7 @@ model = MyModel(5)
 model.step()
 ```
 
-You should see agents 0-4, activated in random order. See the [tutorial](tutorials/intro_tutorial.html) or API documentation for more detail on how to add model functionality.
+You should see agents 0-4, activated in random order. See the [tutorial](tutorials/intro_tutorial) or API documentation for more detail on how to add model functionality.
 
 To bootstrap a new model install mesa and run `mesa startproject`
 
@@ -64,8 +64,8 @@ To bootstrap a new model install mesa and run `mesa startproject`
 
 If you're using modeling for research, you'll want a way to collect the data each model run generates. You'll probably also want to run the model multiple times, to see how some output changes with different parameters. Data collection and batch running are implemented in the appropriately-named analysis modules:
 
-- [mesa.datacollection](apis/datacollection.html)
-- [mesa.batchrunner](apis/batchrunner.html)
+- [mesa.datacollection](apis/datacollection)
+- [mesa.batchrunner](apis/batchrunner)
 
 You'd add a data collector to the model like this:
 
diff --git a/docs/tutorials/MoneyModel.py b/docs/tutorials/MoneyModel.py
index 94555f98..93e19dec 100644
--- a/docs/tutorials/MoneyModel.py
+++ b/docs/tutorials/MoneyModel.py
@@ -15,8 +15,8 @@ def compute_gini(model):
 class MoneyAgent(mesa.Agent):
     """An agent with fixed initial wealth."""
 
-    def __init__(self, unique_id, model):
-        super().__init__(unique_id, model)
+    def __init__(self, model):
+        super().__init__(model)
         self.wealth = 1
 
     def move(self):
diff --git a/docs/tutorials/visualization_tutorial.ipynb b/docs/tutorials/visualization_tutorial.ipynb
index ba8d7a92..c40b0d1b 100644
--- a/docs/tutorials/visualization_tutorial.ipynb
+++ b/docs/tutorials/visualization_tutorial.ipynb
@@ -208,7 +208,7 @@
     "    # plt.figure(), for thread safety purpose\n",
     "    fig = Figure()\n",
     "    ax = fig.subplots()\n",
-    "    wealth_vals = [agent.wealth for agent in model.schedule.agents]\n",
+    "    wealth_vals = [agent.wealth for agent in model.agents]\n",
     "    # Note: you have to use Matplotlib's OOP API instead of plt.hist\n",
     "    # because plt.hist is not thread-safe.\n",
     "    ax.hist(wealth_vals, bins=10)\n",
diff --git a/mesa/__init__.py b/mesa/__init__.py
index 50d6b300..5fe04262 100644
--- a/mesa/__init__.py
+++ b/mesa/__init__.py
@@ -24,7 +24,7 @@ __all__ = [
 ]
 
 __title__ = "mesa"
-__version__ = "3.0.0a2"
+__version__ = "3.0.0a3"
 __license__ = "Apache 2.0"
 _this_year = datetime.datetime.now(tz=datetime.timezone.utc).date().year
 __copyright__ = f"Copyright {_this_year} Project Mesa Team"
diff --git a/mesa/agent.py b/mesa/agent.py
index f137ba09..225e89cd 100644
--- a/mesa/agent.py
+++ b/mesa/agent.py
@@ -10,6 +10,8 @@ from __future__ import annotations
 
 import contextlib
 import copy
+import functools
+import itertools
 import operator
 import warnings
 import weakref
@@ -32,36 +34,57 @@ class Agent:
     Base class for a model agent in Mesa.
 
     Attributes:
-        unique_id (int): A unique identifier for this agent.
         model (Model): A reference to the model instance.
-        self.pos: Position | None = None
+        unique_id (int): A unique identifier for this agent.
+        pos (Position): A reference to the position where this agent is located.
+
+    Notes:
+          unique_id is unique relative to a model instance and starts from 1
+
     """
 
-    def __init__(self, unique_id: int, model: Model) -> None:
+    # this is a class level attribute
+    # it is a dictionary, indexed by model instance
+    # so, unique_id is unique relative to a model, and counting starts from 1
+    _ids = defaultdict(functools.partial(itertools.count, 1))
+
+    def __init__(self, *args, **kwargs) -> None:
         """
         Create a new agent.
 
         Args:
-            unique_id (int): A unique identifier for this agent.
             model (Model): The model instance in which the agent exists.
         """
-        self.unique_id = unique_id
-        self.model = model
+        # TODO: Cleanup in future Mesa version (3.1+)
+        match args:
+            # Case 1: Only the model is provided. The new correct behavior.
+            case [model]:
+                self.model = model
+                self.unique_id = next(self._ids[model])
+            # Case 2: Both unique_id and model are provided, deprecated
+            case [_, model]:
+                warnings.warn(
+                    "unique ids are assigned automatically to Agents in Mesa 3. The use of custom unique_id is "
+                    "deprecated. Only input a model when calling `super()__init__(model)`. The unique_id inputted is not used.",
+                    DeprecationWarning,
+                    stacklevel=2,
+                )
+                self.model = model
+                self.unique_id = next(self._ids[model])
+            # Case 3: Anything else, raise an error
+            case _:
+                raise ValueError(
+                    "Invalid arguments provided to initialize the Agent. Only input a model: `super()__init__(model)`."
+                )
+
         self.pos: Position | None = None
 
-        # register agent
-        try:
-            self.model.agents_[type(self)][self] = None
-        except AttributeError as err:
-            # model super has not been called
-            raise RuntimeError(
-                "The Mesa Model class was not initialized. You must explicitly initialize the Model by calling super().__init__() on initialization."
-            ) from err
+        self.model.register_agent(self)
 
     def remove(self) -> None:
         """Remove and delete the agent from the model."""
         with contextlib.suppress(KeyError):
-            self.model.agents_[type(self)].pop(self)
+            self.model.deregister_agent(self)
 
     def step(self) -> None:
         """A single step of the agent."""
@@ -121,9 +144,10 @@ class AgentSet(MutableSet, Sequence):
     def select(
         self,
         filter_func: Callable[[Agent], bool] | None = None,
-        n: int = 0,
+        at_most: int | float = float("inf"),
         inplace: bool = False,
         agent_type: type[Agent] | None = None,
+        n: int | None = None,
     ) -> AgentSet:
         """
         Select a subset of agents from the AgentSet based on a filter function and/or quantity limit.
@@ -131,29 +155,47 @@ class AgentSet(MutableSet, Sequence):
         Args:
             filter_func (Callable[[Agent], bool], optional): A function that takes an Agent and returns True if the
                 agent should be included in the result. Defaults to None, meaning no filtering is applied.
-            n (int, optional): The number of agents to select. If 0, all matching agents are selected. Defaults to 0.
+            at_most (int | float, optional): The maximum amount of agents to select. Defaults to infinity.
+              - If an integer, at most the first number of matching agents are selected.
+              - If a float between 0 and 1, at most that fraction of original the agents are selected.
             inplace (bool, optional): If True, modifies the current AgentSet; otherwise, returns a new AgentSet. Defaults to False.
             agent_type (type[Agent], optional): The class type of the agents to select. Defaults to None, meaning no type filtering is applied.
 
         Returns:
             AgentSet: A new AgentSet containing the selected agents, unless inplace is True, in which case the current AgentSet is updated.
+
+        Notes:
+            - at_most just return the first n or fraction of agents. To take a random sample, shuffle() beforehand.
+            - at_most is an upper limit. When specifying other criteria, the number of agents returned can be smaller.
         """
+        if n is not None:
+            warnings.warn(
+                "The parameter 'n' is deprecated. Use 'at_most' instead.",
+                DeprecationWarning,
+                stacklevel=2,
+            )
+            at_most = n
 
-        if filter_func is None and agent_type is None and n == 0:
+        inf = float("inf")
+        if filter_func is None and agent_type is None and at_most == inf:
             return self if inplace else copy.copy(self)
 
-        def agent_generator(filter_func=None, agent_type=None, n=0):
+        # Check if at_most is of type float
+        if at_most <= 1.0 and isinstance(at_most, float):
+            at_most = int(len(self) * at_most)  # Note that it rounds down (floor)
+
+        def agent_generator(filter_func, agent_type, at_most):
             count = 0
             for agent in self:
+                if count >= at_most:
+                    break
                 if (not filter_func or filter_func(agent)) and (
                     not agent_type or isinstance(agent, agent_type)
                 ):
                     yield agent
                     count += 1
-                    if 0 < n <= count:
-                        break
 
-        agents = agent_generator(filter_func, agent_type, n)
+        agents = agent_generator(filter_func, agent_type, at_most)
 
         return AgentSet(agents, self.model) if not inplace else self._update(agents)
 
@@ -292,6 +334,20 @@ class AgentSet(MutableSet, Sequence):
 
         return res
 
+    def agg(self, attribute: str, func: Callable) -> Any:
+        """
+        Aggregate an attribute of all agents in the AgentSet using a specified function.
+
+        Args:
+            attribute (str): The name of the attribute to aggregate.
+            func (Callable): The function to apply to the attribute values (e.g., min, max, sum, np.mean).
+
+        Returns:
+            Any: The result of applying the function to the attribute values. Often a single value.
+        """
+        values = self.get(attribute)
+        return func(values)
+
     def get(self, attr_names: str | list[str]) -> list[Any]:
         """
         Retrieve the specified attribute(s) from each agent in the AgentSet.
@@ -316,6 +372,21 @@ class AgentSet(MutableSet, Sequence):
                 for agent in self._agents
             ]
 
+    def set(self, attr_name: str, value: Any) -> AgentSet:
+        """
+        Set a specified attribute to a given value for all agents in the AgentSet.
+
+        Args:
+            attr_name (str): The name of the attribute to set.
+            value (Any): The value to set the attribute to.
+
+        Returns:
+            AgentSet: The AgentSet instance itself, after setting the attribute.
+        """
+        for agent in self:
+            setattr(agent, attr_name, value)
+        return self
+
     def __getitem__(self, item: int | slice) -> Agent:
         """
         Retrieve an agent or a slice of agents from the AgentSet.
diff --git a/mesa/batchrunner.py b/mesa/batchrunner.py
index 80ba23ca..4e7fe5ca 100644
--- a/mesa/batchrunner.py
+++ b/mesa/batchrunner.py
@@ -1,4 +1,5 @@
 import itertools
+import multiprocessing
 from collections.abc import Iterable, Mapping
 from functools import partial
 from multiprocessing import Pool
@@ -8,6 +9,8 @@ from tqdm.auto import tqdm
 
 from mesa.model import Model
 
+multiprocessing.set_start_method("spawn", force=True)
+
 
 def batch_run(
     model_cls: type[Model],
@@ -132,14 +135,14 @@ def _model_run_func(
     """
     run_id, iteration, kwargs = run
     model = model_cls(**kwargs)
-    while model.running and model._steps <= max_steps:
+    while model.running and model.steps <= max_steps:
         model.step()
 
     data = []
 
-    steps = list(range(0, model._steps, data_collection_period))
-    if not steps or steps[-1] != model._steps - 1:
-        steps.append(model._steps - 1)
+    steps = list(range(0, model.steps, data_collection_period))
+    if not steps or steps[-1] != model.steps - 1:
+        steps.append(model.steps - 1)
 
     for step in steps:
         model_data, all_agents_data = _collect_data(model, step)
diff --git a/mesa/datacollection.py b/mesa/datacollection.py
index 712482af..5a82e71c 100644
--- a/mesa/datacollection.py
+++ b/mesa/datacollection.py
@@ -180,7 +180,7 @@ class DataCollector:
         rep_funcs = self.agent_reporters.values()
 
         def get_reports(agent):
-            _prefix = (agent.model._steps, agent.unique_id)
+            _prefix = (agent.model.steps, agent.unique_id)
             reports = tuple(rep(agent) for rep in rep_funcs)
             return _prefix + reports
 
@@ -216,7 +216,7 @@ class DataCollector:
 
         if self.agent_reporters:
             agent_records = self._record_agents(model)
-            self._agent_records[model._steps] = list(agent_records)
+            self._agent_records[model.steps] = list(agent_records)
 
     def add_table_row(self, table_name, row, ignore_missing=False):
         """Add a row dictionary to a specific table.
diff --git a/mesa/experimental/UserParam.py b/mesa/experimental/UserParam.py
new file mode 100644
index 00000000..5b342471
--- /dev/null
+++ b/mesa/experimental/UserParam.py
@@ -0,0 +1,56 @@
+class UserParam:
+    _ERROR_MESSAGE = "Missing or malformed inputs for '{}' Option '{}'"
+
+    def maybe_raise_error(self, param_type, valid):
+        if valid:
+            return
+        msg = self._ERROR_MESSAGE.format(param_type, self.label)
+        raise ValueError(msg)
+
+
+class Slider(UserParam):
+    """
+    A number-based slider input with settable increment.
+
+    Example:
+
+    slider_option = Slider("My Slider", value=123, min=10, max=200, step=0.1)
+
+    Args:
+        label: The displayed label in the UI
+        value: The initial value of the slider
+        min: The minimum possible value of the slider
+        max: The maximum possible value of the slider
+        step: The step between min and max for a range of possible values
+        dtype: either int or float
+    """
+
+    def __init__(
+        self,
+        label="",
+        value=None,
+        min=None,
+        max=None,
+        step=1,
+        dtype=None,
+    ):
+        self.label = label
+        self.value = value
+        self.min = min
+        self.max = max
+        self.step = step
+
+        # Validate option type to make sure values are supplied properly
+        valid = not (self.value is None or self.min is None or self.max is None)
+        self.maybe_raise_error("slider", valid)
+
+        if dtype is None:
+            self.is_float_slider = self._check_values_are_float(value, min, max, step)
+        else:
+            self.is_float_slider = dtype is float
+
+    def _check_values_are_float(self, value, min, max, step):
+        return any(isinstance(n, float) for n in (value, min, max, step))
+
+    def get(self, attr):
+        return getattr(self, attr)
diff --git a/mesa/experimental/__init__.py b/mesa/experimental/__init__.py
index e37cdb04..753b4bd7 100644
--- a/mesa/experimental/__init__.py
+++ b/mesa/experimental/__init__.py
@@ -1,3 +1,5 @@
 from mesa.experimental import cell_space
 
-__all__ = ["cell_space"]
+from .solara_viz import JupyterViz, Slider, SolaraViz, make_text
+
+__all__ = ["cell_space", "JupyterViz", "SolaraViz", "make_text", "Slider"]
diff --git a/mesa/experimental/cell_space/__init__.py b/mesa/experimental/cell_space/__init__.py
index dce296ae..8db71025 100644
--- a/mesa/experimental/cell_space/__init__.py
+++ b/mesa/experimental/cell_space/__init__.py
@@ -9,6 +9,7 @@ from mesa.experimental.cell_space.grid import (
     OrthogonalVonNeumannGrid,
 )
 from mesa.experimental.cell_space.network import Network
+from mesa.experimental.cell_space.voronoi import VoronoiGrid
 
 __all__ = [
     "CellCollection",
@@ -20,4 +21,5 @@ __all__ = [
     "OrthogonalMooreGrid",
     "OrthogonalVonNeumannGrid",
     "Network",
+    "VoronoiGrid",
 ]
diff --git a/mesa/experimental/cell_space/cell_agent.py b/mesa/experimental/cell_space/cell_agent.py
index abc5155a..5f1cca5c 100644
--- a/mesa/experimental/cell_space/cell_agent.py
+++ b/mesa/experimental/cell_space/cell_agent.py
@@ -19,7 +19,7 @@ class CellAgent(Agent):
         cell: (Cell | None): the cell which the agent occupies
     """
 
-    def __init__(self, unique_id: int, model: Model) -> None:
+    def __init__(self, model: Model) -> None:
         """
         Create a new agent.
 
@@ -27,7 +27,7 @@ class CellAgent(Agent):
             unique_id (int): A unique identifier for this agent.
             model (Model): The model instance in which the agent exists.
         """
-        super().__init__(unique_id, model)
+        super().__init__(model)
         self.cell: Cell | None = None
 
     def move_to(self, cell) -> None:
diff --git a/mesa/experimental/cell_space/voronoi.py b/mesa/experimental/cell_space/voronoi.py
new file mode 100644
index 00000000..4395ca4e
--- /dev/null
+++ b/mesa/experimental/cell_space/voronoi.py
@@ -0,0 +1,264 @@
+from collections.abc import Sequence
+from itertools import combinations
+from random import Random
+
+import numpy as np
+
+from mesa.experimental.cell_space.cell import Cell
+from mesa.experimental.cell_space.discrete_space import DiscreteSpace
+
+
+class Delaunay:
+    """
+    Class to compute a Delaunay triangulation in 2D
+    ref: http://github.com/jmespadero/pyDelaunay2D
+    """
+
+    def __init__(self, center: tuple = (0, 0), radius: int = 9999) -> None:
+        """
+        Init and create a new frame to contain the triangulation
+        center: Optional position for the center of the frame. Default (0,0)
+        radius: Optional distance from corners to the center.
+        """
+        center = np.asarray(center)
+        # Create coordinates for the corners of the frame
+        self.coords = [
+            center + radius * np.array((-1, -1)),
+            center + radius * np.array((+1, -1)),
+            center + radius * np.array((+1, +1)),
+            center + radius * np.array((-1, +1)),
+        ]
+
+        # Create two dicts to store triangle neighbours and circumcircles.
+        self.triangles = {}
+        self.circles = {}
+
+        # Create two CCW triangles for the frame
+        triangle1 = (0, 1, 3)
+        triangle2 = (2, 3, 1)
+        self.triangles[triangle1] = [triangle2, None, None]
+        self.triangles[triangle2] = [triangle1, None, None]
+
+        # Compute circumcenters and circumradius for each triangle
+        for t in self.triangles:
+            self.circles[t] = self._circumcenter(t)
+
+    def _circumcenter(self, triangle: list) -> tuple:
+        """
+        Compute circumcenter and circumradius of a triangle in 2D.
+        """
+        points = np.asarray([self.coords[v] for v in triangle])
+        points2 = np.dot(points, points.T)
+        a = np.bmat([[2 * points2, [[1], [1], [1]]], [[[1, 1, 1, 0]]]])
+
+        b = np.hstack((np.sum(points * points, axis=1), [1]))
+        x = np.linalg.solve(a, b)
+        bary_coords = x[:-1]
+        center = np.dot(bary_coords, points)
+
+        radius = np.sum(np.square(points[0] - center))  # squared distance
+        return (center, radius)
+
+    def _in_circle(self, triangle: list, point: list) -> bool:
+        """
+        Check if point p is inside of precomputed circumcircle of triangle.
+        """
+        center, radius = self.circles[triangle]
+        return np.sum(np.square(center - point)) <= radius
+
+    def add_point(self, point: Sequence) -> None:
+        """
+        Add a point to the current DT, and refine it using Bowyer-Watson.
+        """
+        point_index = len(self.coords)
+        self.coords.append(np.asarray(point))
+
+        bad_triangles = []
+        for triangle in self.triangles:
+            if self._in_circle(triangle, point):
+                bad_triangles.append(triangle)
+
+        boundary = []
+        triangle = bad_triangles[0]
+        edge = 0
+
+        while True:
+            opposite_triangle = self.triangles[triangle][edge]
+            if opposite_triangle not in bad_triangles:
+                boundary.append(
+                    (
+                        triangle[(edge + 1) % 3],
+                        triangle[(edge - 1) % 3],
+                        opposite_triangle,
+                    )
+                )
+                edge = (edge + 1) % 3
+                if boundary[0][0] == boundary[-1][1]:
+                    break
+            else:
+                edge = (self.triangles[opposite_triangle].index(triangle) + 1) % 3
+                triangle = opposite_triangle
+
+        for triangle in bad_triangles:
+            del self.triangles[triangle]
+            del self.circles[triangle]
+
+        new_triangles = []
+        for e0, e1, opposite_triangle in boundary:
+            triangle = (point_index, e0, e1)
+            self.circles[triangle] = self._circumcenter(triangle)
+            self.triangles[triangle] = [opposite_triangle, None, None]
+            if opposite_triangle:
+                for i, neighbor in enumerate(self.triangles[opposite_triangle]):
+                    if neighbor and e1 in neighbor and e0 in neighbor:
+                        self.triangles[opposite_triangle][i] = triangle
+
+            new_triangles.append(triangle)
+
+        n = len(new_triangles)
+        for i, triangle in enumerate(new_triangles):
+            self.triangles[triangle][1] = new_triangles[(i + 1) % n]  # next
+            self.triangles[triangle][2] = new_triangles[(i - 1) % n]  # previous
+
+    def export_triangles(self) -> list:
+        """
+        Export the current list of Delaunay triangles
+        """
+        triangles_list = [
+            (a - 4, b - 4, c - 4)
+            for (a, b, c) in self.triangles
+            if a > 3 and b > 3 and c > 3
+        ]
+        return triangles_list
+
+    def export_voronoi_regions(self):
+        """
+        Export coordinates and regions of Voronoi diagram as indexed data.
+        """
+        use_vertex = {i: [] for i in range(len(self.coords))}
+        vor_coors = []
+        index = {}
+        for triangle_index, (a, b, c) in enumerate(sorted(self.triangles)):
+            vor_coors.append(self.circles[(a, b, c)][0])
+            use_vertex[a] += [(b, c, a)]
+            use_vertex[b] += [(c, a, b)]
+            use_vertex[c] += [(a, b, c)]
+
+            index[(a, b, c)] = triangle_index
+            index[(c, a, b)] = triangle_index
+            index[(b, c, a)] = triangle_index
+
+        regions = {}
+        for i in range(4, len(self.coords)):
+            vertex = use_vertex[i][0][0]
+            region = []
+            for _ in range(len(use_vertex[i])):
+                triangle = next(
+                    triangle for triangle in use_vertex[i] if triangle[0] == vertex
+                )
+                region.append(index[triangle])
+                vertex = triangle[1]
+            regions[i - 4] = region
+
+        return vor_coors, regions
+
+
+def round_float(x: float) -> int:
+    return int(x * 500)
+
+
+class VoronoiGrid(DiscreteSpace):
+    triangulation: Delaunay
+    voronoi_coordinates: list
+    regions: list
+
+    def __init__(
+        self,
+        centroids_coordinates: Sequence[Sequence[float]],
+        capacity: float | None = None,
+        random: Random | None = None,
+        cell_klass: type[Cell] = Cell,
+        capacity_function: callable = round_float,
+        cell_coloring_property: str | None = None,
+    ) -> None:
+        """
+        A Voronoi Tessellation Grid.
+
+        Given a set of points, this class creates a grid where a cell is centered in each point,
+        its neighbors are given by Voronoi Tessellation cells neighbors
+        and the capacity by the polygon area.
+
+        Args:
+            centroids_coordinates: coordinates of centroids to build the tessellation space
+            capacity (int) : capacity of the cells in the discrete space
+            random (Random): random number generator
+            CellKlass (type[Cell]): type of cell class
+            capacity_function (Callable): function to compute (int) capacity according to (float) area
+            cell_coloring_property (str): voronoi visualization polygon fill property
+        """
+        super().__init__(capacity=capacity, random=random, cell_klass=cell_klass)
+        self.centroids_coordinates = centroids_coordinates
+        self._validate_parameters()
+
+        self._cells = {
+            i: cell_klass(self.centroids_coordinates[i], capacity, random=self.random)
+            for i in range(len(self.centroids_coordinates))
+        }
+
+        self.regions = None
+        self.triangulation = None
+        self.voronoi_coordinates = None
+        self.capacity_function = capacity_function
+        self.cell_coloring_property = cell_coloring_property
+
+        self._connect_cells()
+        self._build_cell_polygons()
+
+    def _connect_cells(self) -> None:
+        """
+        Connect cells to neighbors based on given centroids and using Delaunay Triangulation
+        """
+        self.triangulation = Delaunay()
+        for centroid in self.centroids_coordinates:
+            self.triangulation.add_point(centroid)
+
+        for point in self.triangulation.export_triangles():
+            for i, j in combinations(point, 2):
+                self._cells[i].connect(self._cells[j])
+                self._cells[j].connect(self._cells[i])
+
+    def _validate_parameters(self) -> None:
+        if self.capacity is not None and not isinstance(self.capacity, float | int):
+            raise ValueError("Capacity must be a number or None.")
+        if not isinstance(self.centroids_coordinates, Sequence) or not isinstance(
+            self.centroids_coordinates[0], Sequence
+        ):
+            raise ValueError("Centroids should be a list of lists")
+        dimension_1 = len(self.centroids_coordinates[0])
+        for coordinate in self.centroids_coordinates:
+            if dimension_1 != len(coordinate):
+                raise ValueError("Centroid coordinates should be a homogeneous array")
+
+    def _get_voronoi_regions(self) -> tuple:
+        if self.voronoi_coordinates is None or self.regions is None:
+            self.voronoi_coordinates, self.regions = (
+                self.triangulation.export_voronoi_regions()
+            )
+        return self.voronoi_coordinates, self.regions
+
+    @staticmethod
+    def _compute_polygon_area(polygon: list) -> float:
+        polygon = np.array(polygon)
+        x = polygon[:, 0]
+        y = polygon[:, 1]
+        return 0.5 * np.abs(np.dot(x, np.roll(y, 1)) - np.dot(y, np.roll(x, 1)))
+
+    def _build_cell_polygons(self):
+        coordinates, regions = self._get_voronoi_regions()
+        for region in regions:
+            polygon = [coordinates[i] for i in regions[region]]
+            self._cells[region].properties["polygon"] = polygon
+            polygon_area = self._compute_polygon_area(polygon)
+            self._cells[region].properties["area"] = polygon_area
+            self._cells[region].capacity = self.capacity_function(polygon_area)
+            self._cells[region].properties[self.cell_coloring_property] = 0
diff --git a/mesa/experimental/components/altair.py b/mesa/experimental/components/altair.py
new file mode 100644
index 00000000..f9d1a81c
--- /dev/null
+++ b/mesa/experimental/components/altair.py
@@ -0,0 +1,71 @@
+import contextlib
+
+import solara
+
+with contextlib.suppress(ImportError):
+    import altair as alt
+
+
+@solara.component
+def SpaceAltair(model, agent_portrayal, dependencies: list[any] | None = None):
+    space = getattr(model, "grid", None)
+    if space is None:
+        # Sometimes the space is defined as model.space instead of model.grid
+        space = model.space
+    chart = _draw_grid(space, agent_portrayal)
+    solara.FigureAltair(chart)
+
+
+def _draw_grid(space, agent_portrayal):
+    def portray(g):
+        all_agent_data = []
+        for content, (x, y) in g.coord_iter():
+            if not content:
+                continue
+            if not hasattr(content, "__iter__"):
+                # Is a single grid
+                content = [content]  # noqa: PLW2901
+            for agent in content:
+                # use all data from agent portrayal, and add x,y coordinates
+                agent_data = agent_portrayal(agent)
+                agent_data["x"] = x
+                agent_data["y"] = y
+                all_agent_data.append(agent_data)
+        return all_agent_data
+
+    all_agent_data = portray(space)
+    invalid_tooltips = ["color", "size", "x", "y"]
+
+    encoding_dict = {
+        # no x-axis label
+        "x": alt.X("x", axis=None, type="ordinal"),
+        # no y-axis label
+        "y": alt.Y("y", axis=None, type="ordinal"),
+        "tooltip": [
+            alt.Tooltip(key, type=alt.utils.infer_vegalite_type([value]))
+            for key, value in all_agent_data[0].items()
+            if key not in invalid_tooltips
+        ],
+    }
+    has_color = "color" in all_agent_data[0]
+    if has_color:
+        encoding_dict["color"] = alt.Color("color", type="nominal")
+    has_size = "size" in all_agent_data[0]
+    if has_size:
+        encoding_dict["size"] = alt.Size("size", type="quantitative")
+
+    chart = (
+        alt.Chart(
+            alt.Data(values=all_agent_data), encoding=alt.Encoding(**encoding_dict)
+        )
+        .mark_point(filled=True)
+        .properties(width=280, height=280)
+        # .configure_view(strokeOpacity=0)  # hide grid/chart lines
+    )
+    # This is the default value for the marker size, which auto-scales
+    # according to the grid area.
+    if not has_size:
+        length = min(space.width, space.height)
+        chart = chart.mark_point(size=30000 / length**2, filled=True)
+
+    return chart
diff --git a/mesa/experimental/components/matplotlib.py b/mesa/experimental/components/matplotlib.py
new file mode 100644
index 00000000..c35fe97b
--- /dev/null
+++ b/mesa/experimental/components/matplotlib.py
@@ -0,0 +1,224 @@
+from collections import defaultdict
+
+import networkx as nx
+import solara
+from matplotlib.figure import Figure
+from matplotlib.ticker import MaxNLocator
+
+import mesa
+from mesa.experimental.cell_space import VoronoiGrid
+
+
+@solara.component
+def SpaceMatplotlib(model, agent_portrayal, dependencies: list[any] | None = None):
+    space_fig = Figure()
+    space_ax = space_fig.subplots()
+    space = getattr(model, "grid", None)
+    if space is None:
+        # Sometimes the space is defined as model.space instead of model.grid
+        space = model.space
+    if isinstance(space, mesa.space.NetworkGrid):
+        _draw_network_grid(space, space_ax, agent_portrayal)
+    elif isinstance(space, mesa.space.ContinuousSpace):
+        _draw_continuous_space(space, space_ax, agent_portrayal)
+    elif isinstance(space, VoronoiGrid):
+        _draw_voronoi(space, space_ax, agent_portrayal)
+    else:
+        _draw_grid(space, space_ax, agent_portrayal)
+    solara.FigureMatplotlib(space_fig, format="png", dependencies=dependencies)
+
+
+# matplotlib scatter does not allow for multiple shapes in one call
+def _split_and_scatter(portray_data, space_ax):
+    grouped_data = defaultdict(lambda: {"x": [], "y": [], "s": [], "c": []})
+
+    # Extract data from the dictionary
+    x = portray_data["x"]
+    y = portray_data["y"]
+    s = portray_data["s"]
+    c = portray_data["c"]
+    m = portray_data["m"]
+
+    if not (len(x) == len(y) == len(s) == len(c) == len(m)):
+        raise ValueError(
+            "Length mismatch in portrayal data lists: "
+            f"x: {len(x)}, y: {len(y)}, size: {len(s)}, "
+            f"color: {len(c)}, marker: {len(m)}"
+        )
+
+    # Group the data by marker
+    for i in range(len(x)):
+        marker = m[i]
+        grouped_data[marker]["x"].append(x[i])
+        grouped_data[marker]["y"].append(y[i])
+        grouped_data[marker]["s"].append(s[i])
+        grouped_data[marker]["c"].append(c[i])
+
+    # Plot each group with the same marker
+    for marker, data in grouped_data.items():
+        space_ax.scatter(data["x"], data["y"], s=data["s"], c=data["c"], marker=marker)
+
+
+def _draw_grid(space, space_ax, agent_portrayal):
+    def portray(g):
+        x = []
+        y = []
+        s = []  # size
+        c = []  # color
+        m = []  # shape
+        for i in range(g.width):
+            for j in range(g.height):
+                content = g._grid[i][j]
+                if not content:
+                    continue
+                if not hasattr(content, "__iter__"):
+                    # Is a single grid
+                    content = [content]
+                for agent in content:
+                    data = agent_portrayal(agent)
+                    x.append(i)
+                    y.append(j)
+
+                    # This is the default value for the marker size, which auto-scales
+                    # according to the grid area.
+                    default_size = (180 / max(g.width, g.height)) ** 2
+                    # establishing a default prevents misalignment if some agents are not given size, color, etc.
+                    size = data.get("size", default_size)
+                    s.append(size)
+                    color = data.get("color", "tab:blue")
+                    c.append(color)
+                    mark = data.get("shape", "o")
+                    m.append(mark)
+        out = {"x": x, "y": y, "s": s, "c": c, "m": m}
+        return out
+
+    space_ax.set_xlim(-1, space.width)
+    space_ax.set_ylim(-1, space.height)
+    _split_and_scatter(portray(space), space_ax)
+
+
+def _draw_network_grid(space, space_ax, agent_portrayal):
+    graph = space.G
+    pos = nx.spring_layout(graph, seed=0)
+    nx.draw(
+        graph,
+        ax=space_ax,
+        pos=pos,
+        **agent_portrayal(graph),
+    )
+
+
+def _draw_continuous_space(space, space_ax, agent_portrayal):
+    def portray(space):
+        x = []
+        y = []
+        s = []  # size
+        c = []  # color
+        m = []  # shape
+        for agent in space._agent_to_index:
+            data = agent_portrayal(agent)
+            _x, _y = agent.pos
+            x.append(_x)
+            y.append(_y)
+
+            # This is matplotlib's default marker size
+            default_size = 20
+            # establishing a default prevents misalignment if some agents are not given size, color, etc.
+            size = data.get("size", default_size)
+            s.append(size)
+            color = data.get("color", "tab:blue")
+            c.append(color)
+            mark = data.get("shape", "o")
+            m.append(mark)
+        out = {"x": x, "y": y, "s": s, "c": c, "m": m}
+        return out
+
+    # Determine border style based on space.torus
+    border_style = "solid" if not space.torus else (0, (5, 10))
+
+    # Set the border of the plot
+    for spine in space_ax.spines.values():
+        spine.set_linewidth(1.5)
+        spine.set_color("black")
+        spine.set_linestyle(border_style)
+
+    width = space.x_max - space.x_min
+    x_padding = width / 20
+    height = space.y_max - space.y_min
+    y_padding = height / 20
+    space_ax.set_xlim(space.x_min - x_padding, space.x_max + x_padding)
+    space_ax.set_ylim(space.y_min - y_padding, space.y_max + y_padding)
+
+    # Portray and scatter the agents in the space
+    _split_and_scatter(portray(space), space_ax)
+
+
+def _draw_voronoi(space, space_ax, agent_portrayal):
+    def portray(g):
+        x = []
+        y = []
+        s = []  # size
+        c = []  # color
+
+        for cell in g.all_cells:
+            for agent in cell.agents:
+                data = agent_portrayal(agent)
+                x.append(cell.coordinate[0])
+                y.append(cell.coordinate[1])
+                if "size" in data:
+                    s.append(data["size"])
+                if "color" in data:
+                    c.append(data["color"])
+        out = {"x": x, "y": y}
+        # This is the default value for the marker size, which auto-scales
+        # according to the grid area.
+        out["s"] = s
+        if len(c) > 0:
+            out["c"] = c
+
+        return out
+
+    x_list = [i[0] for i in space.centroids_coordinates]
+    y_list = [i[1] for i in space.centroids_coordinates]
+    x_max = max(x_list)
+    x_min = min(x_list)
+    y_max = max(y_list)
+    y_min = min(y_list)
+
+    width = x_max - x_min
+    x_padding = width / 20
+    height = y_max - y_min
+    y_padding = height / 20
+    space_ax.set_xlim(x_min - x_padding, x_max + x_padding)
+    space_ax.set_ylim(y_min - y_padding, y_max + y_padding)
+    space_ax.scatter(**portray(space))
+
+    for cell in space.all_cells:
+        polygon = cell.properties["polygon"]
+        space_ax.fill(
+            *zip(*polygon),
+            alpha=min(1, cell.properties[space.cell_coloring_property]),
+            c="red",
+        )  # Plot filled polygon
+        space_ax.plot(*zip(*polygon), color="black")  # Plot polygon edges in red
+
+
+@solara.component
+def PlotMatplotlib(model, measure, dependencies: list[any] | None = None):
+    fig = Figure()
+    ax = fig.subplots()
+    df = model.datacollector.get_model_vars_dataframe()
+    if isinstance(measure, str):
+        ax.plot(df.loc[:, measure])
+        ax.set_ylabel(measure)
+    elif isinstance(measure, dict):
+        for m, color in measure.items():
+            ax.plot(df.loc[:, m], label=m, color=color)
+        fig.legend()
+    elif isinstance(measure, list | tuple):
+        for m in measure:
+            ax.plot(df.loc[:, m], label=m)
+        fig.legend()
+    # Set integer x axis
+    ax.xaxis.set_major_locator(MaxNLocator(integer=True))
+    solara.FigureMatplotlib(fig, dependencies=dependencies)
diff --git a/mesa/experimental/devs/examples/epstein_civil_violence.py b/mesa/experimental/devs/examples/epstein_civil_violence.py
index 9a7314e0..c976bb73 100644
--- a/mesa/experimental/devs/examples/epstein_civil_violence.py
+++ b/mesa/experimental/devs/examples/epstein_civil_violence.py
@@ -7,8 +7,8 @@ from mesa.space import SingleGrid
 
 
 class EpsteinAgent(Agent):
-    def __init__(self, unique_id, model, vision, movement):
-        super().__init__(unique_id, model)
+    def __init__(self, model, vision, movement):
+        super().__init__(model)
         self.vision = vision
         self.movement = movement
 
@@ -46,7 +46,6 @@ class Citizen(EpsteinAgent):
 
     def __init__(
         self,
-        unique_id,
         model,
         vision,
         movement,
@@ -59,7 +58,6 @@ class Citizen(EpsteinAgent):
         """
         Create a new Citizen.
         Args:
-            unique_id: unique int
             model : model instance
             hardship: Agent's 'perceived hardship (i.e., physical or economic
                 privation).' Exogenous, drawn from U(0,1).
@@ -71,7 +69,7 @@ class Citizen(EpsteinAgent):
             vision: number of cells in each direction (N, S, E and W) that
                 agent can inspect. Exogenous.
         """
-        super().__init__(unique_id, model, vision, movement)
+        super().__init__(model, vision, movement)
         self.hardship = hardship
         self.regime_legitimacy = regime_legitimacy
         self.risk_aversion = risk_aversion
@@ -144,8 +142,8 @@ class Cop(EpsteinAgent):
             able to inspect
     """
 
-    def __init__(self, unique_id, model, vision, movement, max_jail_term):
-        super().__init__(unique_id, model, vision, movement)
+    def __init__(self, model, vision, movement, max_jail_term):
+        super().__init__(model, vision, movement)
         self.max_jail_term = max_jail_term
 
     def step(self):
@@ -236,7 +234,6 @@ class EpsteinCivilViolence(Model):
         for _, pos in self.grid.coord_iter():
             if self.random.random() < self.cop_density:
                 agent = Cop(
-                    self.next_id(),
                     self,
                     cop_vision,
                     movement,
@@ -244,7 +241,6 @@ class EpsteinCivilViolence(Model):
                 )
             elif self.random.random() < (self.cop_density + self.citizen_density):
                 agent = Citizen(
-                    self.next_id(),
                     self,
                     citizen_vision,
                     movement,
@@ -270,4 +266,4 @@ if __name__ == "__main__":
 
     simulator.setup(model)
 
-    simulator.run(time_delta=100)
+    simulator.run_for(time_delta=100)
diff --git a/mesa/experimental/devs/examples/wolf_sheep.py b/mesa/experimental/devs/examples/wolf_sheep.py
index 9fa6b3d9..cd81cb2f 100644
--- a/mesa/experimental/devs/examples/wolf_sheep.py
+++ b/mesa/experimental/devs/examples/wolf_sheep.py
@@ -14,8 +14,8 @@ from mesa.experimental.devs.simulator import ABMSimulator
 
 
 class Animal(mesa.Agent):
-    def __init__(self, unique_id, model, moore, energy, p_reproduce, energy_from_food):
-        super().__init__(unique_id, model)
+    def __init__(self, model, moore, energy, p_reproduce, energy_from_food):
+        super().__init__(model)
         self.energy = energy
         self.p_reproduce = p_reproduce
         self.energy_from_food = energy_from_food
@@ -30,7 +30,6 @@ class Animal(mesa.Agent):
     def spawn_offspring(self):
         self.energy /= 2
         offspring = self.__class__(
-            self.model.next_id(),
             self.model,
             self.moore,
             self.energy,
@@ -109,7 +108,7 @@ class GrassPatch(mesa.Agent):
                 function_args=[self, "fully_grown", True],
             )
 
-    def __init__(self, unique_id, model, fully_grown, countdown, grass_regrowth_time):
+    def __init__(self, model, fully_grown, countdown, grass_regrowth_time):
         """
         Creates a new patch of grass
 
@@ -117,7 +116,7 @@ class GrassPatch(mesa.Agent):
             grown: (boolean) Whether the patch of grass is fully grown or not
             countdown: Time for the patch of grass to be fully grown again
         """
-        super().__init__(unique_id, model)
+        super().__init__(model)
         self._fully_grown = fully_grown
         self.grass_regrowth_time = grass_regrowth_time
 
@@ -191,7 +190,6 @@ class WolfSheep(mesa.Model):
             )
             energy = self.random.randrange(2 * sheep_gain_from_food)
             sheep = Sheep(
-                self.next_id(),
                 self,
                 moore,
                 energy,
@@ -208,7 +206,6 @@ class WolfSheep(mesa.Model):
             )
             energy = self.random.randrange(2 * wolf_gain_from_food)
             wolf = Wolf(
-                self.next_id(),
                 self,
                 moore,
                 energy,
@@ -225,14 +222,12 @@ class WolfSheep(mesa.Model):
                 countdown = grass_regrowth_time
             else:
                 countdown = self.random.randrange(grass_regrowth_time)
-            patch = GrassPatch(
-                self.next_id(), self, fully_grown, countdown, grass_regrowth_time
-            )
+            patch = GrassPatch(self, fully_grown, countdown, grass_regrowth_time)
             self.grid.place_agent(patch, pos)
 
     def step(self):
-        self.get_agents_of_type(Sheep).shuffle(inplace=True).do("step")
-        self.get_agents_of_type(Wolf).shuffle(inplace=True).do("step")
+        self.agents_by_type[Sheep].shuffle(inplace=True).do("step")
+        self.agents_by_type[Wolf].shuffle(inplace=True).do("step")
 
 
 if __name__ == "__main__":
diff --git a/mesa/experimental/solara_viz.py b/mesa/experimental/solara_viz.py
new file mode 100644
index 00000000..28f3a3c6
--- /dev/null
+++ b/mesa/experimental/solara_viz.py
@@ -0,0 +1,462 @@
+"""
+Mesa visualization module for creating interactive model visualizations.
+
+This module provides components to create browser- and Jupyter notebook-based visualizations of
+Mesa models, allowing users to watch models run step-by-step and interact with model parameters.
+
+Key features:
+    - SolaraViz: Main component for creating visualizations, supporting grid displays and plots
+    - ModelController: Handles model execution controls (step, play, pause, reset)
+    - UserInputs: Generates UI elements for adjusting model parameters
+    - Card: Renders individual visualization elements (space, measures)
+
+The module uses Solara for rendering in Jupyter notebooks or as standalone web applications.
+It supports various types of visualizations including matplotlib plots, agent grids, and
+custom visualization components.
+
+Usage:
+    1. Define an agent_portrayal function to specify how agents should be displayed
+    2. Set up model_params to define adjustable parameters
+    3. Create a SolaraViz instance with your model, parameters, and desired measures
+    4. Display the visualization in a Jupyter notebook or run as a Solara app
+
+See the Visualization Tutorial and example models for more details.
+"""
+
+import threading
+
+import reacton.ipywidgets as widgets
+import solara
+from solara.alias import rv
+
+import mesa.experimental.components.altair as components_altair
+import mesa.experimental.components.matplotlib as components_matplotlib
+from mesa.experimental.UserParam import Slider
+
+
+# TODO: Turn this function into a Solara component once the current_step.value
+# dependency is passed to measure()
+def Card(
+    model, measures, agent_portrayal, space_drawer, dependencies, color, layout_type
+):
+    """
+    Create a card component for visualizing model space or measures.
+
+    Args:
+        model: The Mesa model instance
+        measures: List of measures to be plotted
+        agent_portrayal: Function to define agent appearance
+        space_drawer: Method to render agent space
+        dependencies: List of dependencies for updating the visualization
+        color: Background color of the card
+        layout_type: Type of layout (Space or Measure)
+
+    Returns:
+        rv.Card: A card component containing the visualization
+    """
+    with rv.Card(
+        style_=f"background-color: {color}; width: 100%; height: 100%"
+    ) as main:
+        if "Space" in layout_type:
+            rv.CardTitle(children=["Space"])
+            if space_drawer == "default":
+                # draw with the default implementation
+                components_matplotlib.SpaceMatplotlib(
+                    model, agent_portrayal, dependencies=dependencies
+                )
+            elif space_drawer == "altair":
+                components_altair.SpaceAltair(
+                    model, agent_portrayal, dependencies=dependencies
+                )
+            elif space_drawer:
+                # if specified, draw agent space with an alternate renderer
+                space_drawer(model, agent_portrayal, dependencies=dependencies)
+        elif "Measure" in layout_type:
+            rv.CardTitle(children=["Measure"])
+            measure = measures[layout_type["Measure"]]
+            if callable(measure):
+                # Is a custom object
+                measure(model)
+            else:
+                components_matplotlib.PlotMatplotlib(
+                    model, measure, dependencies=dependencies
+                )
+    return main
+
+
+@solara.component
+def SolaraViz(
+    model_class,
+    model_params,
+    measures=None,
+    name=None,
+    agent_portrayal=None,
+    space_drawer="default",
+    play_interval=150,
+    seed=None,
+):
+    """
+    Initialize a component to visualize a model.
+
+    Args:
+        model_class: Class of the model to instantiate
+        model_params: Parameters for initializing the model
+        measures: List of callables or data attributes to plot
+        name: Name for display
+        agent_portrayal: Options for rendering agents (dictionary);
+            Default drawer supports custom `"size"`, `"color"`, and `"shape"`.
+        space_drawer: Method to render the agent space for
+            the model; default implementation is the `SpaceMatplotlib` component;
+            simulations with no space to visualize should
+            specify `space_drawer=False`
+        play_interval: Play interval (default: 150)
+        seed: The random seed used to initialize the model
+    """
+    if name is None:
+        name = model_class.__name__
+
+    current_step = solara.use_reactive(0)
+
+    # 1. Set up model parameters
+    reactive_seed = solara.use_reactive(0)
+    user_params, fixed_params = split_model_params(model_params)
+    model_parameters, set_model_parameters = solara.use_state(
+        {**fixed_params, **{k: v.get("value") for k, v in user_params.items()}}
+    )
+
+    # 2. Set up Model
+    def make_model():
+        """Create a new model instance with current parameters and seed."""
+        model = model_class.__new__(
+            model_class, **model_parameters, seed=reactive_seed.value
+        )
+        model.__init__(**model_parameters)
+        current_step.value = 0
+        return model
+
+    reset_counter = solara.use_reactive(0)
+    model = solara.use_memo(
+        make_model,
+        dependencies=[
+            *list(model_parameters.values()),
+            reset_counter.value,
+            reactive_seed.value,
+        ],
+    )
+
+    def handle_change_model_params(name: str, value: any):
+        """Update model parameters when user input changes."""
+        set_model_parameters({**model_parameters, name: value})
+
+    # 3. Set up UI
+
+    with solara.AppBar():
+        solara.AppBarTitle(name)
+
+    # render layout and plot
+    def do_reseed():
+        """Update the random seed for the model."""
+        reactive_seed.value = model.random.random()
+
+    dependencies = [
+        *list(model_parameters.values()),
+        current_step.value,
+        reactive_seed.value,
+    ]
+
+    # if space drawer is disabled, do not include it
+    layout_types = [{"Space": "default"}] if space_drawer else []
+
+    if measures:
+        layout_types += [{"Measure": elem} for elem in range(len(measures))]
+
+    grid_layout_initial = make_initial_grid_layout(layout_types=layout_types)
+    grid_layout, set_grid_layout = solara.use_state(grid_layout_initial)
+
+    with solara.Sidebar():
+        with solara.Card("Controls", margin=1, elevation=2):
+            solara.InputText(
+                label="Seed",
+                value=reactive_seed,
+                continuous_update=True,
+            )
+            UserInputs(user_params, on_change=handle_change_model_params)
+            ModelController(model, play_interval, current_step, reset_counter)
+            solara.Button(label="Reseed", color="primary", on_click=do_reseed)
+        with solara.Card("Information", margin=1, elevation=2):
+            solara.Markdown(md_text=f"Step - {current_step}")
+
+    items = [
+        Card(
+            model,
+            measures,
+            agent_portrayal,
+            space_drawer,
+            dependencies,
+            color="white",
+            layout_type=layout_types[i],
+        )
+        for i in range(len(layout_types))
+    ]
+    solara.GridDraggable(
+        items=items,
+        grid_layout=grid_layout,
+        resizable=True,
+        draggable=True,
+        on_grid_layout=set_grid_layout,
+    )
+
+
+JupyterViz = SolaraViz
+
+
+@solara.component
+def ModelController(model, play_interval, current_step, reset_counter):
+    """
+    Create controls for model execution (step, play, pause, reset).
+
+    Args:
+        model: The model being visualized
+        play_interval: Interval between steps during play
+        current_step: Reactive value for the current step
+        reset_counter: Counter to trigger model reset
+    """
+    playing = solara.use_reactive(False)
+    thread = solara.use_reactive(None)
+    # We track the previous step to detect if user resets the model via
+    # clicking the reset button or changing the parameters. If previous_step >
+    # current_step, it means a model reset happens while the simulation is
+    # still playing.
+    previous_step = solara.use_reactive(0)
+
+    def on_value_play(change):
+        """Handle play/pause state changes."""
+        if previous_step.value > current_step.value and current_step.value == 0:
+            # We add extra checks for current_step.value == 0, just to be sure.
+            # We automatically stop the playing if a model is reset.
+            playing.value = False
+        elif model.running:
+            do_step()
+        else:
+            playing.value = False
+
+    def do_step():
+        """Advance the model by one step."""
+        model.step()
+        previous_step.value = current_step.value
+        current_step.value = model.steps
+
+    def do_play():
+        """Run the model continuously."""
+        model.running = True
+        while model.running:
+            do_step()
+
+    def threaded_do_play():
+        """Start a new thread for continuous model execution."""
+        if thread is not None and thread.is_alive():
+            return
+        thread.value = threading.Thread(target=do_play)
+        thread.start()
+
+    def do_pause():
+        """Pause the model execution."""
+        if (thread is None) or (not thread.is_alive()):
+            return
+        model.running = False
+        thread.join()
+
+    def do_reset():
+        """Reset the model."""
+        reset_counter.value += 1
+
+    def do_set_playing(value):
+        """Set the playing state."""
+        if current_step.value == 0:
+            # This means the model has been recreated, and the step resets to
+            # 0. We want to avoid triggering the playing.value = False in the
+            # on_value_play function.
+            previous_step.value = current_step.value
+        playing.set(value)
+
+    with solara.Row():
+        solara.Button(label="Step", color="primary", on_click=do_step)
+        # This style is necessary so that the play widget has almost the same
+        # height as typical Solara buttons.
+        solara.Style(
+            """
+        .widget-play {
+            height: 35px;
+        }
+        .widget-play button {
+            color: white;
+            background-color: #1976D2;  // Solara blue color
+        }
+        """
+        )
+        widgets.Play(
+            value=0,
+            interval=play_interval,
+            repeat=True,
+            show_repeat=False,
+            on_value=on_value_play,
+            playing=playing.value,
+            on_playing=do_set_playing,
+        )
+        solara.Button(label="Reset", color="primary", on_click=do_reset)
+        # threaded_do_play is not used for now because it
+        # doesn't work in Google colab. We use
+        # ipywidgets.Play until it is fixed. The threading
+        # version is definite a much better implementation,
+        # if it works.
+        # solara.Button(label="▶", color="primary", on_click=viz.threaded_do_play)
+        # solara.Button(label="⏸︎", color="primary", on_click=viz.do_pause)
+        # solara.Button(label="Reset", color="primary", on_click=do_reset)
+
+
+def split_model_params(model_params):
+    """
+    Split model parameters into user-adjustable and fixed parameters.
+
+    Args:
+        model_params: Dictionary of all model parameters
+
+    Returns:
+        tuple: (user_adjustable_params, fixed_params)
+    """
+    model_params_input = {}
+    model_params_fixed = {}
+    for k, v in model_params.items():
+        if check_param_is_fixed(v):
+            model_params_fixed[k] = v
+        else:
+            model_params_input[k] = v
+    return model_params_input, model_params_fixed
+
+
+def check_param_is_fixed(param):
+    """
+    Check if a parameter is fixed (not user-adjustable).
+
+    Args:
+        param: Parameter to check
+
+    Returns:
+        bool: True if parameter is fixed, False otherwise
+    """
+    if isinstance(param, Slider):
+        return False
+    if not isinstance(param, dict):
+        return True
+    if "type" not in param:
+        return True
+
+
+@solara.component
+def UserInputs(user_params, on_change=None):
+    """
+    Initialize user inputs for configurable model parameters.
+    Currently supports :class:`solara.SliderInt`, :class:`solara.SliderFloat`,
+    :class:`solara.Select`, and :class:`solara.Checkbox`.
+
+    Args:
+        user_params: Dictionary with options for the input, including label,
+        min and max values, and other fields specific to the input type.
+        on_change: Function to be called with (name, value) when the value of an input changes.
+    """
+
+    for name, options in user_params.items():
+
+        def change_handler(value, name=name):
+            on_change(name, value)
+
+        if isinstance(options, Slider):
+            slider_class = (
+                solara.SliderFloat if options.is_float_slider else solara.SliderInt
+            )
+            slider_class(
+                options.label,
+                value=options.value,
+                on_value=change_handler,
+                min=options.min,
+                max=options.max,
+                step=options.step,
+            )
+            continue
+
+        # label for the input is "label" from options or name
+        label = options.get("label", name)
+        input_type = options.get("type")
+        if input_type == "SliderInt":
+            solara.SliderInt(
+                label,
+                value=options.get("value"),
+                on_value=change_handler,
+                min=options.get("min"),
+                max=options.get("max"),
+                step=options.get("step"),
+            )
+        elif input_type == "SliderFloat":
+            solara.SliderFloat(
+                label,
+                value=options.get("value"),
+                on_value=change_handler,
+                min=options.get("min"),
+                max=options.get("max"),
+                step=options.get("step"),
+            )
+        elif input_type == "Select":
+            solara.Select(
+                label,
+                value=options.get("value"),
+                on_value=change_handler,
+                values=options.get("values"),
+            )
+        elif input_type == "Checkbox":
+            solara.Checkbox(
+                label=label,
+                on_value=change_handler,
+                value=options.get("value"),
+            )
+        else:
+            raise ValueError(f"{input_type} is not a supported input type")
+
+
+def make_text(renderer):
+    """
+    Create a function that renders text using Markdown.
+
+    Args:
+        renderer: Function that takes a model and returns a string
+
+    Returns:
+        function: A function that renders the text as Markdown
+    """
+
+    def function(model):
+        solara.Markdown(renderer(model))
+
+    return function
+
+
+def make_initial_grid_layout(layout_types):
+    """
+    Create an initial grid layout for visualization components.
+
+    Args:
+        layout_types: List of layout types (Space or Measure)
+
+    Returns:
+        list: Initial grid layout configuration
+    """
+    return [
+        {
+            "i": i,
+            "w": 6,
+            "h": 10,
+            "moved": False,
+            "x": 6 * (i % 2),
+            "y": 16 * (i - i % 2),
+        }
+        for i in range(len(layout_types))
+    ]
diff --git a/mesa/model.py b/mesa/model.py
index bcd5902f..f859aea3 100644
--- a/mesa/model.py
+++ b/mesa/model.py
@@ -8,9 +8,8 @@ Core Objects: Model
 # Remove this __future__ import once the oldest supported Python is 3.10
 from __future__ import annotations
 
-import itertools
 import random
-from collections import defaultdict
+import warnings
 
 # mypy
 from typing import Any
@@ -18,8 +17,6 @@ from typing import Any
 from mesa.agent import Agent, AgentSet
 from mesa.datacollection import DataCollector
 
-TimeT = float | int
-
 
 class Model:
     """Base class for models in the Mesa ABM library.
@@ -31,21 +28,30 @@ class Model:
     Attributes:
         running: A boolean indicating if the model should continue running.
         schedule: An object to manage the order and execution of agent steps.
-        current_id: A counter for assigning unique IDs to agents.
-        agents_: A defaultdict mapping each agent type to a dict of its instances.
-                 This private attribute is used internally to manage agents.
 
     Properties:
-        agents: An AgentSet containing all agents in the model, generated from the _agents attribute.
+        agents: An AgentSet containing all agents in the model
         agent_types: A list of different agent types present in the model.
+        agents_by_type: A dictionary where the keys are agent types and the values are the corresponding AgentSets.
+        steps: An integer representing the number of steps the model has taken.
+               It increases automatically at the start of each step() call.
 
     Methods:
         get_agents_of_type: Returns an AgentSet of agents of the specified type.
+            Deprecated: Use agents_by_type[agenttype] instead.
         run_model: Runs the model's simulation until a defined end condition is reached.
         step: Executes a single step of the model's simulation process.
         next_id: Generates and returns the next unique identifier for an agent.
         reset_randomizer: Resets the model's random number generator with a new or existing seed.
         initialize_data_collector: Sets up the data collector for the model, requiring an initialized scheduler and agents.
+        register_agent : register an agent with the model
+        deregister_agent : remove an agent from the model
+
+    Notes:
+        Model.agents returns the AgentSet containing all agents registered with the model. Changing
+        the content of the AgentSet directly can result in strange behavior. If you want change the
+        composition of this AgentSet, ensure you operate on a copy.
+
     """
 
     def __new__(cls, *args: Any, **kwargs: Any) -> Any:
@@ -57,9 +63,6 @@ class Model:
             # advance.
             obj._seed = random.random()
         obj.random = random.Random(obj._seed)
-        # TODO: Remove these 2 lines just before Mesa 3.0
-        obj._steps = 0
-        obj._time = 0
         return obj
 
     def __init__(self, *args: Any, **kwargs: Any) -> None:
@@ -70,40 +73,117 @@ class Model:
 
         self.running = True
         self.schedule = None
-        self.current_id = 0
-        self.agents_: defaultdict[type, dict] = defaultdict(dict)
+        self.steps: int = 0
+
+        self._setup_agent_registration()
 
-        self._steps: int = 0
-        self._time: TimeT = 0  # the model's clock
+        # Wrap the user-defined step method
+        self._user_step = self.step
+        self.step = self._wrapped_step
+
+    def _wrapped_step(self, *args: Any, **kwargs: Any) -> None:
+        """Automatically increments time and steps after calling the user's step method."""
+        # Automatically increment time and step counters
+        self.steps += 1
+        # Call the original user-defined step method
+        self._user_step(*args, **kwargs)
+
+    def next_id(self) -> int:
+        warnings.warn(
+            "using model.next_id() is deprecated. Agents track their unique ID automatically",
+            DeprecationWarning,
+            stacklevel=2,
+        )
+        return 0
 
     @property
     def agents(self) -> AgentSet:
         """Provides an AgentSet of all agents in the model, combining agents from all types."""
-
-        if hasattr(self, "_agents"):
-            return self._agents
-        else:
-            all_agents = itertools.chain.from_iterable(self.agents_.values())
-            return AgentSet(all_agents, self)
+        return self._all_agents
 
     @agents.setter
     def agents(self, agents: Any) -> None:
         raise AttributeError(
-            "You are trying to set model.agents. In Mesa 3.0 and higher, this attribute will be "
+            "You are trying to set model.agents. In Mesa 3.0 and higher, this attribute is "
             "used by Mesa itself, so you cannot use it directly anymore."
             "Please adjust your code to use a different attribute name for custom agent storage."
         )
 
-        self._agents = agents
-
     @property
     def agent_types(self) -> list[type]:
-        """Return a list of different agent types."""
-        return list(self.agents_.keys())
+        """Return a list of all unique agent types registered with the model."""
+        return list(self._agents_by_type.keys())
+
+    @property
+    def agents_by_type(self) -> dict[type[Agent], AgentSet]:
+        """A dictionary where the keys are agent types and the values are the corresponding AgentSets."""
+        return self._agents_by_type
 
     def get_agents_of_type(self, agenttype: type[Agent]) -> AgentSet:
-        """Retrieves an AgentSet containing all agents of the specified type."""
-        return AgentSet(self.agents_[agenttype].keys(), self)
+        """Deprecated: Retrieves an AgentSet containing all agents of the specified type."""
+        warnings.warn(
+            f"Model.get_agents_of_type() is deprecated, please replace get_agents_of_type({agenttype})"
+            f"with the property agents_by_type[{agenttype}].",
+            DeprecationWarning,
+            stacklevel=2,
+        )
+        return self.agents_by_type[agenttype]
+
+    def _setup_agent_registration(self):
+        """helper method to initialize the agent registration datastructures"""
+        self._agents = {}  # the hard references to all agents in the model
+        self._agents_by_type: dict[
+            type[Agent], AgentSet
+        ] = {}  # a dict with an agentset for each class of agents
+        self._all_agents = AgentSet([], self)  # an agenset with all agents
+
+    def register_agent(self, agent):
+        """Register the agent with the model
+
+        Args:
+            agent: The agent to register.
+
+        Notes:
+            This method is called automatically by ``Agent.__init__``, so there is no need to use this
+            if you are subclassing Agent and calling its super in the ``__init__`` method.
+
+        """
+        if not hasattr(self, "_agents"):
+            self._setup_agent_registration()
+
+            warnings.warn(
+                "The Mesa Model class was not initialized. In the future, you need to explicitly initialize "
+                "the Model by calling super().__init__() on initialization.",
+                FutureWarning,
+                stacklevel=2,
+            )
+
+        self._agents[agent] = None
+
+        # because AgentSet requires model, we cannot use defaultdict
+        # tricks with a function won't work because model then cannot be pickled
+        try:
+            self._agents_by_type[type(agent)].add(agent)
+        except KeyError:
+            self._agents_by_type[type(agent)] = AgentSet(
+                [
+                    agent,
+                ],
+                self,
+            )
+
+        self._all_agents.add(agent)
+
+    def deregister_agent(self, agent):
+        """Deregister the agent with the model
+
+        Notes::
+        This method is called automatically by ``Agent.remove``
+
+        """
+        del self._agents[agent]
+        self._agents_by_type[type(agent)].remove(agent)
+        self._all_agents.remove(agent)
 
     def run_model(self) -> None:
         """Run the model until the end condition is reached. Overload as
@@ -115,16 +195,6 @@ class Model:
     def step(self) -> None:
         """A single step. Fill in here."""
 
-    def _advance_time(self, deltat: TimeT = 1):
-        """Increment the model's steps counter and clock."""
-        self._steps += 1
-        self._time += deltat
-
-    def next_id(self) -> int:
-        """Return the next unique ID for agents, increment current_id"""
-        self.current_id += 1
-        return self.current_id
-
     def reset_randomizer(self, seed: int | None = None) -> None:
         """Reset the model random number generator.
 
diff --git a/mesa/space.py b/mesa/space.py
index 39433cb3..c4d855d8 100644
--- a/mesa/space.py
+++ b/mesa/space.py
@@ -459,15 +459,21 @@ class _Grid:
             elif selection == "closest":
                 current_pos = agent.pos
                 # Find the closest position without sorting all positions
-                closest_pos = None
+                # TODO: See if this method can be optimized further
+                closest_pos = []
                 min_distance = float("inf")
                 agent.random.shuffle(pos)
                 for p in pos:
                     distance = self._distance_squared(p, current_pos)
                     if distance < min_distance:
                         min_distance = distance
-                        closest_pos = p
-                chosen_pos = closest_pos
+                        closest_pos.clear()
+                        closest_pos.append(p)
+                    elif distance == min_distance:
+                        closest_pos.append(p)
+
+                chosen_pos = agent.random.choice(closest_pos)
+
             else:
                 raise ValueError(
                     f"Invalid selection method {selection}. Choose 'random' or 'closest'."
diff --git a/mesa/time.py b/mesa/time.py
index e47563b4..f50aa89b 100644
--- a/mesa/time.py
+++ b/mesa/time.py
@@ -69,8 +69,6 @@ class BaseScheduler:
         self.model = model
         self.steps = 0
         self.time: TimeT = 0
-        self._original_step = self.step
-        self.step = self._wrapped_step
 
         if agents is None:
             agents = []
@@ -113,11 +111,6 @@ class BaseScheduler:
         self.steps += 1
         self.time += 1
 
-    def _wrapped_step(self):
-        """Wrapper for the step method to include time and step updating."""
-        self._original_step()
-        self.model._advance_time()
-
     def get_agent_count(self) -> int:
         """Returns the current number of agents in the queue."""
         return len(self._agents)
diff --git a/mesa/visualization/__init__.py b/mesa/visualization/__init__.py
index 76be748f..d8a0ebec 100644
--- a/mesa/visualization/__init__.py
+++ b/mesa/visualization/__init__.py
@@ -1,3 +1,14 @@
-from .solara_viz import JupyterViz, Slider, SolaraViz, make_text
+from .components.altair import make_space_altair
+from .components.matplotlib import make_plot_measure, make_space_matplotlib
+from .solara_viz import JupyterViz, SolaraViz, make_text
+from .UserParam import Slider
 
-__all__ = ["JupyterViz", "make_text", "Slider", "SolaraViz"]
+__all__ = [
+    "JupyterViz",
+    "SolaraViz",
+    "make_text",
+    "Slider",
+    "make_space_altair",
+    "make_space_matplotlib",
+    "make_plot_measure",
+]
diff --git a/mesa/visualization/components/altair.py b/mesa/visualization/components/altair.py
index f9d1a81c..1d23b170 100644
--- a/mesa/visualization/components/altair.py
+++ b/mesa/visualization/components/altair.py
@@ -5,9 +5,24 @@ import solara
 with contextlib.suppress(ImportError):
     import altair as alt
 
+from mesa.visualization.utils import update_counter
+
+
+def make_space_altair(agent_portrayal=None):
+    if agent_portrayal is None:
+
+        def agent_portrayal(a):
+            return {"id": a.unique_id}
+
+    def MakeSpaceAltair(model):
+        return SpaceAltair(model, agent_portrayal)
+
+    return MakeSpaceAltair
+
 
 @solara.component
 def SpaceAltair(model, agent_portrayal, dependencies: list[any] | None = None):
+    update_counter.get()
     space = getattr(model, "grid", None)
     if space is None:
         # Sometimes the space is defined as model.space instead of model.grid
diff --git a/mesa/visualization/components/matplotlib.py b/mesa/visualization/components/matplotlib.py
index 83d0e3d8..b1c61581 100644
--- a/mesa/visualization/components/matplotlib.py
+++ b/mesa/visualization/components/matplotlib.py
@@ -6,10 +6,25 @@ from matplotlib.figure import Figure
 from matplotlib.ticker import MaxNLocator
 
 import mesa
+from mesa.experimental.cell_space import VoronoiGrid
+from mesa.visualization.utils import update_counter
+
+
+def make_space_matplotlib(agent_portrayal=None):
+    if agent_portrayal is None:
+
+        def agent_portrayal(a):
+            return {"id": a.unique_id}
+
+    def MakeSpaceMatplotlib(model):
+        return SpaceMatplotlib(model, agent_portrayal)
+
+    return MakeSpaceMatplotlib
 
 
 @solara.component
 def SpaceMatplotlib(model, agent_portrayal, dependencies: list[any] | None = None):
+    update_counter.get()
     space_fig = Figure()
     space_ax = space_fig.subplots()
     space = getattr(model, "grid", None)
@@ -20,6 +35,8 @@ def SpaceMatplotlib(model, agent_portrayal, dependencies: list[any] | None = Non
         _draw_network_grid(space, space_ax, agent_portrayal)
     elif isinstance(space, mesa.space.ContinuousSpace):
         _draw_continuous_space(space, space_ax, agent_portrayal)
+    elif isinstance(space, VoronoiGrid):
+        _draw_voronoi(space, space_ax, agent_portrayal)
     else:
         _draw_grid(space, space_ax, agent_portrayal)
     solara.FigureMatplotlib(space_fig, format="png", dependencies=dependencies)
@@ -150,8 +167,66 @@ def _draw_continuous_space(space, space_ax, agent_portrayal):
     _split_and_scatter(portray(space), space_ax)
 
 
+def _draw_voronoi(space, space_ax, agent_portrayal):
+    def portray(g):
+        x = []
+        y = []
+        s = []  # size
+        c = []  # color
+
+        for cell in g.all_cells:
+            for agent in cell.agents:
+                data = agent_portrayal(agent)
+                x.append(cell.coordinate[0])
+                y.append(cell.coordinate[1])
+                if "size" in data:
+                    s.append(data["size"])
+                if "color" in data:
+                    c.append(data["color"])
+        out = {"x": x, "y": y}
+        # This is the default value for the marker size, which auto-scales
+        # according to the grid area.
+        out["s"] = s
+        if len(c) > 0:
+            out["c"] = c
+
+        return out
+
+    x_list = [i[0] for i in space.centroids_coordinates]
+    y_list = [i[1] for i in space.centroids_coordinates]
+    x_max = max(x_list)
+    x_min = min(x_list)
+    y_max = max(y_list)
+    y_min = min(y_list)
+
+    width = x_max - x_min
+    x_padding = width / 20
+    height = y_max - y_min
+    y_padding = height / 20
+    space_ax.set_xlim(x_min - x_padding, x_max + x_padding)
+    space_ax.set_ylim(y_min - y_padding, y_max + y_padding)
+    space_ax.scatter(**portray(space))
+
+    for cell in space.all_cells:
+        polygon = cell.properties["polygon"]
+        space_ax.fill(
+            *zip(*polygon),
+            alpha=min(1, cell.properties[space.cell_coloring_property]),
+            c="red",
+        )  # Plot filled polygon
+        space_ax.plot(*zip(*polygon), color="black")  # Plot polygon edges in red
+
+
+def make_plot_measure(measure: str | dict[str, str] | list[str] | tuple[str]):
+    def MakePlotMeasure(model):
+        return PlotMatplotlib(model, measure)
+
+    return MakePlotMeasure
+
+
 @solara.component
 def PlotMatplotlib(model, measure, dependencies: list[any] | None = None):
+    update_counter.get()
     fig = Figure()
     ax = fig.subplots()
     df = model.datacollector.get_model_vars_dataframe()
diff --git a/mesa/visualization/solara_viz.py b/mesa/visualization/solara_viz.py
index 8ae53cfd..1187373f 100644
--- a/mesa/visualization/solara_viz.py
+++ b/mesa/visualization/solara_viz.py
@@ -23,15 +23,20 @@ Usage:
 See the Visualization Tutorial and example models for more details.
 """
 
-import threading
+import copy
+import time
+from typing import TYPE_CHECKING, Literal
 
-import reacton.ipywidgets as widgets
 import solara
 from solara.alias import rv
 
 import mesa.visualization.components.altair as components_altair
 import mesa.visualization.components.matplotlib as components_matplotlib
 from mesa.visualization.UserParam import Slider
+from mesa.visualization.utils import force_update, update_counter
+
+if TYPE_CHECKING:
+    from mesa.model import Model
 
 
 # TODO: Turn this function into a Solara component once the current_step.value
@@ -86,124 +91,56 @@ def Card(
 
 @solara.component
 def SolaraViz(
-    model_class,
-    model_params,
-    measures=None,
-    name=None,
-    agent_portrayal=None,
-    space_drawer="default",
-    play_interval=150,
-    seed=None,
+    model: "Model" | solara.Reactive["Model"],
+    components: list[solara.component] | Literal["default"] = "default",
+    *args,
+    play_interval=100,
+    model_params=None,
+    seed=0,
+    name: str | None = None,
 ):
-    """
-    Initialize a component to visualize a model.
+    update_counter.get()
+    if components == "default":
+        components = [components_altair.make_space_altair()]
 
-    Args:
-        model_class: Class of the model to instantiate
-        model_params: Parameters for initializing the model
-        measures: List of callables or data attributes to plot
-        name: Name for display
-        agent_portrayal: Options for rendering agents (dictionary);
-            Default drawer supports custom `"size"`, `"color"`, and `"shape"`.
-        space_drawer: Method to render the agent space for
-            the model; default implementation is the `SpaceMatplotlib` component;
-            simulations with no space to visualize should
-            specify `space_drawer=False`
-        play_interval: Play interval (default: 150)
-        seed: The random seed used to initialize the model
-    """
-    if name is None:
-        name = model_class.__name__
+    # Convert model to reactive
+    if not isinstance(model, solara.Reactive):
+        model = solara.use_reactive(model)
 
-    current_step = solara.use_reactive(0)
+    def connect_to_model():
+        # Patch the step function to force updates
+        original_step = model.value.step
 
-    # 1. Set up model parameters
-    reactive_seed = solara.use_reactive(0)
-    user_params, fixed_params = split_model_params(model_params)
-    model_parameters, set_model_parameters = solara.use_state(
-        {**fixed_params, **{k: v.get("value") for k, v in user_params.items()}}
-    )
+        def step():
+            original_step()
+            force_update()
 
-    # 2. Set up Model
-    def make_model():
-        """Create a new model instance with current parameters and seed."""
-        model = model_class.__new__(
-            model_class, **model_parameters, seed=reactive_seed.value
-        )
-        model.__init__(**model_parameters)
-        current_step.value = 0
-        return model
-
-    reset_counter = solara.use_reactive(0)
-    model = solara.use_memo(
-        make_model,
-        dependencies=[
-            *list(model_parameters.values()),
-            reset_counter.value,
-            reactive_seed.value,
-        ],
-    )
+        model.value.step = step
+        # Add a trigger to model itself
+        model.value.force_update = force_update
+        force_update()
 
-    def handle_change_model_params(name: str, value: any):
-        """Update model parameters when user input changes."""
-        set_model_parameters({**model_parameters, name: value})
-
-    # 3. Set up UI
+    solara.use_effect(connect_to_model, [model.value])
 
     with solara.AppBar():
-        solara.AppBarTitle(name)
-
-    # render layout and plot
-    def do_reseed():
-        """Update the random seed for the model."""
-        reactive_seed.value = model.random.random()
-
-    dependencies = [
-        *list(model_parameters.values()),
-        current_step.value,
-        reactive_seed.value,
-    ]
-
-    # if space drawer is disabled, do not include it
-    layout_types = [{"Space": "default"}] if space_drawer else []
-
-    if measures:
-        layout_types += [{"Measure": elem} for elem in range(len(measures))]
-
-    grid_layout_initial = make_initial_grid_layout(layout_types=layout_types)
-    grid_layout, set_grid_layout = solara.use_state(grid_layout_initial)
+        solara.AppBarTitle(name if name else model.value.__class__.__name__)
 
     with solara.Sidebar():
         with solara.Card("Controls", margin=1, elevation=2):
-            solara.InputText(
-                label="Seed",
-                value=reactive_seed,
-                continuous_update=True,
-            )
-            UserInputs(user_params, on_change=handle_change_model_params)
-            ModelController(model, play_interval, current_step, reset_counter)
-            solara.Button(label="Reseed", color="primary", on_click=do_reseed)
+            if model_params is not None:
+                ModelCreator(
+                    model,
+                    model_params,
+                    seed=seed,
+                )
+            ModelController(model, play_interval)
         with solara.Card("Information", margin=1, elevation=2):
-            solara.Markdown(md_text=f"Step - {current_step}")
-
-    items = [
-        Card(
-            model,
-            measures,
-            agent_portrayal,
-            space_drawer,
-            dependencies,
-            color="white",
-            layout_type=layout_types[i],
-        )
-        for i in range(len(layout_types))
-    ]
-    solara.GridDraggable(
-        items=items,
-        grid_layout=grid_layout,
-        resizable=True,
-        draggable=True,
-        on_grid_layout=set_grid_layout,
+            ShowSteps(model.value)
+
+    solara.Column(
+        [
+            *(component(model.value) for component in components),
+        ]
     )
 
 
@@ -211,107 +148,57 @@ JupyterViz = SolaraViz
 
 
 @solara.component
-def ModelController(model, play_interval, current_step, reset_counter):
+def ModelController(model: solara.Reactive["Model"], play_interval=100):
     """
     Create controls for model execution (step, play, pause, reset).
 
     Args:
-        model: The model being visualized
+        model: The reactive model being visualized
         play_interval: Interval between steps during play
-        current_step: Reactive value for the current step
-        reset_counter: Counter to trigger model reset
     """
+    if not isinstance(model, solara.Reactive):
+        model = solara.use_reactive(model)
+
     playing = solara.use_reactive(False)
-    thread = solara.use_reactive(None)
-    # We track the previous step to detect if user resets the model via
-    # clicking the reset button or changing the parameters. If previous_step >
-    # current_step, it means a model reset happens while the simulation is
-    # still playing.
-    previous_step = solara.use_reactive(0)
-
-    def on_value_play(change):
-        """Handle play/pause state changes."""
-        if previous_step.value > current_step.value and current_step.value == 0:
-            # We add extra checks for current_step.value == 0, just to be sure.
-            # We automatically stop the playing if a model is reset.
-            playing.value = False
-        elif model.running:
+    original_model = solara.use_reactive(None)
+
+    def save_initial_model():
+        """Save the initial model for comparison."""
+        original_model.set(copy.deepcopy(model.value))
+        playing.value = False
+        force_update()
+
+    solara.use_effect(save_initial_model, [model.value])
+
+    def step():
+        while playing.value:
+            time.sleep(play_interval / 1000)
             do_step()
-        else:
-            playing.value = False
+
+    solara.use_thread(step, [playing.value])
 
     def do_step():
         """Advance the model by one step."""
-        model.step()
-        previous_step.value = current_step.value
-        current_step.value = model._steps
+        model.value.step()
 
     def do_play():
         """Run the model continuously."""
-        model.running = True
-        while model.running:
-            do_step()
-
-    def threaded_do_play():
-        """Start a new thread for continuous model execution."""
-        if thread is not None and thread.is_alive():
-            return
-        thread.value = threading.Thread(target=do_play)
-        thread.start()
+        playing.value = True
 
     def do_pause():
         """Pause the model execution."""
-        if (thread is None) or (not thread.is_alive()):
-            return
-        model.running = False
-        thread.join()
+        playing.value = False
 
     def do_reset():
-        """Reset the model."""
-        reset_counter.value += 1
-
-    def do_set_playing(value):
-        """Set the playing state."""
-        if current_step.value == 0:
-            # This means the model has been recreated, and the step resets to
-            # 0. We want to avoid triggering the playing.value = False in the
-            # on_value_play function.
-            previous_step.value = current_step.value
-        playing.set(value)
-
-    with solara.Row():
-        solara.Button(label="Step", color="primary", on_click=do_step)
-        # This style is necessary so that the play widget has almost the same
-        # height as typical Solara buttons.
-        solara.Style(
-            """
-        .widget-play {
-            height: 35px;
-        }
-        .widget-play button {
-            color: white;
-            background-color: #1976D2;  // Solara blue color
-        }
-        """
-        )
-        widgets.Play(
-            value=0,
-            interval=play_interval,
-            repeat=True,
-            show_repeat=False,
-            on_value=on_value_play,
-            playing=playing.value,
-            on_playing=do_set_playing,
-        )
+        """Reset the model to its initial state."""
+        playing.value = False
+        model.value = copy.deepcopy(original_model.value)
+
+    with solara.Row(justify="space-between"):
         solara.Button(label="Reset", color="primary", on_click=do_reset)
-        # threaded_do_play is not used for now because it
-        # doesn't work in Google colab. We use
-        # ipywidgets.Play until it is fixed. The threading
-        # version is definite a much better implementation,
-        # if it works.
-        # solara.Button(label="▶", color="primary", on_click=viz.threaded_do_play)
-        # solara.Button(label="⏸︎", color="primary", on_click=viz.do_pause)
-        # solara.Button(label="Reset", color="primary", on_click=do_reset)
+        solara.Button(label="Step", color="primary", on_click=do_step)
+        solara.Button(label="▶", color="primary", on_click=do_play)
+        solara.Button(label="⏸︎", color="primary", on_click=do_pause)
 
 
 def split_model_params(model_params):
@@ -352,6 +239,45 @@ def check_param_is_fixed(param):
         return True
 
 
+@solara.component
+def ModelCreator(model, model_params, seed=1):
+    user_params, fixed_params = split_model_params(model_params)
+
+    reactive_seed = solara.use_reactive(seed)
+
+    model_parameters, set_model_parameters = solara.use_state(
+        {
+            **fixed_params,
+            **{k: v.get("value") for k, v in user_params.items()},
+        }
+    )
+
+    def do_reseed():
+        """Update the random seed for the model."""
+        reactive_seed.value = model.value.random.random()
+
+    def on_change(name, value):
+        set_model_parameters({**model_parameters, name: value})
+
+    def create_model():
+        model.value = model.value.__class__.__new__(
+            model.value.__class__, **model_parameters, seed=reactive_seed.value
+        )
+        model.value.__init__(**model_parameters)
+
+    solara.use_effect(create_model, [model_parameters, reactive_seed.value])
+
+    solara.InputText(
+        label="Seed",
+        value=reactive_seed,
+        continuous_update=True,
+    )
+
+    solara.Button(label="Reseed", color="primary", on_click=do_reseed)
+
+    UserInputs(user_params, on_change=on_change)
+
+
 @solara.component
 def UserInputs(user_params, on_change=None):
     """
@@ -460,3 +386,9 @@ def make_initial_grid_layout(layout_types):
         }
         for i in range(len(layout_types))
     ]
+
+
+@solara.component
+def ShowSteps(model):
+    update_counter.get()
+    return solara.Text(f"Step: {model.steps}")
diff --git a/mesa/visualization/utils.py b/mesa/visualization/utils.py
new file mode 100644
index 00000000..c49b35e3
--- /dev/null
+++ b/mesa/visualization/utils.py
@@ -0,0 +1,7 @@
+import solara
+
+update_counter = solara.reactive(0)
+
+
+def force_update():
+    update_counter.value += 1
diff --git a/pyproject.toml b/pyproject.toml
index ee2dec72..bc949f83 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -28,6 +28,7 @@ classifiers = [
   "Programming Language :: Python :: 3.10",
   "Programming Language :: Python :: 3.11",
   "Programming Language :: Python :: 3.12",
+  "Programming Language :: Python :: 3.13",
   "License :: OSI Approved :: Apache Software License",
   "Operating System :: OS Independent",
   "Development Status :: 3 - Alpha",
@@ -54,6 +55,10 @@ dev = [
   "sphinx",
   "pytest-mock",
 ]
+examples = [
+  "pytest >= 4.6",
+  "scipy"
+]
 docs = [
   "sphinx",
   "ipython",


Test patch:
diff --git a/tests/test_agent.py b/tests/test_agent.py
index 516902f9..e78a4dce 100644
--- a/tests/test_agent.py
+++ b/tests/test_agent.py
@@ -1,5 +1,6 @@
 import pickle
 
+import numpy as np
 import pytest
 
 from mesa.agent import Agent, AgentSet
@@ -14,17 +15,16 @@ class TestAgent(Agent):
 class TestAgentDo(Agent):
     def __init__(
         self,
-        unique_id,
         model,
     ):
-        super().__init__(unique_id, model)
+        super().__init__(model)
         self.agent_set = None
 
     def get_unique_identifier(self):
         return self.unique_id
 
     def do_add(self):
-        agent = TestAgentDo(self.model.next_id(), self.model)
+        agent = TestAgentDo(self.model)
         self.agent_set.add(agent)
 
     def do_remove(self):
@@ -33,7 +33,7 @@ class TestAgentDo(Agent):
 
 def test_agent_removal():
     model = Model()
-    agent = TestAgent(model.next_id(), model)
+    agent = TestAgent(model)
     # Check if the agent is added
     assert agent in model.agents
 
@@ -45,7 +45,7 @@ def test_agent_removal():
 def test_agentset():
     # create agentset
     model = Model()
-    agents = [TestAgent(model.next_id(), model) for _ in range(10)]
+    agents = [TestAgent(model) for _ in range(10)]
 
     agentset = AgentSet(agents, model)
 
@@ -59,6 +59,12 @@ def test_agentset():
     def test_function(agent):
         return agent.unique_id > 5
 
+    assert len(agentset.select(at_most=0.2)) == 2  # Select 20% of agents
+    assert len(agentset.select(at_most=0.549)) == 5  # Select 50% of agents
+    assert len(agentset.select(at_most=0.09)) == 0  # Select 0% of agents
+    assert len(agentset.select(at_most=1.0)) == 10  # Select 100% agents
+    assert len(agentset.select(at_most=1)) == 1  # Select 1 agent
+
     assert len(agentset.select(test_function)) == 5
     assert len(agentset.select(test_function, n=2)) == 2
     assert len(agentset.select(test_function, inplace=True)) == 5
@@ -110,14 +116,14 @@ def test_agentset_initialization():
     empty_agentset = AgentSet([], model)
     assert len(empty_agentset) == 0
 
-    agents = [TestAgent(model.next_id(), model) for _ in range(10)]
+    agents = [TestAgent(model) for _ in range(10)]
     agentset = AgentSet(agents, model)
     assert len(agentset) == 10
 
 
 def test_agentset_serialization():
     model = Model()
-    agents = [TestAgent(model.next_id(), model) for _ in range(5)]
+    agents = [TestAgent(model) for _ in range(5)]
     agentset = AgentSet(agents, model)
 
     serialized = pickle.dumps(agentset)
@@ -131,16 +137,16 @@ def test_agentset_serialization():
 
 def test_agent_membership():
     model = Model()
-    agents = [TestAgent(model.next_id(), model) for _ in range(5)]
+    agents = [TestAgent(model) for _ in range(5)]
     agentset = AgentSet(agents, model)
 
     assert agents[0] in agentset
-    assert TestAgent(model.next_id(), model) not in agentset
+    assert TestAgent(model) not in agentset
 
 
 def test_agent_add_remove_discard():
     model = Model()
-    agent = TestAgent(model.next_id(), model)
+    agent = TestAgent(model)
     agentset = AgentSet([], model)
 
     agentset.add(agent)
@@ -159,7 +165,7 @@ def test_agent_add_remove_discard():
 
 def test_agentset_get_item():
     model = Model()
-    agents = [TestAgent(model.next_id(), model) for _ in range(10)]
+    agents = [TestAgent(model) for _ in range(10)]
     agentset = AgentSet(agents, model)
 
     assert agentset[0] == agents[0]
@@ -172,7 +178,7 @@ def test_agentset_get_item():
 
 def test_agentset_do_str():
     model = Model()
-    agents = [TestAgent(model.next_id(), model) for _ in range(10)]
+    agents = [TestAgent(model) for _ in range(10)]
     agentset = AgentSet(agents, model)
 
     with pytest.raises(AttributeError):
@@ -185,7 +191,7 @@ def test_agentset_do_str():
     # setup
     n = 10
     model = Model()
-    agents = [TestAgentDo(model.next_id(), model) for _ in range(n)]
+    agents = [TestAgentDo(model) for _ in range(n)]
     agentset = AgentSet(agents, model)
     for agent in agents:
         agent.agent_set = agentset
@@ -195,7 +201,7 @@ def test_agentset_do_str():
 
     # setup
     model = Model()
-    agents = [TestAgentDo(model.next_id(), model) for _ in range(10)]
+    agents = [TestAgentDo(model) for _ in range(10)]
     agentset = AgentSet(agents, model)
     for agent in agents:
         agent.agent_set = agentset
@@ -206,7 +212,7 @@ def test_agentset_do_str():
 
 def test_agentset_do_callable():
     model = Model()
-    agents = [TestAgent(model.next_id(), model) for _ in range(10)]
+    agents = [TestAgent(model) for _ in range(10)]
     agentset = AgentSet(agents, model)
 
     # Test callable with non-existent function
@@ -220,7 +226,7 @@ def test_agentset_do_callable():
     # setup for lambda function tests
     n = 10
     model = Model()
-    agents = [TestAgentDo(model.next_id(), model) for _ in range(n)]
+    agents = [TestAgentDo(model) for _ in range(n)]
     agentset = AgentSet(agents, model)
     for agent in agents:
         agent.agent_set = agentset
@@ -231,7 +237,7 @@ def test_agentset_do_callable():
 
     # setup again for lambda function tests
     model = Model()
-    agents = [TestAgentDo(model.next_id(), model) for _ in range(10)]
+    agents = [TestAgentDo(model) for _ in range(10)]
     agentset = AgentSet(agents, model)
     for agent in agents:
         agent.agent_set = agentset
@@ -249,7 +255,7 @@ def test_agentset_do_callable():
 
     # setup again for actual function tests
     model = Model()
-    agents = [TestAgentDo(model.next_id(), model) for _ in range(n)]
+    agents = [TestAgentDo(model) for _ in range(n)]
     agentset = AgentSet(agents, model)
     for agent in agents:
         agent.agent_set = agentset
@@ -260,7 +266,7 @@ def test_agentset_do_callable():
 
     # setup again for actual function tests
     model = Model()
-    agents = [TestAgentDo(model.next_id(), model) for _ in range(10)]
+    agents = [TestAgentDo(model) for _ in range(10)]
     agentset = AgentSet(agents, model)
     for agent in agents:
         agent.agent_set = agentset
@@ -270,9 +276,68 @@ def test_agentset_do_callable():
     assert len(agentset) == 0
 
 
+def test_agentset_agg():
+    model = Model()
+    agents = [TestAgent(model) for i in range(10)]
+
+    # Assign some values to attributes
+    for i, agent in enumerate(agents):
+        agent.energy = i + 1
+        agent.wealth = 10 * (i + 1)
+
+    agentset = AgentSet(agents, model)
+
+    # Test min aggregation
+    min_energy = agentset.agg("energy", min)
+    assert min_energy == 1
+
+    # Test max aggregation
+    max_energy = agentset.agg("energy", max)
+    assert max_energy == 10
+
+    # Test sum aggregation
+    total_energy = agentset.agg("energy", sum)
+    assert total_energy == sum(range(1, 11))
+
+    # Test mean aggregation using numpy
+    avg_wealth = agentset.agg("wealth", np.mean)
+    assert avg_wealth == 55.0
+
+    # Test aggregation with a custom function
+    def custom_func(values):
+        return sum(values) / len(values)
+
+    custom_avg_energy = agentset.agg("energy", custom_func)
+    assert custom_avg_energy == 5.5
+
+
+def test_agentset_set_method():
+    # Initialize the model and agents with and without existing attributes
+    class TestAgentWithAttribute(Agent):
+        def __init__(self, model, age=None):
+            super().__init__(model)
+            self.age = age
+
+    model = Model()
+    agents = [TestAgentWithAttribute(model, age=i) for i in range(5)]
+    agentset = AgentSet(agents, model)
+
+    # Set a new attribute "health" and an existing attribute "age" for all agents
+    agentset.set("health", 100).set("age", 50).set("status", "active")
+
+    # Check if all agents have the "health", "age", and "status" attributes correctly set
+    for agent in agentset:
+        assert hasattr(agent, "health")
+        assert agent.health == 100
+        assert hasattr(agent, "age")
+        assert agent.age == 50
+        assert hasattr(agent, "status")
+        assert agent.status == "active"
+
+
 def test_agentset_map_str():
     model = Model()
-    agents = [TestAgent(model.next_id(), model) for _ in range(10)]
+    agents = [TestAgent(model) for _ in range(10)]
     agentset = AgentSet(agents, model)
 
     with pytest.raises(AttributeError):
@@ -284,7 +349,7 @@ def test_agentset_map_str():
 
 def test_agentset_map_callable():
     model = Model()
-    agents = [TestAgent(model.next_id(), model) for _ in range(10)]
+    agents = [TestAgent(model) for _ in range(10)]
     agentset = AgentSet(agents, model)
 
     # Test callable with non-existent function
@@ -301,7 +366,7 @@ def test_agentset_map_callable():
 
 def test_agentset_get_attribute():
     model = Model()
-    agents = [TestAgent(model.next_id(), model) for _ in range(10)]
+    agents = [TestAgent(model) for _ in range(10)]
     agentset = AgentSet(agents, model)
 
     unique_ids = agentset.get("unique_id")
@@ -313,7 +378,7 @@ def test_agentset_get_attribute():
     model = Model()
     agents = []
     for i in range(10):
-        agent = TestAgent(model.next_id(), model)
+        agent = TestAgent(model)
         agent.i = i**2
         agents.append(agent)
     agentset = AgentSet(agents, model)
@@ -337,8 +402,8 @@ class OtherAgentType(Agent):
 def test_agentset_select_by_type():
     model = Model()
     # Create a mix of agents of two different types
-    test_agents = [TestAgent(model.next_id(), model) for _ in range(4)]
-    other_agents = [OtherAgentType(model.next_id(), model) for _ in range(6)]
+    test_agents = [TestAgent(model) for _ in range(4)]
+    other_agents = [OtherAgentType(model) for _ in range(6)]
 
     # Combine the two types of agents
     mixed_agents = test_agents + other_agents
@@ -362,7 +427,7 @@ def test_agentset_select_by_type():
 
 def test_agentset_shuffle():
     model = Model()
-    test_agents = [TestAgent(model.next_id(), model) for _ in range(12)]
+    test_agents = [TestAgent(model) for _ in range(12)]
 
     agentset = AgentSet(test_agents, model=model)
     agentset = agentset.shuffle()
@@ -375,15 +440,15 @@ def test_agentset_shuffle():
 
 def test_agentset_groupby():
     class TestAgent(Agent):
-        def __init__(self, unique_id, model):
-            super().__init__(unique_id, model)
+        def __init__(self, model):
+            super().__init__(model)
             self.even = self.unique_id % 2 == 0
 
         def get_unique_identifier(self):
             return self.unique_id
 
     model = Model()
-    agents = [TestAgent(model.next_id(), model) for _ in range(10)]
+    agents = [TestAgent(model) for _ in range(10)]
     agentset = AgentSet(agents, model)
 
     groups = agentset.groupby("even")
@@ -413,3 +478,14 @@ def test_agentset_groupby():
     groups = agentset.groupby("even", result_type="agentset")
     another_ref_to_groups = groups.do(lambda x: x.do("step"))
     assert groups == another_ref_to_groups
+
+
+def test_oldstyle_agent_instantiation():
+    """Old behavior of Agent creation with unique_id and model as positional arguments.
+    Can be removed/updated in the future."""
+    model = Model()
+    agent = Agent("some weird unique id", model)
+    assert isinstance(agent.unique_id, int)
+    assert agent.model == model
+    assert isinstance(agent.model, Model)
+    assert agent.unique_id == 1  # test that we ignore unique ID that is passed
diff --git a/tests/test_batch_run.py b/tests/test_batch_run.py
index f5ddb0ae..784c0416 100644
--- a/tests/test_batch_run.py
+++ b/tests/test_batch_run.py
@@ -28,9 +28,8 @@ class MockAgent(Agent):
     Minimalistic agent implementation for testing purposes
     """
 
-    def __init__(self, unique_id, model, val):
-        super().__init__(unique_id, model)
-        self.unique_id = unique_id
+    def __init__(self, model, val):
+        super().__init__(model)
         self.val = val
         self.local = 0
 
@@ -76,15 +75,15 @@ class MockModel(Model):
             agent_val = 1
         else:
             agent_val = self.variable_agent_param
-        for i in range(self.n_agents):
-            self.schedule.add(MockAgent(i, self, agent_val))
+        for _ in range(self.n_agents):
+            self.schedule.add(MockAgent(self, agent_val))
 
     def get_local_model_param(self):
         return 42
 
     def step(self):
-        self.datacollector.collect(self)
         self.schedule.step()
+        self.datacollector.collect(self)
 
 
 def test_batch_run():
@@ -95,8 +94,8 @@ def test_batch_run():
             "iteration": 0,
             "Step": 1000,
             "reported_model_param": 42,
-            "AgentID": 0,
-            "agent_id": 0,
+            "AgentID": 1,
+            "agent_id": 1,
             "agent_local": 250.0,
         },
         {
@@ -104,8 +103,8 @@ def test_batch_run():
             "iteration": 0,
             "Step": 1000,
             "reported_model_param": 42,
-            "AgentID": 1,
-            "agent_id": 1,
+            "AgentID": 2,
+            "agent_id": 2,
             "agent_local": 250.0,
         },
         {
@@ -113,8 +112,8 @@ def test_batch_run():
             "iteration": 0,
             "Step": 1000,
             "reported_model_param": 42,
-            "AgentID": 2,
-            "agent_id": 2,
+            "AgentID": 3,
+            "agent_id": 3,
             "agent_local": 250.0,
         },
     ]
@@ -172,29 +171,29 @@ def test_batch_run_unhashable_param():
         {
             "RunId": 0,
             "iteration": 0,
-            "AgentID": 0,
-            "agent_id": 0,
+            "AgentID": 1,
+            "agent_id": 1,
             **template,
         },
         {
             "RunId": 0,
             "iteration": 0,
-            "AgentID": 1,
-            "agent_id": 1,
+            "AgentID": 2,
+            "agent_id": 2,
             **template,
         },
         {
             "RunId": 1,
             "iteration": 1,
-            "AgentID": 0,
-            "agent_id": 0,
+            "AgentID": 1,
+            "agent_id": 1,
             **template,
         },
         {
             "RunId": 1,
             "iteration": 1,
-            "AgentID": 1,
-            "agent_id": 1,
+            "AgentID": 2,
+            "agent_id": 2,
             **template,
         },
     ]
diff --git a/tests/test_cell_space.py b/tests/test_cell_space.py
index 050e39a0..4127847d 100644
--- a/tests/test_cell_space.py
+++ b/tests/test_cell_space.py
@@ -11,6 +11,7 @@ from mesa.experimental.cell_space import (
     Network,
     OrthogonalMooreGrid,
     OrthogonalVonNeumannGrid,
+    VoronoiGrid,
 )
 
 
@@ -372,6 +373,28 @@ def test_networkgrid():
             assert connection.coordinate in G.neighbors(i)
 
 
+def test_voronoigrid():
+    points = [[0, 1], [1, 3], [1.1, 1], [1, 1]]
+
+    grid = VoronoiGrid(points)
+
+    assert len(grid._cells) == len(points)
+
+    # Check cell neighborhood
+    assert len(grid._cells[0]._connections) == 2
+    for connection in grid._cells[0]._connections:
+        assert connection.coordinate in [[1, 1], [1, 3]]
+
+    with pytest.raises(ValueError):
+        VoronoiGrid(points, capacity="str")
+
+    with pytest.raises(ValueError):
+        VoronoiGrid((1, 1))
+
+    with pytest.raises(ValueError):
+        VoronoiGrid([[0, 1], [0, 1, 1]])
+
+
 def test_empties_space():
     import networkx as nx
 
@@ -385,7 +408,7 @@ def test_empties_space():
 
     model = Model()
     for i in range(8):
-        grid._cells[i].add_agent(CellAgent(i, model))
+        grid._cells[i].add_agent(CellAgent(model))
 
     cell = grid.select_random_empty_cell()
     assert cell.coordinate in {8, 9}
@@ -409,7 +432,7 @@ def test_cell():
 
     # add_agent
     model = Model()
-    agent = CellAgent(1, model)
+    agent = CellAgent(model)
 
     cell1.add_agent(agent)
     assert agent in cell1.agents
@@ -422,11 +445,11 @@ def test_cell():
         cell1.remove_agent(agent)
 
     cell1 = Cell((1,), capacity=1, random=random.Random())
-    cell1.add_agent(CellAgent(1, model))
+    cell1.add_agent(CellAgent(model))
     assert cell1.is_full
 
     with pytest.raises(Exception):
-        cell1.add_agent(CellAgent(2, model))
+        cell1.add_agent(CellAgent(model))
 
 
 def test_cell_collection():
@@ -452,9 +475,9 @@ def test_cell_collection():
 
     cells = collection.cells
     model = Model()
-    cells[0].add_agent(CellAgent(1, model))
-    cells[3].add_agent(CellAgent(2, model))
-    cells[7].add_agent(CellAgent(3, model))
+    cells[0].add_agent(CellAgent(model))
+    cells[3].add_agent(CellAgent(model))
+    cells[7].add_agent(CellAgent(model))
     agents = collection.agents
     assert len(list(agents)) == 3
 
diff --git a/tests/test_datacollector.py b/tests/test_datacollector.py
index bf74e379..94e7742a 100644
--- a/tests/test_datacollector.py
+++ b/tests/test_datacollector.py
@@ -13,8 +13,8 @@ class MockAgent(Agent):
     Minimalistic agent for testing purposes.
     """
 
-    def __init__(self, unique_id, model, val=0):
-        super().__init__(unique_id, model)
+    def __init__(self, model, val=0):
+        super().__init__(model)
         self.val = val
         self.val2 = val
 
@@ -40,14 +40,6 @@ def agent_function_with_params(agent, multiplier, offset):
     return (agent.val * multiplier) + offset
 
 
-class DifferentMockAgent(MockAgent):
-    # We define a different MockAgent to test for attributes that are present
-    # only in 1 type of agent, but not the other.
-    def __init__(self, unique_id, model, val=0):
-        super().__init__(unique_id, model, val=val)
-        self.val3 = val + 42
-
-
 class MockModel(Model):
     """
     Minimalistic model for testing purposes.
@@ -61,8 +53,8 @@ class MockModel(Model):
         self.model_val = 100
 
         self.n = 10
-        for i in range(self.n):
-            self.schedule.add(MockAgent(i, self, val=i))
+        for i in range(1, self.n + 1):
+            self.schedule.add(MockAgent(self, val=i))
         self.initialize_data_collector(
             model_reporters={
                 "total_agents": lambda m: m.schedule.get_agent_count(),
diff --git a/tests/test_examples.py b/tests/test_examples.py
index e5c0381f..25b8e0e0 100644
--- a/tests/test_examples.py
+++ b/tests/test_examples.py
@@ -66,3 +66,4 @@ class TestExamples(unittest.TestCase):
                 model = model_class()
                 for _ in range(10):
                     model.step()
+                self.assertEqual(model.steps, 10)
diff --git a/tests/test_lifespan.py b/tests/test_lifespan.py
index cfd60cde..00dd878c 100644
--- a/tests/test_lifespan.py
+++ b/tests/test_lifespan.py
@@ -29,9 +29,7 @@ class LifeTimeModel(Model):
         self.schedule = RandomActivation(self)
 
         for _ in range(n_agents):
-            self.schedule.add(
-                FiniteLifeAgent(self.next_id(), self.agent_lifetime, self)
-            )
+            self.schedule.add(FiniteLifeAgent(self.agent_lifetime, self))
 
     def step(self):
         """Add agents back to n_agents in each step"""
@@ -40,9 +38,7 @@ class LifeTimeModel(Model):
 
         if len(self.schedule.agents) < self.n_agents:
             for _ in range(self.n_agents - len(self.schedule.agents)):
-                self.schedule.add(
-                    FiniteLifeAgent(self.next_id(), self.agent_lifetime, self)
-                )
+                self.schedule.add(FiniteLifeAgent(self.agent_lifetime, self))
 
     def run_model(self, step_count=100):
         for _ in range(step_count):
@@ -54,8 +50,8 @@ class FiniteLifeAgent(Agent):
     Also has a 10% chance of dying in each tick.
     """
 
-    def __init__(self, unique_id, lifetime, model):
-        super().__init__(unique_id, model)
+    def __init__(self, lifetime, model):
+        super().__init__(model)
         self.remaining_life = lifetime
         self.steps = 0
         self.model = model
diff --git a/tests/test_model.py b/tests/test_model.py
index 874d45f9..016185e6 100644
--- a/tests/test_model.py
+++ b/tests/test_model.py
@@ -1,4 +1,4 @@
-from mesa.agent import Agent
+from mesa.agent import Agent, AgentSet
 from mesa.model import Model
 
 
@@ -6,10 +6,9 @@ def test_model_set_up():
     model = Model()
     assert model.running is True
     assert model.schedule is None
-    assert model.current_id == 0
-    assert model.current_id + 1 == model.next_id()
-    assert model.current_id == 1
+    assert model.steps == 0
     model.step()
+    assert model.steps == 1
 
 
 def test_running():
@@ -18,12 +17,12 @@ def test_running():
 
         def step(self):
             """Increase steps until 10."""
-            self.steps += 1
             if self.steps == 10:
                 self.running = False
 
     model = TestModel()
     model.run_model()
+    assert model.steps == 10
 
 
 def test_seed(seed=23):
@@ -48,6 +47,22 @@ def test_agent_types():
         pass
 
     model = Model()
-    test_agent = TestAgent(model.next_id(), model)
+    test_agent = TestAgent(model)
     assert test_agent in model.agents
     assert type(test_agent) in model.agent_types
+
+
+def test_agents_by_type():
+    class Wolf(Agent):
+        pass
+
+    class Sheep(Agent):
+        pass
+
+    model = Model()
+    wolf = Wolf(model)
+    sheep = Sheep(model)
+
+    assert model.agents_by_type[Wolf] == AgentSet([wolf], model)
+    assert model.agents_by_type[Sheep] == AgentSet([sheep], model)
+    assert len(model.agents_by_type) == 2
diff --git a/tests/test_solara_viz.py b/tests/test_solara_viz.py
index 5fef7e0b..3ff81640 100644
--- a/tests/test_solara_viz.py
+++ b/tests/test_solara_viz.py
@@ -5,6 +5,7 @@ import ipyvuetify as vw
 import solara
 
 import mesa
+from mesa.visualization.components.matplotlib import make_space_matplotlib
 from mesa.visualization.solara_viz import Slider, SolaraViz, UserInputs
 
 
@@ -95,49 +96,30 @@ def test_call_space_drawer(mocker):
         "Shape": "circle",
         "color": "gray",
     }
-    current_step = 0
-    seed = 0
-    dependencies = [current_step, seed]
     # initialize with space drawer unspecified (use default)
     # component must be rendered for code to run
-    solara.render(
-        SolaraViz(
-            model_class=mesa.Model,
-            model_params={},
-            agent_portrayal=agent_portrayal,
-        )
-    )
+    solara.render(SolaraViz(model, components=[make_space_matplotlib(agent_portrayal)]))
     # should call default method with class instance and agent portrayal
-    mock_space_matplotlib.assert_called_with(
-        model, agent_portrayal, dependencies=dependencies
-    )
+    mock_space_matplotlib.assert_called_with(model, agent_portrayal)
 
-    # specify no space should be drawn; any false value should work
-    for falsy_value in [None, False, 0]:
-        mock_space_matplotlib.reset_mock()
-        solara.render(
-            SolaraViz(
-                model_class=mesa.Model,
-                model_params={},
-                agent_portrayal=agent_portrayal,
-                space_drawer=falsy_value,
-            )
-        )
-        # should call default method with class instance and agent portrayal
-        assert mock_space_matplotlib.call_count == 0
+    # specify no space should be drawn
+    mock_space_matplotlib.reset_mock()
+    solara.render(SolaraViz(model, components=[]))
+    # should call default method with class instance and agent portrayal
+    assert mock_space_matplotlib.call_count == 0
 
     # specify a custom space method
     altspace_drawer = Mock()
-    solara.render(
-        SolaraViz(
-            model_class=mesa.Model,
-            model_params={},
-            agent_portrayal=agent_portrayal,
-            space_drawer=altspace_drawer,
-        )
+    solara.render(SolaraViz(model, components=[altspace_drawer]))
+    altspace_drawer.assert_called_with(model)
+
+    # check voronoi space drawer
+    voronoi_model = mesa.Model()
+    voronoi_model.grid = mesa.experimental.cell_space.VoronoiGrid(
+        centroids_coordinates=[(0, 1), (0, 0), (1, 0)],
     )
-    altspace_drawer.assert_called_with(
-        model, agent_portrayal, dependencies=dependencies
+    solara.render(
+        SolaraViz(voronoi_model, components=[make_space_matplotlib(agent_portrayal)])
     )
 
 
diff --git a/tests/test_space.py b/tests/test_space.py
index 23cf8c95..2da9ee2a 100644
--- a/tests/test_space.py
+++ b/tests/test_space.py
@@ -509,6 +509,23 @@ class TestSingleGrid(unittest.TestCase):
         self.space.move_agent_to_one_of(agent, possible_positions, selection="closest")
         assert agent.pos == (6, 6)
 
+    def test_move_agent_closest_selection_multiple(self):
+        random_locations = []
+        agent = self.agents[0]
+        agent.pos = (5, 5)
+        repetititions = 10
+
+        for _ in range(repetititions):
+            possible_positions = [(4, 4), (6, 6), (10, 10), (20, 20)]
+            self.space.move_agent_to_one_of(
+                agent, possible_positions, selection="closest"
+            )
+            random_locations.append(agent.pos)
+            assert agent.pos in possible_positions
+            self.space.move_agent_to_one_of(agent, [(5, 5)], selection="closest")
+        non_random_locations = [random_locations[0]] * repetititions
+        assert random_locations != non_random_locations
+
     def test_move_agent_invalid_selection(self):
         agent = self.agents[0]
         possible_positions = [(10, 10), (20, 20), (30, 30)]
diff --git a/tests/test_time.py b/tests/test_time.py
index a9d8e2e6..13d71d22 100644
--- a/tests/test_time.py
+++ b/tests/test_time.py
@@ -25,8 +25,8 @@ class MockAgent(Agent):
     Minimalistic agent for testing purposes.
     """
 
-    def __init__(self, unique_id, model):
-        super().__init__(unique_id, model)
+    def __init__(self, model):
+        super().__init__(model)
         self.steps = 0
         self.advances = 0
 
@@ -38,10 +38,10 @@ class MockAgent(Agent):
     def stage_one(self):
         if self.model.enable_kill_other_agent:
             self.kill_other_agent()
-        self.model.log.append(self.unique_id + "_1")
+        self.model.log.append(f"{self.unique_id}_1")
 
     def stage_two(self):
-        self.model.log.append(self.unique_id + "_2")
+        self.model.log.append(f"{self.unique_id}_2")
 
     def advance(self):
         self.advances += 1
@@ -89,8 +89,8 @@ class MockModel(Model):
             self.schedule = BaseScheduler(self)
 
         # Make agents
-        for name in ["A", "B"]:
-            agent = MockAgent(name, self)
+        for _ in range(2):
+            agent = MockAgent(self)
             self.schedule.add(agent)
 
     def step(self):
@@ -105,7 +105,7 @@ class TestStagedActivation(TestCase):
     Test the staged activation.
     """
 
-    expected_output = ["A_1", "B_1", "model_stage", "A_2", "B_2"]
+    expected_output = ["1_1", "1_1", "model_stage", "1_2", "1_2"]
 
     def test_no_shuffle(self):
         """
@@ -168,7 +168,7 @@ class TestRandomActivation(TestCase):
 
     def test_init(self):
         model = Model()
-        agents = [MockAgent(model.next_id(), model) for _ in range(10)]
+        agents = [MockAgent(model) for _ in range(10)]
 
         scheduler = RandomActivation(model, agents)
         assert all(agent in scheduler.agents for agent in agents)
@@ -227,7 +227,7 @@ class TestRandomActivation(TestCase):
         model = MockModel(activation=RANDOM)
         # Create 10 agents
         for _ in range(10):
-            model.schedule.add(MockAgent(model.next_id(), model))
+            model.schedule.add(MockAgent(model))
         # Run 3 steps
         for _ in range(3):
             model.step()
@@ -273,8 +273,8 @@ class TestRandomActivationByType(TestCase):
 
     def test_init(self):
         model = Model()
-        agents = [MockAgent(model.next_id(), model) for _ in range(10)]
-        agents += [Agent(model.next_id(), model) for _ in range(10)]
+        agents = [MockAgent(model) for _ in range(10)]
+        agents += [Agent(model) for _ in range(10)]
 
         scheduler = RandomActivationByType(model, agents)
         assert all(agent in scheduler.agents for agent in agents)
@@ -320,7 +320,7 @@ class TestRandomActivationByType(TestCase):
         agent_types = model.agent_types
         for agent_type in agent_types:
             assert model.schedule.get_type_count(agent_type) == len(
-                model.get_agents_of_type(agent_type)
+                model.agents_by_type[agent_type]
             )
 
     # def test_add_non_unique_ids(self):