Repository: projectmesa/mesa
Pull request #2325 (2024-09-25 05:35:43)

CI: Use Python 3.13
Use Python 3.13 in all CI jobs. It’s faster and gives better error messages.

github-actions[bot]: Performance benchmarks:

| Model           | Size   | Init time [95% CI]      | Run time [95% CI]        |
|-----------------|--------|-------------------------|--------------------------|
| BoltzmannWealth | small  | 🔵 -0.6% [-1.7%, +0.4%] | 🔵 +0.5% [+0.3%, +0.6%]  |
| BoltzmannWealth | large  | 🔵 +0.3% [-0.2%, +0.7%] | 🔴 +4.4% [+3.2%, +6.2%]  |
| Schelling       | small  | 🔵 +0.3% [-0.1%, +0.6%] | 🔵 +1.4% [+1.1%, +1.6%]  |
| Schelling       | large  | 🔵 +0.9% [+0.0%, +1.8%] | 🔴 +8.4% [+4.8%, +12.1%] |
| WolfSheep       | small  | 🔵 +0.3% [-0.0%, +0.7%] | 🔵 +1.0% [+0.7%, +1.2%]  |
| WolfSheep       | large  | 🔵 +2.2% [+1.1%, +3.6%] | 🔴 +8.2% [+3.2%, +14.5%] |
| BoidFlockers    | small  | 🔵 +0.4% [-0.0%, +0.8%] | 🔵 +1.4% [+0.7%, +2.0%]  |
| BoidFlockers    | large  | 🔵 +0.4% [-0.1%, +0.9%] | 🔵 +0.9% [+0.5%, +1.5%]  |

EwoutH: This is exactly why you have these kind of PRs before a new stable Python release:
- https://github.com/widgetti/solara/issues/797

Benchmarks numbers look remarkably similar. Where Python 3.11 and 3.12 gave a noticeable speedup, that doesn't look to be the case with 3.13.

Patch:
diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml
index d59663f6..82608c28 100644
--- a/.github/workflows/benchmarks.yml
+++ b/.github/workflows/benchmarks.yml
@@ -25,7 +25,7 @@ jobs:
       - name: Set up Python
         uses: actions/setup-python@v5
         with:
-          python-version: '3.12'
+          python-version: '3.13'
       - name: Add project directory to PYTHONPATH
         run: echo "PYTHONPATH=$PYTHONPATH:$(pwd)" >> $GITHUB_ENV
       - name: Install dependencies
diff --git a/.github/workflows/build_lint.yml b/.github/workflows/build_lint.yml
index 5e59c2f0..d981e754 100644
--- a/.github/workflows/build_lint.yml
+++ b/.github/workflows/build_lint.yml
@@ -31,10 +31,10 @@ jobs:
       fail-fast: False
       matrix:
         os: [windows, ubuntu, macos]
-        python-version: ["3.12"]
+        python-version: ["3.13"]
         include:
           - os: ubuntu
-            python-version: "3.13"
+            python-version: "3.12"
           - os: ubuntu
             python-version: "3.11"
           - os: ubuntu
@@ -68,7 +68,8 @@ jobs:
     - name: Set up Python
       uses: actions/setup-python@v5
       with:
-        python-version: "3.12"
+        python-version: "3.13"
+        allow-prereleases: true
         cache: 'pip'
     - name: Install uv
       run: pip install uv
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index b8f01c47..bf73bc1a 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -32,7 +32,8 @@ jobs:
       - name: Set up Python
         uses: actions/setup-python@v5
         with:
-          python-version: "3.12"
+          python-version: "3.13"
+          allow-prereleases: true
           cache: 'pip'
       - name: Install dependencies
         run: pip install -U pip hatch
diff --git a/HISTORY.md b/HISTORY.md
index c1eb2c8f..1558c041 100644
--- a/HISTORY.md
+++ b/HISTORY.md
@@ -1,6 +1,50 @@
 ---
 title: Release History
 ---
+# 3.0.0b0 (2024-10-04)
+## Highlights
+We're proud to release the first Mesa 3.0 beta! This pre-release announces that we're ready for Mesa 3.0 to be tested by all our regular users. We try to not making breaking changes anymore, but focus on resolving bugs and imperfections.
+
+In this beta, not so much has changed as in the alphas (we're stabilizing, that's a good sign), but there are still a few notable things:
+- Agents now have to be initialized without their `unique_id`. See [#2328](https://github.com/projectmesa/mesa/pull/2328) and the [Migration guide](https://mesa.readthedocs.io/latest/migration_guide.html#automatic-assignment-of-unique-id-to-agents).
+- PropertyLayers can now be visualized! See [#2336](https://github.com/projectmesa/mesa/pull/2336) for details and some examples, and [mesa-examples#214](https://github.com/projectmesa/mesa-examples/pull/214) as a simple example model.
+- We reduced the core dependencies of Mesa, so that's a lighter and simpler install. You can now use extras to install the dependencies, for example add `[viz]` to install all visualisation dependencies: `pip install -U --pre mesa[viz]`. See [#2265](https://github.com/projectmesa/mesa/pull/2265) for details.
+- The [Mesa Overview](https://mesa.readthedocs.io/latest/overview.html) as fully updated for 3.0. We highly recommend reading though it!
+- We made some more progress on the experimental Cell Space, adding movement and integrating the PropertyLayer. Among others, Agents have nu initial movement capabilities for grids. Development continues during the betas and
+
+We plan to release one or two more beta's in the coming weeks, and tag a release candidate and Mesa 3.0 late October. In the [v3.0 milestone](https://github.com/projectmesa/mesa/milestone/43) are the critical items on our todo-list.
+
+You can install this pre-release as usual with:
+
+```bash
+pip install --upgrade --pre mesa
+```
+We're very curious what you think, try it out and ask any questions or share any feedback [here](https://github.com/projectmesa/mesa/discussions/2338)!
+
+## What's Changed
+### ⚠️ Breaking changes
+* update `Agent.__init__` to remove deprecation warning by @quaquel in https://github.com/projectmesa/mesa/pull/2328
+### 🎉 New features added
+* Visualize PropertyLayers by @EwoutH in https://github.com/projectmesa/mesa/pull/2336
+### 🧪 Experimental features
+* Encapsulate cell movement in properties by @quaquel in https://github.com/projectmesa/mesa/pull/2333
+* experimental: Integrate PropertyLayers into cell space by @EwoutH in https://github.com/projectmesa/mesa/pull/2319
+* Generalize CellAgent by @Corvince in https://github.com/projectmesa/mesa/pull/2292
+### 🛠 Enhancements made
+* Reduce core dependencies, split in optional dependencies by @EwoutH in https://github.com/projectmesa/mesa/pull/2265
+### 🐛 Bugs fixed
+* viz: stop running and disable buttons when model.running is False by @wang-boyu in https://github.com/projectmesa/mesa/pull/2332
+### 📜 Documentation improvements
+* docs: Update overview for Mesa 3.0 by @EwoutH in https://github.com/projectmesa/mesa/pull/2317
+* Readthedocs: Add version switch and update URL by @EwoutH in https://github.com/projectmesa/mesa/pull/2324
+### 🔧 Maintenance
+* tests: Resolve warnings by removing scheduler and updating arguments by @EwoutH in https://github.com/projectmesa/mesa/pull/2329
+* add super call to Model and remove self.schedule by @quaquel in https://github.com/projectmesa/mesa/pull/2334
+### Other changes
+* Deprecate `initialize_data_collector` by @EwoutH in https://github.com/projectmesa/mesa/pull/2327
+
+**Full Changelog**: https://github.com/projectmesa/mesa/compare/v3.0.0a5...v3.0.0b0
+
 # 3.0.0a5 (2024-09-21)
 ## Highlights
 Mesa v3.0 alpha 5 release contains many quality of life updates, a big new feature for the DataCollector and a major deprecation.
diff --git a/LICENSE b/LICENSE
index b130ab1f..d6456956 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,13 +1,202 @@
-Copyright 2023 Core Mesa Team and contributors
 
-Licensed under the Apache License, Version 2.0 (the "License");
-you may not use this file except in compliance with the License.
-You may obtain a copy of the License at
+                                 Apache License
+                           Version 2.0, January 2004
+                        http://www.apache.org/licenses/
 
-    http://www.apache.org/licenses/LICENSE-2.0
+   TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
 
-Unless required by applicable law or agreed to in writing, software
-distributed under the License is distributed on an "AS IS" BASIS,
-WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-See the License for the specific language governing permissions and
-limitations under the License.
+   1. Definitions.
+
+      "License" shall mean the terms and conditions for use, reproduction,
+      and distribution as defined by Sections 1 through 9 of this document.
+
+      "Licensor" shall mean the copyright owner or entity authorized by
+      the copyright owner that is granting the License.
+
+      "Legal Entity" shall mean the union of the acting entity and all
+      other entities that control, are controlled by, or are under common
+      control with that entity. For the purposes of this definition,
+      "control" means (i) the power, direct or indirect, to cause the
+      direction or management of such entity, whether by contract or
+      otherwise, or (ii) ownership of fifty percent (50%) or more of the
+      outstanding shares, or (iii) beneficial ownership of such entity.
+
+      "You" (or "Your") shall mean an individual or Legal Entity
+      exercising permissions granted by this License.
+
+      "Source" form shall mean the preferred form for making modifications,
+      including but not limited to software source code, documentation
+      source, and configuration files.
+
+      "Object" form shall mean any form resulting from mechanical
+      transformation or translation of a Source form, including but
+      not limited to compiled object code, generated documentation,
+      and conversions to other media types.
+
+      "Work" shall mean the work of authorship, whether in Source or
+      Object form, made available under the License, as indicated by a
+      copyright notice that is included in or attached to the work
+      (an example is provided in the Appendix below).
+
+      "Derivative Works" shall mean any work, whether in Source or Object
+      form, that is based on (or derived from) the Work and for which the
+      editorial revisions, annotations, elaborations, or other modifications
+      represent, as a whole, an original work of authorship. For the purposes
+      of this License, Derivative Works shall not include works that remain
+      separable from, or merely link (or bind by name) to the interfaces of,
+      the Work and Derivative Works thereof.
+
+      "Contribution" shall mean any work of authorship, including
+      the original version of the Work and any modifications or additions
+      to that Work or Derivative Works thereof, that is intentionally
+      submitted to Licensor for inclusion in the Work by the copyright owner
+      or by an individual or Legal Entity authorized to submit on behalf of
+      the copyright owner. For the purposes of this definition, "submitted"
+      means any form of electronic, verbal, or written communication sent
+      to the Licensor or its representatives, including but not limited to
+      communication on electronic mailing lists, source code control systems,
+      and issue tracking systems that are managed by, or on behalf of, the
+      Licensor for the purpose of discussing and improving the Work, but
+      excluding communication that is conspicuously marked or otherwise
+      designated in writing by the copyright owner as "Not a Contribution."
+
+      "Contributor" shall mean Licensor and any individual or Legal Entity
+      on behalf of whom a Contribution has been received by Licensor and
+      subsequently incorporated within the Work.
+
+   2. Grant of Copyright License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      copyright license to reproduce, prepare Derivative Works of,
+      publicly display, publicly perform, sublicense, and distribute the
+      Work and such Derivative Works in Source or Object form.
+
+   3. Grant of Patent License. Subject to the terms and conditions of
+      this License, each Contributor hereby grants to You a perpetual,
+      worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+      (except as stated in this section) patent license to make, have made,
+      use, offer to sell, sell, import, and otherwise transfer the Work,
+      where such license applies only to those patent claims licensable
+      by such Contributor that are necessarily infringed by their
+      Contribution(s) alone or by combination of their Contribution(s)
+      with the Work to which such Contribution(s) was submitted. If You
+      institute patent litigation against any entity (including a
+      cross-claim or counterclaim in a lawsuit) alleging that the Work
+      or a Contribution incorporated within the Work constitutes direct
+      or contributory patent infringement, then any patent licenses
+      granted to You under this License for that Work shall terminate
+      as of the date such litigation is filed.
+
+   4. Redistribution. You may reproduce and distribute copies of the
+      Work or Derivative Works thereof in any medium, with or without
+      modifications, and in Source or Object form, provided that You
+      meet the following conditions:
+
+      (a) You must give any other recipients of the Work or
+          Derivative Works a copy of this License; and
+
+      (b) You must cause any modified files to carry prominent notices
+          stating that You changed the files; and
+
+      (c) You must retain, in the Source form of any Derivative Works
+          that You distribute, all copyright, patent, trademark, and
+          attribution notices from the Source form of the Work,
+          excluding those notices that do not pertain to any part of
+          the Derivative Works; and
+
+      (d) If the Work includes a "NOTICE" text file as part of its
+          distribution, then any Derivative Works that You distribute must
+          include a readable copy of the attribution notices contained
+          within such NOTICE file, excluding those notices that do not
+          pertain to any part of the Derivative Works, in at least one
+          of the following places: within a NOTICE text file distributed
+          as part of the Derivative Works; within the Source form or
+          documentation, if provided along with the Derivative Works; or,
+          within a display generated by the Derivative Works, if and
+          wherever such third-party notices normally appear. The contents
+          of the NOTICE file are for informational purposes only and
+          do not modify the License. You may add Your own attribution
+          notices within Derivative Works that You distribute, alongside
+          or as an addendum to the NOTICE text from the Work, provided
+          that such additional attribution notices cannot be construed
+          as modifying the License.
+
+      You may add Your own copyright statement to Your modifications and
+      may provide additional or different license terms and conditions
+      for use, reproduction, or distribution of Your modifications, or
+      for any such Derivative Works as a whole, provided Your use,
+      reproduction, and distribution of the Work otherwise complies with
+      the conditions stated in this License.
+
+   5. Submission of Contributions. Unless You explicitly state otherwise,
+      any Contribution intentionally submitted for inclusion in the Work
+      by You to the Licensor shall be under the terms and conditions of
+      this License, without any additional terms or conditions.
+      Notwithstanding the above, nothing herein shall supersede or modify
+      the terms of any separate license agreement you may have executed
+      with Licensor regarding such Contributions.
+
+   6. Trademarks. This License does not grant permission to use the trade
+      names, trademarks, service marks, or product names of the Licensor,
+      except as required for reasonable and customary use in describing the
+      origin of the Work and reproducing the content of the NOTICE file.
+
+   7. Disclaimer of Warranty. Unless required by applicable law or
+      agreed to in writing, Licensor provides the Work (and each
+      Contributor provides its Contributions) on an "AS IS" BASIS,
+      WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+      implied, including, without limitation, any warranties or conditions
+      of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+      PARTICULAR PURPOSE. You are solely responsible for determining the
+      appropriateness of using or redistributing the Work and assume any
+      risks associated with Your exercise of permissions under this License.
+
+   8. Limitation of Liability. In no event and under no legal theory,
+      whether in tort (including negligence), contract, or otherwise,
+      unless required by applicable law (such as deliberate and grossly
+      negligent acts) or agreed to in writing, shall any Contributor be
+      liable to You for damages, including any direct, indirect, special,
+      incidental, or consequential damages of any character arising as a
+      result of this License or out of the use or inability to use the
+      Work (including but not limited to damages for loss of goodwill,
+      work stoppage, computer failure or malfunction, or any and all
+      other commercial damages or losses), even if such Contributor
+      has been advised of the possibility of such damages.
+
+   9. Accepting Warranty or Additional Liability. While redistributing
+      the Work or Derivative Works thereof, You may choose to offer,
+      and charge a fee for, acceptance of support, warranty, indemnity,
+      or other liability obligations and/or rights consistent with this
+      License. However, in accepting such obligations, You may act only
+      on Your own behalf and on Your sole responsibility, not on behalf
+      of any other Contributor, and only if You agree to indemnify,
+      defend, and hold each Contributor harmless for any liability
+      incurred by, or claims asserted against, such Contributor by reason
+      of your accepting any such warranty or additional liability.
+
+   END OF TERMS AND CONDITIONS
+
+   APPENDIX: How to apply the Apache License to your work.
+
+      To apply the Apache License to your work, attach the following
+      boilerplate notice, with the fields enclosed by brackets "[]"
+      replaced with your own identifying information. (Don't include
+      the brackets!)  The text should be enclosed in the appropriate
+      comment syntax for the file format. We also recommend that a
+      file or class name and description of purpose be included on the
+      same "printed page" as the copyright notice for easier
+      identification within third-party archives.
+
+   Copyright [yyyy] [name of copyright owner]
+
+   Licensed under the Apache License, Version 2.0 (the "License");
+   you may not use this file except in compliance with the License.
+   You may obtain a copy of the License at
+
+       http://www.apache.org/licenses/LICENSE-2.0
+
+   Unless required by applicable law or agreed to in writing, software
+   distributed under the License is distributed on an "AS IS" BASIS,
+   WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+   See the License for the specific language governing permissions and
+   limitations under the License.
diff --git a/NOTICE b/NOTICE
new file mode 100644
index 00000000..abeac775
--- /dev/null
+++ b/NOTICE
@@ -0,0 +1,13 @@
+Copyright 2014-2024 Core Mesa Team and contributors
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+    https://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
diff --git a/README.md b/README.md
index 8b103bb3..ff8fad06 100644
--- a/README.md
+++ b/README.md
@@ -28,7 +28,7 @@ can be displayed in browser windows or Jupyter.*
 
 ## Using Mesa
 
-To install our latest stable release (2.3.x), run:
+To install our latest stable release (2.4.x), run:
 
 ``` bash
 pip install -U mesa
@@ -39,6 +39,17 @@ To install our latest pre-release (3.0.0 alpha), run:
 ``` bash
 pip install -U --pre mesa
 ```
+With Mesa 3.0, we don't install all our dependencies anymore by default.
+```bash
+# You can customize the additional dependencies you need, if you want. Available are:
+pip install -U --pre mesa[network,viz]
+
+# This is equivalent to our recommended dependencies:
+pip install -U --pre mesa[rec]
+
+# To install all, including developer, dependencies:
+pip install -U --pre mesa[all]
+```
 
 You can also use `pip` to install the latest GitHub version:
 
@@ -60,7 +71,7 @@ For resources or help on using Mesa, check out the following:
 -   [Visualization Tutorial](https://mesa.readthedocs.io/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
+-   [Mesa Examples](https://github.com/projectmesa/mesa-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/latest/) (the latest version docs if you're using a pre-release Mesa version)
diff --git a/benchmarks/BoltzmannWealth/boltzmann_wealth.py b/benchmarks/BoltzmannWealth/boltzmann_wealth.py
index 5423eb82..2d863efc 100644
--- a/benchmarks/BoltzmannWealth/boltzmann_wealth.py
+++ b/benchmarks/BoltzmannWealth/boltzmann_wealth.py
@@ -40,7 +40,7 @@ class BoltzmannWealth(mesa.Model):
             width: the width of the grid
             height: the height of the grid
         """
-        super().__init__(seed)
+        super().__init__(seed=seed)
         self.num_agents = n
         self.grid = mesa.space.MultiGrid(width, height, True)
         self.datacollector = mesa.DataCollector(
diff --git a/benchmarks/Schelling/schelling.py b/benchmarks/Schelling/schelling.py
index 52519efc..2b6614da 100644
--- a/benchmarks/Schelling/schelling.py
+++ b/benchmarks/Schelling/schelling.py
@@ -1,13 +1,22 @@
 """Schelling separation for performance benchmarking."""
 
+from __future__ import annotations
+
 from mesa import Model
-from mesa.experimental.cell_space import CellAgent, OrthogonalMooreGrid
+from mesa.experimental.cell_space import Cell, CellAgent, OrthogonalMooreGrid
 
 
 class SchellingAgent(CellAgent):
     """Schelling segregation agent."""
 
-    def __init__(self, model, agent_type, radius, homophily):
+    def __init__(
+        self,
+        model: Schelling,
+        agent_type: int,
+        radius: int,
+        homophily: float,
+        cell: Cell,
+    ):
         """Create a new Schelling agent.
 
         Args:
@@ -15,11 +24,13 @@ class SchellingAgent(CellAgent):
             agent_type: type of agent (minority=1, majority=0)
             radius: size of neighborhood of agent
             homophily: fraction of neighbors of the same type that triggers movement
+            cell: the cell in which the agent is located
         """
         super().__init__(model)
         self.type = agent_type
         self.radius = radius
         self.homophily = homophily
+        self.cell = cell
 
     def step(self):
         """Run one step of the agent."""
@@ -31,7 +42,7 @@ class SchellingAgent(CellAgent):
 
         # If unhappy, move:
         if similar < self.homophily:
-            self.move_to(self.model.grid.select_random_empty_cell())
+            self.cell = self.model.grid.select_random_empty_cell()
         else:
             self.model.happy += 1
 
@@ -41,6 +52,7 @@ class Schelling(Model):
 
     def __init__(
         self,
+        simulator=None,
         height=40,
         width=40,
         homophily=3,
@@ -48,11 +60,11 @@ class Schelling(Model):
         density=0.8,
         minority_pc=0.5,
         seed=None,
-        simulator=None,
     ):
         """Create a new Schelling model.
 
         Args:
+            simulator: simulator instance
             height: height of the grid
             width: width of the grid
             homophily: Minimum number of agents of same class needed to be happy
@@ -63,8 +75,9 @@ class Schelling(Model):
             simulator: a simulator instance
         """
         super().__init__(seed=seed)
-        self.minority_pc = minority_pc
         self.simulator = simulator
+        self.minority_pc = minority_pc
+        self.happy = 0
 
         self.grid = OrthogonalMooreGrid(
             [height, width],
@@ -80,8 +93,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, agent_type, radius, homophily)
-                agent.move_to(cell)
+                SchellingAgent(self, agent_type, radius, homophily, cell)
 
     def step(self):
         """Run one step of the model."""
diff --git a/benchmarks/WolfSheep/wolf_sheep.py b/benchmarks/WolfSheep/wolf_sheep.py
index aaeed135..f085ce42 100644
--- a/benchmarks/WolfSheep/wolf_sheep.py
+++ b/benchmarks/WolfSheep/wolf_sheep.py
@@ -10,14 +10,14 @@ Replication of the model found in NetLogo:
 import math
 
 from mesa import Model
-from mesa.experimental.cell_space import CellAgent, OrthogonalVonNeumannGrid
+from mesa.experimental.cell_space import CellAgent, FixedAgent, OrthogonalVonNeumannGrid
 from mesa.experimental.devs import ABMSimulator
 
 
 class Animal(CellAgent):
     """The base animal class."""
 
-    def __init__(self, model, energy, p_reproduce, energy_from_food):
+    def __init__(self, model, energy, p_reproduce, energy_from_food, cell):
         """Initializes an animal.
 
         Args:
@@ -25,43 +25,36 @@ class Animal(CellAgent):
             energy: starting amount of energy
             p_reproduce: probability of sexless reproduction
             energy_from_food: energy obtained from 1 unit of food
+            cell: the cell in which the animal starts
         """
         super().__init__(model)
         self.energy = energy
         self.p_reproduce = p_reproduce
         self.energy_from_food = energy_from_food
-
-    def random_move(self):
-        """Move to a random neighboring cell."""
-        self.move_to(self.cell.neighborhood.select_random_cell())
+        self.cell = cell
 
     def spawn_offspring(self):
         """Create offspring."""
         self.energy /= 2
-        offspring = self.__class__(
+        self.__class__(
             self.model,
             self.energy,
             self.p_reproduce,
             self.energy_from_food,
+            self.cell,
         )
-        offspring.move_to(self.cell)
 
     def feed(self): ...  # noqa: D102
 
-    def die(self):
-        """Die."""
-        self.cell.remove_agent(self)
-        self.remove()
-
     def step(self):
         """One step of the agent."""
-        self.random_move()
+        self.cell = self.cell.neighborhood.select_random_cell()
         self.energy -= 1
 
         self.feed()
 
         if self.energy < 0:
-            self.die()
+            self.remove()
         elif self.random.random() < self.p_reproduce:
             self.spawn_offspring()
 
@@ -91,10 +84,10 @@ class Wolf(Animal):
             self.energy += self.energy_from_food
 
             # Kill the sheep
-            sheep_to_eat.die()
+            sheep_to_eat.remove()
 
 
-class GrassPatch(CellAgent):
+class GrassPatch(FixedAgent):
     """A patch of grass that grows at a fixed rate and it is eaten by sheep."""
 
     @property
@@ -112,19 +105,19 @@ class GrassPatch(CellAgent):
                 function_args=[self, "fully_grown", True],
             )
 
-    def __init__(self, model, fully_grown, countdown, grass_regrowth_time):
+    def __init__(self, model, countdown, grass_regrowth_time, cell):
         """Creates a new patch of grass.
 
         Args:
             model: a model instance
-            fully_grown: (boolean) Whether the patch of grass is fully grown or not
             countdown: Time for the patch of grass to be fully grown again
             grass_regrowth_time : time to fully regrow grass
+            cell: the cell to which the patch of grass belongs
         """
-        # TODO:: fully grown can just be an int --> so one less param (i.e. countdown)
         super().__init__(model)
-        self._fully_grown = fully_grown
+        self._fully_grown = True if countdown == 0 else False  # Noqa: SIM210
         self.grass_regrowth_time = grass_regrowth_time
+        self.cell = cell
 
         if not self.fully_grown:
             self.model.simulator.schedule_event_relative(
@@ -191,13 +184,7 @@ class WolfSheep(Model):
                 self.random.randrange(self.height),
             )
             energy = self.random.randrange(2 * sheep_gain_from_food)
-            sheep = Sheep(
-                self,
-                energy,
-                sheep_reproduce,
-                sheep_gain_from_food,
-            )
-            sheep.move_to(self.grid[pos])
+            Sheep(self, energy, sheep_reproduce, sheep_gain_from_food, self.grid[pos])
 
         # Create wolves
         for _ in range(self.initial_wolves):
@@ -206,24 +193,14 @@ class WolfSheep(Model):
                 self.random.randrange(self.height),
             )
             energy = self.random.randrange(2 * wolf_gain_from_food)
-            wolf = Wolf(
-                self,
-                energy,
-                wolf_reproduce,
-                wolf_gain_from_food,
-            )
-            wolf.move_to(self.grid[pos])
+            Wolf(self, energy, wolf_reproduce, wolf_gain_from_food, self.grid[pos])
 
         # Create grass patches
         possibly_fully_grown = [True, False]
         for cell in self.grid:
             fully_grown = self.random.choice(possibly_fully_grown)
-            if fully_grown:
-                countdown = grass_regrowth_time
-            else:
-                countdown = self.random.randrange(grass_regrowth_time)
-            patch = GrassPatch(self, fully_grown, countdown, grass_regrowth_time)
-            patch.move_to(cell)
+            countdown = 0 if fully_grown else self.random.randrange(grass_regrowth_time)
+            GrassPatch(self, countdown, grass_regrowth_time, cell)
 
     def step(self):
         """Run one step of the model."""
diff --git a/docs/index.md b/docs/index.md
index 042fc64d..eceefe6c 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -38,10 +38,16 @@ notebook.*
 
 ## Using Mesa
 
-Getting started quickly:
+To install our latest stable release (2.4.x), run:
 
-```bash
-pip install mesa
+``` bash
+pip install -U mesa
+```
+
+To install our latest pre-release (3.0 alpha), run:
+
+``` bash
+pip install -U --pre mesa
 ```
 
 To launch an example model, clone the [repository](https://github.com/projectmesa/mesa) folder and invoke `mesa runserver` for one of the `examples/` subdirectories:
@@ -52,8 +58,10 @@ mesa runserver examples/wolf_sheep
 
 For more help on using Mesa, check out the following resources:
 
+- [Mesa Overview]
 - [Mesa Introductory Tutorial]
 - [Mesa Visualization Tutorial]
+- [Mesa Examples]
 - [GitHub Issue Tracker]
 - [Matrix chat room]
 - [PyPI]
@@ -99,6 +107,8 @@ 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 overview]: overview
+[mesa examples]: https://github.com/projectmesa/mesa-examples
 [mesa introductory tutorial]: tutorials/intro_tutorial
 [mesa visualization tutorial]: tutorials/visualization_tutorial
 [pypi]: https://pypi.python.org/pypi/Mesa/
diff --git a/docs/migration_guide.md b/docs/migration_guide.md
index 5b24b72f..4741b8fb 100644
--- a/docs/migration_guide.md
+++ b/docs/migration_guide.md
@@ -221,7 +221,10 @@ self.agents_by_type[AgentType].shuffle_do("step")
 2. If you were using `self.schedule.agents`, replace it with `self.agents`.
 3. If you were using `self.schedule.get_agent_count()`, replace it with `len(self.agents)`.
 4. If you were using `self.schedule.agents_by_type`, replace it with `self.agents_by_type`.
-5. Instead of `self.schedule.add()` and `self.schedule.remove()`, agents are now automatically added to and removed from the model's AgentSet when they are created or removed.
+5. Agents are now automatically added to or removed from the model's `AgentSet` (`model.agents`) when they are created or deleted, eliminating the need to manually call `self.schedule.add()` or `self.schedule.remove()`.
+   - However, you still need to explicitly remove the Agent itself by using `Agent.remove()`. Typically, this means:
+     - Replace `self.schedule.remove(agent)` with `agent.remove()` in the Model.
+     - Replace `self.model.schedule.remove(self)` with `self.remove()` within the Agent.
 
 From now on you're now not bound by 5 distinct schedulers, but can mix and match any combination of AgentSet methods (`do`, `shuffle`, `select`, etc.) to get the desired Agent activation.
 
@@ -292,3 +295,19 @@ def show_steps(model):
 
 SolaraViz(model, components=[show_steps])
 ```
+
+### Other changes
+#### Removal of Model.initialize_data_collector
+The `initialize_data_collector` in the Model class is removed. In the Model class, replace:
+
+Replace:
+```python
+self.initialize_data_collector(...)
+```
+
+With:
+```python
+self.datacollector = DataCollector(...)
+```
+
+- Ref: [PR #2327](https://github.com/projectmesa/mesa/pull/2327), Mesa-examples [PR #208](https://github.com/projectmesa/mesa-examples/pull/208))
diff --git a/mesa/__init__.py b/mesa/__init__.py
index 20ace7cf..6214d18d 100644
--- a/mesa/__init__.py
+++ b/mesa/__init__.py
@@ -23,7 +23,7 @@ __all__ = [
 ]
 
 __title__ = "mesa"
-__version__ = "3.0.0a5"
+__version__ = "3.0.0b0"
 __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 ec98efaa..4d28f474 100644
--- a/mesa/agent.py
+++ b/mesa/agent.py
@@ -46,39 +46,25 @@ class Agent:
     # 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:
+    def __init__(self, model: Model, *args, **kwargs) -> None:
         """Create a new agent.
 
         Args:
             model (Model): The model instance in which the agent exists.
-            args: currently ignored, to be fixed in 3.1
-            kwargs: currently ignored, to be fixed in 3.1
-        """
-        # 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)`."
-                )
+            args: passed on to super
+            kwargs: passed on to super
 
-        self.pos: Position | None = None
+        Notes:
+            to make proper use of python's super, in each class remove the arguments and
+            keyword arguments you need and pass on the rest to super
 
+        """
+        super().__init__(*args, **kwargs)
+
+        self.model: Model = model
         self.model.register_agent(self)
+        self.unique_id: int = next(self._ids[model])
+        self.pos: Position | None = None
 
     def remove(self) -> None:
         """Remove and delete the agent from the model."""
diff --git a/mesa/batchrunner.py b/mesa/batchrunner.py
index d50476c6..2a6d50a3 100644
--- a/mesa/batchrunner.py
+++ b/mesa/batchrunner.py
@@ -38,6 +38,9 @@ def batch_run(
     Returns:
         List[Dict[str, Any]]
 
+    Notes:
+        batch_run assumes the model has a `datacollector` attribute that has a DataCollector object initialized.
+
     """
     runs_list = []
     run_id = 0
@@ -173,6 +176,10 @@ def _collect_data(
     step: int,
 ) -> tuple[dict[str, Any], list[dict[str, Any]]]:
     """Collect model and agent data from a model using mesas datacollector."""
+    if not hasattr(model, "datacollector"):
+        raise AttributeError(
+            "The model does not have a datacollector attribute. Please add a DataCollector to your model."
+        )
     dc = model.datacollector
 
     model_data = {param: values[step] for param, values in dc.model_vars.items()}
diff --git a/mesa/experimental/cell_space/__init__.py b/mesa/experimental/cell_space/__init__.py
index 792dde61..69386a4c 100644
--- a/mesa/experimental/cell_space/__init__.py
+++ b/mesa/experimental/cell_space/__init__.py
@@ -6,7 +6,11 @@ expressive that the default grids available in `mesa.space`.
 """
 
 from mesa.experimental.cell_space.cell import Cell
-from mesa.experimental.cell_space.cell_agent import CellAgent
+from mesa.experimental.cell_space.cell_agent import (
+    CellAgent,
+    FixedAgent,
+    Grid2DMovingAgent,
+)
 from mesa.experimental.cell_space.cell_collection import CellCollection
 from mesa.experimental.cell_space.discrete_space import DiscreteSpace
 from mesa.experimental.cell_space.grid import (
@@ -22,6 +26,8 @@ __all__ = [
     "CellCollection",
     "Cell",
     "CellAgent",
+    "Grid2DMovingAgent",
+    "FixedAgent",
     "DiscreteSpace",
     "Grid",
     "HexGrid",
diff --git a/mesa/experimental/cell_space/cell.py b/mesa/experimental/cell_space/cell.py
index 51b2ad75..6c92afbe 100644
--- a/mesa/experimental/cell_space/cell.py
+++ b/mesa/experimental/cell_space/cell.py
@@ -2,15 +2,17 @@
 
 from __future__ import annotations
 
+from collections.abc import Callable
 from functools import cache, cached_property
 from random import Random
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Any
 
+from mesa.experimental.cell_space.cell_agent import CellAgent
 from mesa.experimental.cell_space.cell_collection import CellCollection
+from mesa.space import PropertyLayer
 
 if TYPE_CHECKING:
     from mesa.agent import Agent
-    from mesa.experimental.cell_space.cell_agent import CellAgent
 
 Coordinate = tuple[int, ...]
 
@@ -34,6 +36,7 @@ class Cell:
         "capacity",
         "properties",
         "random",
+        "_mesa_property_layers",
         "__dict__",
     ]
 
@@ -66,9 +69,10 @@ class Cell:
         self.agents: list[
             Agent
         ] = []  # TODO:: change to AgentSet or weakrefs? (neither is very performant, )
-        self.capacity: int = capacity
+        self.capacity: int | None = capacity
         self.properties: dict[Coordinate, object] = {}
         self.random = random
+        self._mesa_property_layers: dict[str, PropertyLayer] = {}
 
     def connect(self, other: Cell, key: Coordinate | None = None) -> None:
         """Connects this cell to another cell.
@@ -117,7 +121,6 @@ class Cell:
 
         """
         self.agents.remove(agent)
-        agent.cell = None
 
     @property
     def is_empty(self) -> bool:
@@ -133,7 +136,7 @@ class Cell:
         return f"Cell({self.coordinate}, {self.agents})"
 
     @cached_property
-    def neighborhood(self) -> CellCollection:
+    def neighborhood(self) -> CellCollection[Cell]:
         """Returns the direct neighborhood of the cell.
 
         This is equivalent to cell.get_neighborhood(radius=1)
@@ -145,7 +148,7 @@ class Cell:
     @cache  # noqa: B019
     def get_neighborhood(
         self, radius: int = 1, include_center: bool = False
-    ) -> CellCollection:
+    ) -> CellCollection[Cell]:
         """Returns a list of all neighboring cells for the given radius.
 
         For getting the direct neighborhood (i.e., radius=1) you can also use
@@ -191,3 +194,20 @@ class Cell:
             if not include_center:
                 neighborhood.pop(self, None)
             return neighborhood
+
+    # PropertyLayer methods
+    def get_property(self, property_name: str) -> Any:
+        """Get the value of a property."""
+        return self._mesa_property_layers[property_name].data[self.coordinate]
+
+    def set_property(self, property_name: str, value: Any):
+        """Set the value of a property."""
+        self._mesa_property_layers[property_name].set_cell(self.coordinate, value)
+
+    def modify_property(
+        self, property_name: str, operation: Callable, value: Any = None
+    ):
+        """Modify the value of a property."""
+        self._mesa_property_layers[property_name].modify_cell(
+            self.coordinate, operation, value
+        )
diff --git a/mesa/experimental/cell_space/cell_agent.py b/mesa/experimental/cell_space/cell_agent.py
index 76a7d16b..5dc967ee 100644
--- a/mesa/experimental/cell_space/cell_agent.py
+++ b/mesa/experimental/cell_space/cell_agent.py
@@ -2,41 +2,132 @@
 
 from __future__ import annotations
 
-from typing import TYPE_CHECKING
+from typing import TYPE_CHECKING, Protocol
 
-from mesa import Agent, Model
+from mesa.agent import Agent
 
 if TYPE_CHECKING:
-    from mesa.experimental.cell_space.cell import Cell
+    from mesa.experimental.cell_space import Cell
 
 
-class CellAgent(Agent):
-    """Cell Agent is an extension of the Agent class and adds behavior for moving in discrete spaces.
+class HasCellProtocol(Protocol):
+    """Protocol for discrete space cell holders."""
 
-    Attributes:
-        unique_id (int): A unique identifier for this agent.
-        model (Model): The model instance to which the agent belongs
-        pos: (Position | None): The position of the agent in the space
-        cell: (Cell | None): the cell which the agent occupies
-    """
+    cell: Cell
 
-    def __init__(self, model: Model) -> None:
-        """Create a new agent.
 
-        Args:
-            model (Model): The model instance in which the agent exists.
-        """
-        super().__init__(model)
-        self.cell: Cell | None = None
+class HasCell:
+    """Descriptor for cell movement behavior."""
 
-    def move_to(self, cell) -> None:
-        """Move agent to cell.
+    _mesa_cell: Cell | None = None
 
-        Args:
-            cell: cell to which agent is to move
+    @property
+    def cell(self) -> Cell | None:  # noqa: D102
+        return self._mesa_cell
 
-        """
+    @cell.setter
+    def cell(self, cell: Cell | None) -> None:
+        # remove from current cell
         if self.cell is not None:
             self.cell.remove_agent(self)
+
+        # update private attribute
+        self._mesa_cell = cell
+
+        # add to new cell
+        if cell is not None:
+            cell.add_agent(self)
+
+
+class BasicMovement:
+    """Mixin for moving agents in discrete space."""
+
+    def move_to(self: HasCellProtocol, cell: Cell) -> None:
+        """Move to a new cell."""
         self.cell = cell
+
+    def move_relative(self: HasCellProtocol, direction: tuple[int, ...]):
+        """Move to a cell relative to the current cell.
+
+        Args:
+            direction: The direction to move in.
+        """
+        new_cell = self.cell.connections.get(direction)
+        if new_cell is not None:
+            self.cell = new_cell
+        else:
+            raise ValueError(f"No cell in direction {direction}")
+
+
+class FixedCell(HasCell):
+    """Mixin for agents that are fixed to a cell."""
+
+    @property
+    def cell(self) -> Cell | None:  # noqa: D102
+        return self._mesa_cell
+
+    @cell.setter
+    def cell(self, cell: Cell) -> None:
+        if self.cell is not None:
+            raise ValueError("Cannot move agent in FixedCell")
+        self._mesa_cell = cell
+
         cell.add_agent(self)
+
+
+class CellAgent(Agent, HasCell, BasicMovement):
+    """Cell Agent is an extension of the Agent class and adds behavior for moving in discrete spaces.
+
+    Attributes:
+        cell (Cell): The cell the agent is currently in.
+    """
+
+    def remove(self):
+        """Remove the agent from the model."""
+        super().remove()
+        self.cell = None  # ensures that we are also removed from cell
+
+
+class FixedAgent(Agent, FixedCell):
+    """A patch in a 2D grid."""
+
+    def remove(self):
+        """Remove the agent from the model."""
+        super().remove()
+
+        # fixme we leave self._mesa_cell on the original value
+        #  so you cannot hijack remove() to move patches
+        self.cell.remove_agent(self)
+
+
+class Grid2DMovingAgent(CellAgent):
+    """Mixin for moving agents in 2D grids."""
+
+    # fmt: off
+    DIRECTION_MAP = {
+        "n": (-1, 0), "north": (-1, 0), "up": (-1, 0),
+        "s": (1, 0), "south": (1, 0), "down": (1, 0),
+        "e": (0, 1), "east": (0, 1), "right": (0, 1),
+        "w": (0, -1), "west": (0, -1), "left": (0, -1),
+        "ne": (-1, 1), "northeast": (-1, 1), "upright": (-1, 1),
+        "nw": (-1, -1), "northwest": (-1, -1), "upleft": (-1, -1),
+        "se": (1, 1), "southeast": (1, 1), "downright": (1, 1),
+        "sw": (1, -1), "southwest": (1, -1), "downleft": (1, -1)
+    }
+    # fmt: on
+
+    def move(self, direction: str, distance: int = 1):
+        """Move the agent in a cardinal direction.
+
+        Args:
+            direction: The cardinal direction to move in.
+            distance: The distance to move.
+        """
+        direction = direction.lower()  # Convert direction to lowercase
+
+        if direction not in self.DIRECTION_MAP:
+            raise ValueError(f"Invalid direction: {direction}")
+
+        move_vector = self.DIRECTION_MAP[direction]
+        for _ in range(distance):
+            self.move_relative(move_vector)
diff --git a/mesa/experimental/cell_space/discrete_space.py b/mesa/experimental/cell_space/discrete_space.py
index 0d3af169..1cfaedeb 100644
--- a/mesa/experimental/cell_space/discrete_space.py
+++ b/mesa/experimental/cell_space/discrete_space.py
@@ -2,12 +2,14 @@
 
 from __future__ import annotations
 
+from collections.abc import Callable
 from functools import cached_property
 from random import Random
-from typing import Generic, TypeVar
+from typing import Any, Generic, TypeVar
 
 from mesa.experimental.cell_space.cell import Cell
 from mesa.experimental.cell_space.cell_collection import CellCollection
+from mesa.space import PropertyLayer
 
 T = TypeVar("T", bound=Cell)
 
@@ -21,7 +23,7 @@ class DiscreteSpace(Generic[T]):
         random (Random): The random number generator
         cell_klass (Type) : the type of cell class
         empties (CellCollection) : collecction of all cells that are empty
-
+        property_layers (dict[str, PropertyLayer]): the property layers of the discrete space
     """
 
     def __init__(
@@ -47,6 +49,7 @@ class DiscreteSpace(Generic[T]):
 
         self._empties: dict[tuple[int, ...], None] = {}
         self._empties_initialized = False
+        self.property_layers: dict[str, PropertyLayer] = {}
 
     @property
     def cutoff_empties(self):  # noqa
@@ -73,3 +76,61 @@ class DiscreteSpace(Generic[T]):
     def select_random_empty_cell(self) -> T:
         """Select random empty cell."""
         return self.random.choice(list(self.empties))
+
+    # PropertyLayer methods
+    def add_property_layer(
+        self, property_layer: PropertyLayer, add_to_cells: bool = True
+    ):
+        """Add a property layer to the grid.
+
+        Args:
+            property_layer: the property layer to add
+            add_to_cells: whether to add the property layer to all cells (default: True)
+        """
+        if property_layer.name in self.property_layers:
+            raise ValueError(f"Property layer {property_layer.name} already exists.")
+        self.property_layers[property_layer.name] = property_layer
+        if add_to_cells:
+            for cell in self._cells.values():
+                cell._mesa_property_layers[property_layer.name] = property_layer
+
+    def remove_property_layer(self, property_name: str, remove_from_cells: bool = True):
+        """Remove a property layer from the grid.
+
+        Args:
+            property_name: the name of the property layer to remove
+            remove_from_cells: whether to remove the property layer from all cells (default: True)
+        """
+        del self.property_layers[property_name]
+        if remove_from_cells:
+            for cell in self._cells.values():
+                del cell._mesa_property_layers[property_name]
+
+    def set_property(
+        self, property_name: str, value, condition: Callable[[T], bool] | None = None
+    ):
+        """Set the value of a property for all cells in the grid.
+
+        Args:
+            property_name: the name of the property to set
+            value: the value to set
+            condition: a function that takes a cell and returns a boolean
+        """
+        self.property_layers[property_name].set_cells(value, condition)
+
+    def modify_properties(
+        self,
+        property_name: str,
+        operation: Callable,
+        value: Any = None,
+        condition: Callable[[T], bool] | None = None,
+    ):
+        """Modify the values of a specific property for all cells in the grid.
+
+        Args:
+            property_name: the name of the property to modify
+            operation: the operation to perform
+            value: the value to use in the operation
+            condition: a function that takes a cell and returns a boolean (used to filter cells)
+        """
+        self.property_layers[property_name].modify_cells(operation, value, condition)
diff --git a/mesa/experimental/devs/examples/wolf_sheep.py b/mesa/experimental/devs/examples/wolf_sheep.py
index 8d7d16d6..74318ef8 100644
--- a/mesa/experimental/devs/examples/wolf_sheep.py
+++ b/mesa/experimental/devs/examples/wolf_sheep.py
@@ -1,6 +1,7 @@
 """Example of using ABM simulator for Wolf-Sheep Predation Model."""
 
 import mesa
+from mesa.experimental.cell_space import FixedAgent
 from mesa.experimental.devs.simulator import ABMSimulator
 
 
@@ -90,7 +91,7 @@ class Wolf(Animal):
             sheep_to_eat.die()
 
 
-class GrassPatch(mesa.Agent):
+class GrassPatch(FixedAgent):
     """A patch of grass that grows at a fixed rate and it is eaten by sheep."""
 
     @property
diff --git a/mesa/model.py b/mesa/model.py
index a3227c10..e4f70793 100644
--- a/mesa/model.py
+++ b/mesa/model.py
@@ -48,8 +48,8 @@ class Model:
             seed: the seed for the random number generator
             kwargs: keyword arguments to pass onto super
         """
+        super().__init__(*args, **kwargs)
         self.running = True
-        self.schedule = None
         self.steps: int = 0
 
         self._setup_agent_registration()
@@ -210,14 +210,13 @@ class Model:
             tables: tables to collect
 
         """
-        if not hasattr(self, "schedule") or self.schedule is None:
-            raise RuntimeError(
-                "You must initialize the scheduler (self.schedule) before initializing the data collector."
-            )
-        if self.schedule.get_agent_count() == 0:
-            raise RuntimeError(
-                "You must add agents to the scheduler before initializing the data collector."
-            )
+        warnings.warn(
+            "initialize_data_collector() is deprecated. Please use the DataCollector class directly. "
+            "by using `self.datacollector = DataCollector(...)`.",
+            DeprecationWarning,
+            stacklevel=2,
+        )
+
         self.datacollector = DataCollector(
             model_reporters=model_reporters,
             agent_reporters=agent_reporters,
diff --git a/mesa/visualization/components/matplotlib.py b/mesa/visualization/components/matplotlib.py
index 6061356a..771978c1 100644
--- a/mesa/visualization/components/matplotlib.py
+++ b/mesa/visualization/components/matplotlib.py
@@ -1,116 +1,190 @@
 """Matplotlib based solara components for visualization MESA spaces and plots."""
 
-from collections import defaultdict
+import warnings
 
+import matplotlib.pyplot as plt
 import networkx as nx
+import numpy as np
 import solara
+from matplotlib.cm import ScalarMappable
+from matplotlib.colors import LinearSegmentedColormap, Normalize, to_rgba
 from matplotlib.figure import Figure
-from matplotlib.ticker import MaxNLocator
 
 import mesa
 from mesa.experimental.cell_space import VoronoiGrid
+from mesa.space import PropertyLayer
 from mesa.visualization.utils import update_counter
 
 
-def make_space_matplotlib(agent_portrayal=None):  # noqa: D103
+def make_space_matplotlib(agent_portrayal=None, propertylayer_portrayal=None):
+    """Create a Matplotlib-based space visualization component.
+
+    Args:
+        agent_portrayal (function): Function to portray agents
+        propertylayer_portrayal (dict): Dictionary of PropertyLayer portrayal specifications
+
+    Returns:
+        function: A function that creates a SpaceMatplotlib component
+    """
     if agent_portrayal is None:
 
         def agent_portrayal(a):
             return {"id": a.unique_id}
 
     def MakeSpaceMatplotlib(model):
-        return SpaceMatplotlib(model, agent_portrayal)
+        return SpaceMatplotlib(model, agent_portrayal, propertylayer_portrayal)
 
     return MakeSpaceMatplotlib
 
 
 @solara.component
-def SpaceMatplotlib(model, agent_portrayal, dependencies: list[any] | None = None):  # noqa: D103
+def SpaceMatplotlib(
+    model,
+    agent_portrayal,
+    propertylayer_portrayal,
+    dependencies: list[any] | None = None,
+):
+    """Create a Matplotlib-based space visualization component."""
     update_counter.get()
     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)
+        space = getattr(model, "space", None)
+
+    if isinstance(space, mesa.space._Grid):
+        _draw_grid(space, space_ax, agent_portrayal, propertylayer_portrayal, model)
     elif isinstance(space, mesa.space.ContinuousSpace):
-        _draw_continuous_space(space, space_ax, agent_portrayal)
+        _draw_continuous_space(space, space_ax, agent_portrayal, model)
+    elif isinstance(space, mesa.space.NetworkGrid):
+        _draw_network_grid(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)
-
+    elif space is None and propertylayer_portrayal:
+        draw_property_layers(space_ax, space, propertylayer_portrayal, model)
 
-# 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])
+    solara.FigureMatplotlib(space_fig, format="png", dependencies=dependencies)
 
-    # 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_property_layers(ax, space, propertylayer_portrayal, model):
+    """Draw PropertyLayers on the given axes.
+
+    Args:
+        ax (matplotlib.axes.Axes): The axes to draw on.
+        space (mesa.space._Grid): The space containing the PropertyLayers.
+        propertylayer_portrayal (dict): Dictionary of PropertyLayer portrayal specifications.
+        model (mesa.Model): The model instance.
+    """
+    for layer_name, portrayal in propertylayer_portrayal.items():
+        layer = getattr(model, layer_name, None)
+        if not isinstance(layer, PropertyLayer):
+            continue
+
+        data = layer.data.astype(float) if layer.data.dtype == bool else layer.data
+        width, height = data.shape if space is None else (space.width, space.height)
+
+        if space and data.shape != (width, height):
+            warnings.warn(
+                f"Layer {layer_name} dimensions ({data.shape}) do not match space dimensions ({width}, {height}).",
+                UserWarning,
+                stacklevel=2,
+            )
+
+        # Get portrayal properties, or use defaults
+        alpha = portrayal.get("alpha", 1)
+        vmin = portrayal.get("vmin", np.min(data))
+        vmax = portrayal.get("vmax", np.max(data))
+        colorbar = portrayal.get("colorbar", True)
+
+        # Draw the layer
+        if "color" in portrayal:
+            rgba_color = to_rgba(portrayal["color"])
+            normalized_data = (data - vmin) / (vmax - vmin)
+            rgba_data = np.full((*data.shape, 4), rgba_color)
+            rgba_data[..., 3] *= normalized_data * alpha
+            rgba_data = np.clip(rgba_data, 0, 1)
+            cmap = LinearSegmentedColormap.from_list(
+                layer_name, [(0, 0, 0, 0), (*rgba_color[:3], alpha)]
+            )
+            im = ax.imshow(
+                rgba_data.transpose(1, 0, 2),
+                extent=(0, width, 0, height),
+                origin="lower",
+            )
+            if colorbar:
+                norm = Normalize(vmin=vmin, vmax=vmax)
+                sm = ScalarMappable(norm=norm, cmap=cmap)
+                sm.set_array([])
+                ax.figure.colorbar(sm, ax=ax, orientation="vertical")
+
+        elif "colormap" in portrayal:
+            cmap = portrayal.get("colormap", "viridis")
+            if isinstance(cmap, list):
+                cmap = LinearSegmentedColormap.from_list(layer_name, cmap)
+            im = ax.imshow(
+                data.T,
+                cmap=cmap,
+                alpha=alpha,
+                vmin=vmin,
+                vmax=vmax,
+                extent=(0, width, 0, height),
+                origin="lower",
+            )
+            if colorbar:
+                plt.colorbar(im, ax=ax, label=layer_name)
+        else:
+            raise ValueError(
+                f"PropertyLayer {layer_name} portrayal must include 'color' or 'colormap'."
+            )
+
+
+def _draw_grid(space, space_ax, agent_portrayal, propertylayer_portrayal, model):
+    if propertylayer_portrayal:
+        draw_property_layers(space_ax, space, propertylayer_portrayal, model)
+
+    agent_data = _get_agent_data(space, agent_portrayal)
+
+    space_ax.set_xlim(0, space.width)
+    space_ax.set_ylim(0, space.height)
+    _split_and_scatter(agent_data, space_ax)
+
+    # Draw grid lines
+    for x in range(space.width + 1):
+        space_ax.axvline(x, color="gray", linestyle=":")
+    for y in range(space.height + 1):
+        space_ax.axhline(y, color="gray", linestyle=":")
+
+
+def _get_agent_data(space, agent_portrayal):
+    """Helper function to get agent data for visualization."""
+    x, y, s, c, m = [], [], [], [], []
+    for agents, pos in space.coord_iter():
+        if not agents:
+            continue
+        if not isinstance(agents, list):
+            agents = [agents]  # noqa PLW2901
+        for agent in agents:
+            data = agent_portrayal(agent)
+            x.append(pos[0] + 0.5)  # Center the agent in the cell
+            y.append(pos[1] + 0.5)  # Center the agent in the cell
+            default_size = (180 / max(space.width, space.height)) ** 2
+            s.append(data.get("size", default_size))
+            c.append(data.get("color", "tab:blue"))
+            m.append(data.get("shape", "o"))
+    return {"x": x, "y": y, "s": s, "c": c, "m": m}
 
-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 _split_and_scatter(portray_data, space_ax):
+    """Helper function to split and scatter agent data."""
+    for marker in set(portray_data["m"]):
+        mask = [m == marker for m in portray_data["m"]]
+        space_ax.scatter(
+            [x for x, show in zip(portray_data["x"], mask) if show],
+            [y for y, show in zip(portray_data["y"], mask) if show],
+            s=[s for s, show in zip(portray_data["s"], mask) if show],
+            c=[c for c, show in zip(portray_data["c"], mask) if show],
+            marker=marker,
+        )
 
 
 def _draw_network_grid(space, space_ax, agent_portrayal):
@@ -124,7 +198,7 @@ def _draw_network_grid(space, space_ax, agent_portrayal):
     )
 
 
-def _draw_continuous_space(space, space_ax, agent_portrayal):
+def _draw_continuous_space(space, space_ax, agent_portrayal, model):
     def portray(space):
         x = []
         y = []
@@ -139,15 +213,13 @@ def _draw_continuous_space(space, space_ax, agent_portrayal):
 
             # 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
+        return {"x": x, "y": y, "s": s, "c": c, "m": m}
 
     # Determine border style based on space.torus
     border_style = "solid" if not space.torus else (0, (5, 10))
@@ -186,8 +258,6 @@ def _draw_voronoi(space, space_ax, agent_portrayal):
                 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
@@ -216,10 +286,19 @@ def _draw_voronoi(space, space_ax, agent_portrayal):
             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
+        space_ax.plot(*zip(*polygon), color="black")  # Plot polygon edges in black
+
+
+def make_plot_measure(measure: str | dict[str, str] | list[str] | tuple[str]):
+    """Create a plotting function for a specified measure.
 
+    Args:
+        measure (str | dict[str, str] | list[str] | tuple[str]): Measure(s) to plot.
+
+    Returns:
+        function: A function that creates a PlotMatplotlib component.
+    """
 
-def make_plot_measure(measure: str | dict[str, str] | list[str] | tuple[str]):  # noqa: D103
     def MakePlotMeasure(model):
         return PlotMatplotlib(model, measure)
 
@@ -227,7 +306,17 @@ def make_plot_measure(measure: str | dict[str, str] | list[str] | tuple[str]):
 
 
 @solara.component
-def PlotMatplotlib(model, measure, dependencies: list[any] | None = None):  # noqa: D103
+def PlotMatplotlib(model, measure, dependencies: list[any] | None = None):
+    """Create a Matplotlib-based plot for a measure or measures.
+
+    Args:
+        model (mesa.Model): The model instance.
+        measure (str | dict[str, str] | list[str] | tuple[str]): Measure(s) to plot.
+        dependencies (list[any] | None): Optional dependencies for the plot.
+
+    Returns:
+        solara.FigureMatplotlib: A component for rendering the plot.
+    """
     update_counter.get()
     fig = Figure()
     ax = fig.subplots()
@@ -244,5 +333,5 @@ def PlotMatplotlib(model, measure, dependencies: list[any] | None = None):  # no
             ax.plot(df.loc[:, m], label=m)
         fig.legend()
     # Set integer x axis
-    ax.xaxis.set_major_locator(MaxNLocator(integer=True))
+    ax.xaxis.set_major_locator(plt.MaxNLocator(integer=True))
     solara.FigureMatplotlib(fig, dependencies=dependencies)
diff --git a/mesa/visualization/solara_viz.py b/mesa/visualization/solara_viz.py
index ed495732..4bca98d8 100644
--- a/mesa/visualization/solara_viz.py
+++ b/mesa/visualization/solara_viz.py
@@ -30,10 +30,8 @@ from typing import TYPE_CHECKING, Literal
 
 import reacton.core
 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
 
@@ -41,55 +39,6 @@ if TYPE_CHECKING:
     from mesa.model import Model
 
 
-# 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: Model | solara.Reactive[Model],
@@ -207,10 +156,16 @@ def ComponentsView(
         model: Model instance to pass to each component
     """
     wrapped_components = [_wrap_component(component) for component in components]
-
-    with solara.Column():
-        for component in wrapped_components:
-            component(model)
+    items = [component(model) for component in wrapped_components]
+    grid_layout_initial = make_initial_grid_layout(num_components=len(items))
+    grid_layout, set_grid_layout = solara.use_state(grid_layout_initial)
+    solara.GridDraggable(
+        items=items,
+        grid_layout=grid_layout,
+        resizable=True,
+        draggable=True,
+        on_grid_layout=set_grid_layout,
+    )
 
 
 JupyterViz = SolaraViz
@@ -225,6 +180,7 @@ def ModelController(model: solara.Reactive[Model], play_interval=100):
         play_interval (int, optional): Interval for playing the model steps in milliseconds.
     """
     playing = solara.use_reactive(False)
+    running = solara.use_reactive(True)
     original_model = solara.use_reactive(None)
 
     def save_initial_model():
@@ -236,19 +192,23 @@ def ModelController(model: solara.Reactive[Model], play_interval=100):
     solara.use_effect(save_initial_model, [model.value])
 
     async def step():
-        while playing.value:
+        while playing.value and running.value:
             await asyncio.sleep(play_interval / 1000)
             do_step()
 
-    solara.lab.use_task(step, dependencies=[playing.value], prefer_threaded=False)
+    solara.lab.use_task(
+        step, dependencies=[playing.value, running.value], prefer_threaded=False
+    )
 
     def do_step():
         """Advance the model by one step."""
         model.value.step()
+        running.value = model.value.running
 
     def do_reset():
         """Reset the model to its initial state."""
         playing.value = False
+        running.value = True
         model.value = copy.deepcopy(original_model.value)
 
     def do_play_pause():
@@ -261,9 +221,13 @@ def ModelController(model: solara.Reactive[Model], play_interval=100):
             label="▶" if not playing.value else "❚❚",
             color="primary",
             on_click=do_play_pause,
+            disabled=not running.value,
         )
         solara.Button(
-            label="Step", color="primary", on_click=do_step, disabled=playing.value
+            label="Step",
+            color="primary",
+            on_click=do_step,
+            disabled=playing.value or not running.value,
         )
 
 
@@ -439,11 +403,11 @@ def UserInputs(user_params, on_change=None):
             raise ValueError(f"{input_type} is not a supported input type")
 
 
-def make_initial_grid_layout(layout_types):
+def make_initial_grid_layout(num_components):
     """Create an initial grid layout for visualization components.
 
     Args:
-        layout_types: List of layout types (Space or Measure)
+        num_components: Number of components to display
 
     Returns:
         list: Initial grid layout configuration
@@ -457,7 +421,7 @@ def make_initial_grid_layout(layout_types):
             "x": 6 * (i % 2),
             "y": 16 * (i - i % 2),
         }
-        for i in range(len(layout_types))
+        for i in range(num_components)
     ]
 
 
diff --git a/pyproject.toml b/pyproject.toml
index edee30d1..fd48a030 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -36,36 +36,47 @@ classifiers = [
 ]
 readme = "README.md"
 dependencies = [
-  "click",
-  "cookiecutter",
-  "matplotlib",
-  "networkx",
   "numpy",
   "pandas",
-  "solara",
   "tqdm",
 ]
 dynamic = ["version"]
 
 [project.optional-dependencies]
+# User collections
+rec = ["mesa[network,viz]"]
+all = ["mesa[network,viz,dev,examples,docs]"]
+# Core dependencies
+network = [
+  "networkx",
+]
+viz = [
+  "matplotlib",
+  "solara",
+]
+# Dev and CI stuff
 dev = [
-  "ruff~=0.1.1",   # Update periodically
-  "pytest >= 4.6",
+  "mesa[rec]",
+  "ruff",
+  "pytest",
   "pytest-cov",
   "sphinx",
   "pytest-mock",
+  "cookiecutter",
 ]
 examples = [
-  "pytest >= 4.6",
-  "scipy"
+  "mesa[rec]",
+  "pytest",
+  "scipy",
 ]
 docs = [
+  "mesa[rec]",
   "sphinx",
   "ipython",
   "pydata_sphinx_theme",
   "seaborn",
   "myst-nb",
-  "myst-parser",         # Markdown in Sphinx
+  "myst-parser",  # Markdown in Sphinx
 ]
 
 [project.urls]


Test patch:
diff --git a/tests/test_agent.py b/tests/test_agent.py
index a0837de7..73aab897 100644
--- a/tests/test_agent.py
+++ b/tests/test_agent.py
@@ -9,7 +9,7 @@ from mesa.agent import Agent, AgentSet
 from mesa.model import Model
 
 
-class TestAgent(Agent):
+class AgentTest(Agent):
     """Agent class for testing."""
 
     def get_unique_identifier(self):
@@ -17,7 +17,7 @@ class TestAgent(Agent):
         return self.unique_id
 
 
-class TestAgentDo(Agent):
+class AgentDoTest(Agent):
     """Agent class for testing."""
 
     def __init__(
@@ -37,7 +37,7 @@ class TestAgentDo(Agent):
         return self.unique_id
 
     def do_add(self):  # noqa: D102
-        agent = TestAgentDo(self.model)
+        agent = AgentDoTest(self.model)
         self.agent_set.add(agent)
 
     def do_remove(self):  # noqa: D102
@@ -47,7 +47,7 @@ class TestAgentDo(Agent):
 def test_agent_removal():
     """Test agent removal."""
     model = Model()
-    agent = TestAgent(model)
+    agent = AgentTest(model)
     # Check if the agent is added
     assert agent in model.agents
 
@@ -60,7 +60,7 @@ def test_agentset():
     """Test agentset class."""
     # create agentset
     model = Model()
-    agents = [TestAgent(model) for _ in range(10)]
+    agents = [AgentTest(model) for _ in range(10)]
 
     agentset = AgentSet(agents, model)
 
@@ -81,13 +81,13 @@ def test_agentset():
     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, at_most=2)) == 2
     assert len(agentset.select(test_function, inplace=True)) == 5
     assert agentset.select(inplace=True) == agentset
     assert all(a1 == a2 for a1, a2 in zip(agentset.select(), agentset))
-    assert all(a1 == a2 for a1, a2 in zip(agentset.select(n=5), agentset[:5]))
+    assert all(a1 == a2 for a1, a2 in zip(agentset.select(at_most=5), agentset[:5]))
 
-    assert len(agentset.shuffle(inplace=False).select(n=5)) == 5
+    assert len(agentset.shuffle(inplace=False).select(at_most=5)) == 5
 
     def test_function(agent):
         return agent.unique_id
@@ -132,7 +132,7 @@ def test_agentset_initialization():
     empty_agentset = AgentSet([], model)
     assert len(empty_agentset) == 0
 
-    agents = [TestAgent(model) for _ in range(10)]
+    agents = [AgentTest(model) for _ in range(10)]
     agentset = AgentSet(agents, model)
     assert len(agentset) == 10
 
@@ -140,7 +140,7 @@ def test_agentset_initialization():
 def test_agentset_serialization():
     """Test pickleability of agentset."""
     model = Model()
-    agents = [TestAgent(model) for _ in range(5)]
+    agents = [AgentTest(model) for _ in range(5)]
     agentset = AgentSet(agents, model)
 
     serialized = pickle.dumps(agentset)
@@ -155,17 +155,17 @@ def test_agentset_serialization():
 def test_agent_membership():
     """Test agent membership in AgentSet."""
     model = Model()
-    agents = [TestAgent(model) for _ in range(5)]
+    agents = [AgentTest(model) for _ in range(5)]
     agentset = AgentSet(agents, model)
 
     assert agents[0] in agentset
-    assert TestAgent(model) not in agentset
+    assert AgentTest(model) not in agentset
 
 
 def test_agent_add_remove_discard():
     """Test adding, removing and discarding agents from AgentSet."""
     model = Model()
-    agent = TestAgent(model)
+    agent = AgentTest(model)
     agentset = AgentSet([], model)
 
     agentset.add(agent)
@@ -185,7 +185,7 @@ def test_agent_add_remove_discard():
 def test_agentset_get_item():
     """Test integer based access to AgentSet."""
     model = Model()
-    agents = [TestAgent(model) for _ in range(10)]
+    agents = [AgentTest(model) for _ in range(10)]
     agentset = AgentSet(agents, model)
 
     assert agentset[0] == agents[0]
@@ -199,7 +199,7 @@ def test_agentset_get_item():
 def test_agentset_do_str():
     """Test AgentSet.do with str."""
     model = Model()
-    agents = [TestAgent(model) for _ in range(10)]
+    agents = [AgentTest(model) for _ in range(10)]
     agentset = AgentSet(agents, model)
 
     with pytest.raises(AttributeError):
@@ -212,7 +212,7 @@ def test_agentset_do_str():
     # setup
     n = 10
     model = Model()
-    agents = [TestAgentDo(model) for _ in range(n)]
+    agents = [AgentDoTest(model) for _ in range(n)]
     agentset = AgentSet(agents, model)
     for agent in agents:
         agent.agent_set = agentset
@@ -222,7 +222,7 @@ def test_agentset_do_str():
 
     # setup
     model = Model()
-    agents = [TestAgentDo(model) for _ in range(10)]
+    agents = [AgentDoTest(model) for _ in range(10)]
     agentset = AgentSet(agents, model)
     for agent in agents:
         agent.agent_set = agentset
@@ -234,7 +234,7 @@ def test_agentset_do_str():
 def test_agentset_do_callable():
     """Test AgentSet.do with callable."""
     model = Model()
-    agents = [TestAgent(model) for _ in range(10)]
+    agents = [AgentTest(model) for _ in range(10)]
     agentset = AgentSet(agents, model)
 
     # Test callable with non-existent function
@@ -248,7 +248,7 @@ def test_agentset_do_callable():
     # setup for lambda function tests
     n = 10
     model = Model()
-    agents = [TestAgentDo(model) for _ in range(n)]
+    agents = [AgentDoTest(model) for _ in range(n)]
     agentset = AgentSet(agents, model)
     for agent in agents:
         agent.agent_set = agentset
@@ -259,7 +259,7 @@ def test_agentset_do_callable():
 
     # setup again for lambda function tests
     model = Model()
-    agents = [TestAgentDo(model) for _ in range(10)]
+    agents = [AgentDoTest(model) for _ in range(10)]
     agentset = AgentSet(agents, model)
     for agent in agents:
         agent.agent_set = agentset
@@ -277,7 +277,7 @@ def test_agentset_do_callable():
 
     # setup again for actual function tests
     model = Model()
-    agents = [TestAgentDo(model) for _ in range(n)]
+    agents = [AgentDoTest(model) for _ in range(n)]
     agentset = AgentSet(agents, model)
     for agent in agents:
         agent.agent_set = agentset
@@ -288,7 +288,7 @@ def test_agentset_do_callable():
 
     # setup again for actual function tests
     model = Model()
-    agents = [TestAgentDo(model) for _ in range(10)]
+    agents = [AgentDoTest(model) for _ in range(10)]
     agentset = AgentSet(agents, model)
     for agent in agents:
         agent.agent_set = agentset
@@ -301,7 +301,7 @@ def test_agentset_do_callable():
 def test_agentset_get():
     """Test AgentSet.get."""
     model = Model()
-    _ = [TestAgent(i, model) for i in range(10)]
+    [AgentTest(model) for _ in range(10)]
 
     agentset = model.agents
 
@@ -347,7 +347,7 @@ def test_agentset_get():
 def test_agentset_agg():
     """Test agentset.agg."""
     model = Model()
-    agents = [TestAgent(model) for i in range(10)]
+    agents = [AgentTest(model) for i in range(10)]
 
     # Assign some values to attributes
     for i, agent in enumerate(agents):
@@ -409,7 +409,7 @@ def test_agentset_set_method():
 def test_agentset_map_str():
     """Test AgentSet.map with strings."""
     model = Model()
-    agents = [TestAgent(model) for _ in range(10)]
+    agents = [AgentTest(model) for _ in range(10)]
     agentset = AgentSet(agents, model)
 
     with pytest.raises(AttributeError):
@@ -422,7 +422,7 @@ def test_agentset_map_str():
 def test_agentset_map_callable():
     """Test AgentSet.map with callable."""
     model = Model()
-    agents = [TestAgent(model) for _ in range(10)]
+    agents = [AgentTest(model) for _ in range(10)]
     agentset = AgentSet(agents, model)
 
     # Test callable with non-existent function
@@ -476,7 +476,7 @@ def test_agentset_shuffle_do():
 def test_agentset_get_attribute():
     """Test AgentSet.get for attributes."""
     model = Model()
-    agents = [TestAgent(model) for _ in range(10)]
+    agents = [AgentTest(model) for _ in range(10)]
     agentset = AgentSet(agents, model)
 
     unique_ids = agentset.get("unique_id")
@@ -488,7 +488,7 @@ def test_agentset_get_attribute():
     model = Model()
     agents = []
     for i in range(10):
-        agent = TestAgent(model)
+        agent = AgentTest(model)
         agent.i = i**2
         agents.append(agent)
     agentset = AgentSet(agents, model)
@@ -516,7 +516,7 @@ def test_agentset_select_by_type():
     """Test AgentSet.select for agent type."""
     model = Model()
     # Create a mix of agents of two different types
-    test_agents = [TestAgent(model) for _ in range(4)]
+    test_agents = [AgentTest(model) for _ in range(4)]
     other_agents = [OtherAgentType(model) for _ in range(6)]
 
     # Combine the two types of agents
@@ -524,9 +524,9 @@ def test_agentset_select_by_type():
     agentset = AgentSet(mixed_agents, model)
 
     # Test selection by type
-    selected_test_agents = agentset.select(agent_type=TestAgent)
+    selected_test_agents = agentset.select(agent_type=AgentTest)
     assert len(selected_test_agents) == len(test_agents)
-    assert all(isinstance(agent, TestAgent) for agent in selected_test_agents)
+    assert all(isinstance(agent, AgentTest) for agent in selected_test_agents)
     assert len(selected_test_agents) == 4
 
     selected_other_agents = agentset.select(agent_type=OtherAgentType)
@@ -542,7 +542,7 @@ def test_agentset_select_by_type():
 def test_agentset_shuffle():
     """Test AgentSet.shuffle."""
     model = Model()
-    test_agents = [TestAgent(model) for _ in range(12)]
+    test_agents = [AgentTest(model) for _ in range(12)]
 
     agentset = AgentSet(test_agents, model=model)
     agentset = agentset.shuffle()
@@ -627,16 +627,3 @@ def test_agentset_groupby():
     assert custom_result[False] == custom_agg(
         [agent.value for agent in agents if not agent.even]
     )
-
-
-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 ee18d067..c60232b5 100644
--- a/tests/test_batch_run.py
+++ b/tests/test_batch_run.py
@@ -5,7 +5,6 @@ from mesa.agent import Agent
 from mesa.batchrunner import _make_model_kwargs
 from mesa.datacollection import DataCollector
 from mesa.model import Model
-from mesa.time import BaseScheduler
 
 
 def test_make_model_kwargs():  # noqa: D103
@@ -52,7 +51,6 @@ class MockModel(Model):
         variable_model_param=None,
         variable_agent_param=None,
         fixed_model_param=None,
-        schedule=None,
         enable_agent_reporters=True,
         n_agents=3,
         **kwargs,
@@ -63,13 +61,11 @@ class MockModel(Model):
             variable_model_param: variable model parameters
             variable_agent_param: variable agent parameters
             fixed_model_param: fixed model parameters
-            schedule: schedule instance
             enable_agent_reporters: whether to enable agent reporters
             n_agents: number of agents
             kwargs: keyword arguments
         """
         super().__init__()
-        self.schedule = BaseScheduler(self) if schedule is None else schedule
         self.variable_model_param = variable_model_param
         self.variable_agent_param = variable_agent_param
         self.fixed_model_param = fixed_model_param
@@ -92,13 +88,13 @@ class MockModel(Model):
         else:
             agent_val = self.variable_agent_param
         for _ in range(self.n_agents):
-            self.schedule.add(MockAgent(self, agent_val))
+            MockAgent(self, agent_val)
 
     def get_local_model_param(self):  # noqa: D102
         return 42
 
     def step(self):  # noqa: D102
-        self.schedule.step()
+        self.agents.do("step")
         self.datacollector.collect(self)
 
 
diff --git a/tests/test_cell_space.py b/tests/test_cell_space.py
index 01420043..4d52e159 100644
--- a/tests/test_cell_space.py
+++ b/tests/test_cell_space.py
@@ -2,6 +2,7 @@
 
 import random
 
+import numpy as np
 import pytest
 
 from mesa import Model
@@ -9,12 +10,15 @@ from mesa.experimental.cell_space import (
     Cell,
     CellAgent,
     CellCollection,
+    FixedAgent,
+    Grid2DMovingAgent,
     HexGrid,
     Network,
     OrthogonalMooreGrid,
     OrthogonalVonNeumannGrid,
     VoronoiGrid,
 )
+from mesa.space import PropertyLayer
 
 
 def test_orthogonal_grid_neumann():
@@ -524,3 +528,166 @@ def test_cell_collection():
 
     cells = collection.select()
     assert len(cells) == len(collection)
+
+
+### PropertyLayer tests
+def test_property_layer_integration():
+    """Test integration of PropertyLayer with DiscrateSpace and Cell."""
+    width, height = 10, 10
+    grid = OrthogonalMooreGrid((width, height), torus=False)
+
+    # Test adding a PropertyLayer to the grid
+    elevation = PropertyLayer("elevation", width, height, default_value=0)
+    grid.add_property_layer(elevation)
+    assert "elevation" in grid.property_layers
+    assert len(grid.property_layers) == 1
+
+    # Test accessing PropertyLayer from a cell
+    cell = grid._cells[(0, 0)]
+    assert "elevation" in cell._mesa_property_layers
+    assert cell.get_property("elevation") == 0
+
+    # Test setting property value for a cell
+    cell.set_property("elevation", 100)
+    assert cell.get_property("elevation") == 100
+
+    # Test modifying property value for a cell
+    cell.modify_property("elevation", lambda x: x + 50)
+    assert cell.get_property("elevation") == 150
+
+    cell.modify_property("elevation", np.add, 50)
+    assert cell.get_property("elevation") == 200
+
+    # Test modifying PropertyLayer values
+    grid.set_property("elevation", 100, condition=lambda value: value == 200)
+    assert cell.get_property("elevation") == 100
+
+    # Test modifying PropertyLayer using numpy operations
+    grid.modify_properties("elevation", np.add, 50)
+    assert cell.get_property("elevation") == 150
+
+    # Test removing a PropertyLayer
+    grid.remove_property_layer("elevation")
+    assert "elevation" not in grid.property_layers
+    assert "elevation" not in cell._mesa_property_layers
+
+
+def test_multiple_property_layers():
+    """Test initialization of DiscrateSpace with PropertyLayers."""
+    width, height = 5, 5
+    elevation = PropertyLayer("elevation", width, height, default_value=0)
+    temperature = PropertyLayer("temperature", width, height, default_value=20)
+
+    # Test initialization with a single PropertyLayer
+    grid1 = OrthogonalMooreGrid((width, height), torus=False)
+    grid1.add_property_layer(elevation)
+    assert "elevation" in grid1.property_layers
+    assert len(grid1.property_layers) == 1
+
+    # Test initialization with multiple PropertyLayers
+    grid2 = OrthogonalMooreGrid((width, height), torus=False)
+    grid2.add_property_layer(temperature, add_to_cells=False)
+    grid2.add_property_layer(elevation, add_to_cells=True)
+
+    assert "temperature" in grid2.property_layers
+    assert "elevation" in grid2.property_layers
+    assert len(grid2.property_layers) == 2
+
+    # Modify properties
+    grid2.modify_properties("elevation", lambda x: x + 10)
+    grid2.modify_properties("temperature", lambda x: x + 5)
+
+    for cell in grid2.all_cells:
+        assert cell.get_property("elevation") == 10
+        # Assert error temperature, since it was not added to cells
+        with pytest.raises(KeyError):
+            cell.get_property("temperature")
+
+
+def test_property_layer_errors():
+    """Test error handling for PropertyLayers."""
+    width, height = 5, 5
+    grid = OrthogonalMooreGrid((width, height), torus=False)
+    elevation = PropertyLayer("elevation", width, height, default_value=0)
+
+    # Test adding a PropertyLayer with an existing name
+    grid.add_property_layer(elevation)
+    with pytest.raises(ValueError, match="Property layer elevation already exists."):
+        grid.add_property_layer(elevation)
+
+
+def test_cell_agent():  # noqa: D103
+    cell1 = Cell((1,), capacity=None, random=random.Random())
+    cell2 = Cell((2,), capacity=None, random=random.Random())
+
+    # connect
+    # add_agent
+    model = Model()
+    agent = CellAgent(model)
+
+    agent.cell = cell1
+    assert agent in cell1.agents
+
+    agent.cell = None
+    assert agent not in cell1.agents
+
+    agent.cell = cell2
+    assert agent not in cell1.agents
+    assert agent in cell2.agents
+
+    agent.cell = cell1
+    assert agent in cell1.agents
+    assert agent not in cell2.agents
+
+    agent.remove()
+    assert agent not in model._all_agents
+    assert agent not in cell1.agents
+    assert agent not in cell2.agents
+
+    model = Model()
+    agent = CellAgent(model)
+    agent.cell = cell1
+    agent.move_to(cell2)
+    assert agent not in cell1.agents
+    assert agent in cell2.agents
+
+
+def test_grid2DMovingAgent():  # noqa: D103
+    # we first test on a moore grid because all directions are defined
+    grid = OrthogonalMooreGrid((10, 10), torus=False)
+
+    model = Model()
+    agent = Grid2DMovingAgent(model)
+
+    agent.cell = grid[4, 4]
+    agent.move("up")
+    assert agent.cell == grid[3, 4]
+
+    grid = OrthogonalVonNeumannGrid((10, 10), torus=False)
+
+    model = Model()
+    agent = Grid2DMovingAgent(model)
+    agent.cell = grid[4, 4]
+
+    with pytest.raises(ValueError):  # test for invalid direction
+        agent.move("upright")
+
+    with pytest.raises(ValueError):  # test for unknown direction
+        agent.move("back")
+
+
+def test_patch():  # noqa: D103
+    cell1 = Cell((1,), capacity=None, random=random.Random())
+    cell2 = Cell((2,), capacity=None, random=random.Random())
+
+    # connect
+    # add_agent
+    model = Model()
+    agent = FixedAgent(model)
+    agent.cell = cell1
+
+    with pytest.raises(ValueError):
+        agent.cell = cell2
+
+    agent.remove()
+    assert agent not in model._agents
diff --git a/tests/test_datacollector.py b/tests/test_datacollector.py
index b2760fc1..0b5153d3 100644
--- a/tests/test_datacollector.py
+++ b/tests/test_datacollector.py
@@ -4,7 +4,6 @@ import unittest
 
 from mesa import Agent, Model
 from mesa.datacollection import DataCollector
-from mesa.time import BaseScheduler
 
 
 class MockAgent(Agent):
@@ -60,21 +59,17 @@ def agent_function_with_params(agent, multiplier, offset):  # noqa: D103
 class MockModel(Model):
     """Minimalistic model for testing purposes."""
 
-    schedule = BaseScheduler(None)
-
     def __init__(self):  # noqa: D107
         super().__init__()
-        self.schedule = BaseScheduler(self)
         self.model_val = 100
 
         self.n = 10
         for i in range(1, self.n + 1):
-            self.schedule.add(MockAgent(self, val=i))
-        self.initialize_data_collector(
+            MockAgent(self, val=i)
+        self.datacollector = DataCollector(
             model_reporters={
-                "total_agents": lambda m: m.schedule.get_agent_count(),
+                "total_agents": lambda m: len(m.agents),
                 "model_value": "model_val",
-                "model_calc": self.schedule.get_agent_count,
                 "model_calc_comp": [self.test_model_calc_comp, [3, 4]],
                 "model_calc_fail": [self.test_model_calc_comp, [12, 0]],
             },
@@ -95,7 +90,7 @@ class MockModel(Model):
             return None
 
     def step(self):  # noqa: D102
-        self.schedule.step()
+        self.agents.do("step")
         self.datacollector.collect(self)
 
 
@@ -132,13 +127,14 @@ class TestDataCollector(unittest.TestCase):
     def setUp(self):
         """Create the model and run it a set number of steps."""
         self.model = MockModel()
+        self.model.datacollector.collect(self.model)
         for i in range(7):
             if i == 4:
-                self.model.schedule.remove(self.model.schedule._agents[3])
+                self.model.agents[3].remove()
             self.model.step()
 
         # Write to table:
-        for agent in self.model.schedule.agents:
+        for agent in self.model.agents:
             agent.write_final_values()
 
     def step_assertion(self, model_var):  # noqa: D102
@@ -153,18 +149,15 @@ class TestDataCollector(unittest.TestCase):
         data_collector = self.model.datacollector
         assert "total_agents" in data_collector.model_vars
         assert "model_value" in data_collector.model_vars
-        assert "model_calc" in data_collector.model_vars
         assert "model_calc_comp" in data_collector.model_vars
         assert "model_calc_fail" in data_collector.model_vars
         length = 8
         assert len(data_collector.model_vars["total_agents"]) == length
         assert len(data_collector.model_vars["model_value"]) == length
-        assert len(data_collector.model_vars["model_calc"]) == length
         assert len(data_collector.model_vars["model_calc_comp"]) == length
         self.step_assertion(data_collector.model_vars["total_agents"])
         for element in data_collector.model_vars["model_value"]:
             assert element == 100
-        self.step_assertion(data_collector.model_vars["model_calc"])
         for element in data_collector.model_vars["model_calc_comp"]:
             assert element == 75
         for element in data_collector.model_vars["model_calc_fail"]:
@@ -226,7 +219,7 @@ class TestDataCollector(unittest.TestCase):
         model_vars = data_collector.get_model_vars_dataframe()
         agent_vars = data_collector.get_agent_vars_dataframe()
         table_df = data_collector.get_table_dataframe("Final_Values")
-        assert model_vars.shape == (8, 5)
+        assert model_vars.shape == (8, 4)
         assert agent_vars.shape == (77, 4)
         assert table_df.shape == (9, 2)
 
@@ -234,30 +227,6 @@ class TestDataCollector(unittest.TestCase):
             table_df = data_collector.get_table_dataframe("not a real table")
 
 
-class TestDataCollectorInitialization(unittest.TestCase):
-    """Tests for DataCollector initialization."""
-
-    def setUp(self):  # noqa: D102
-        self.model = Model()
-
-    def test_initialize_before_scheduler(self):  # noqa: D102
-        with self.assertRaises(RuntimeError) as cm:
-            self.model.initialize_data_collector()
-        self.assertEqual(
-            str(cm.exception),
-            "You must initialize the scheduler (self.schedule) before initializing the data collector.",
-        )
-
-    def test_initialize_before_agents_added_to_scheduler(self):  # noqa: D102
-        with self.assertRaises(RuntimeError) as cm:
-            self.model.schedule = BaseScheduler(self)
-            self.model.initialize_data_collector()
-        self.assertEqual(
-            str(cm.exception),
-            "You must add agents to the scheduler before initializing the data collector.",
-        )
-
-
 class TestDataCollectorWithAgentTypes(unittest.TestCase):
     """Tests for DataCollector with agent-type-specific reporters."""
 
diff --git a/tests/test_lifespan.py b/tests/test_lifespan.py
index 00e830bf..ac6fb892 100644
--- a/tests/test_lifespan.py
+++ b/tests/test_lifespan.py
@@ -6,7 +6,6 @@ import numpy as np
 
 from mesa import Agent, Model
 from mesa.datacollection import DataCollector
-from mesa.time import RandomActivation
 
 
 class LifeTimeModel(Model):
@@ -28,19 +27,20 @@ class LifeTimeModel(Model):
         )
 
         self.current_ID = 0
-        self.schedule = RandomActivation(self)
 
         for _ in range(n_agents):
-            self.schedule.add(FiniteLifeAgent(self.agent_lifetime, self))
+            FiniteLifeAgent(self.agent_lifetime, self)
+
+        self.datacollector.collect(self)
 
     def step(self):
         """Add agents back to n_agents in each step."""
+        self.agents.shuffle_do("step")
         self.datacollector.collect(self)
-        self.schedule.step()
 
-        if len(self.schedule.agents) < self.n_agents:
-            for _ in range(self.n_agents - len(self.schedule.agents)):
-                self.schedule.add(FiniteLifeAgent(self.agent_lifetime, self))
+        if len(self.agents) < self.n_agents:
+            for _ in range(self.n_agents - len(self.agents)):
+                FiniteLifeAgent(self.agent_lifetime, self)
 
     def run_model(self, step_count=100):  # noqa: D102
         for _ in range(step_count):
@@ -64,12 +64,12 @@ class FiniteLifeAgent(Agent):
         if not inactivated:
             self.steps += 1  # keep track of how many ticks are seen
             if np.random.binomial(1, 0.1) != 0:  # 10% chance of dying
-                self.model.schedule.remove(self)
+                self.remove()
 
     def inactivate(self):  # noqa: D102
         self.remaining_life -= 1
         if self.remaining_life < 0:
-            self.model.schedule.remove(self)
+            self.remove()
             return True
         return False
 
diff --git a/tests/test_model.py b/tests/test_model.py
index c7d17bf8..1931e002 100644
--- a/tests/test_model.py
+++ b/tests/test_model.py
@@ -8,7 +8,6 @@ def test_model_set_up():
     """Test Model initialization."""
     model = Model()
     assert model.running is True
-    assert model.schedule is None
     assert model.steps == 0
     model.step()
     assert model.steps == 1
diff --git a/tests/test_solara_viz.py b/tests/test_solara_viz.py
index 301294f2..a0d2b449 100644
--- a/tests/test_solara_viz.py
+++ b/tests/test_solara_viz.py
@@ -100,11 +100,14 @@ def test_call_space_drawer(mocker):  # noqa: D103
         "Shape": "circle",
         "color": "gray",
     }
+    propertylayer_portrayal = None
     # initialize with space drawer unspecified (use default)
     # component must be rendered for code to run
     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)
+    mock_space_matplotlib.assert_called_with(
+        model, agent_portrayal, propertylayer_portrayal
+    )
 
     # specify no space should be drawn
     mock_space_matplotlib.reset_mock()