Repository: projectmesa/mesa
Pull request #2047 (2024-02-22 04:30:41)

Add script to list unlabeled PR's since latest release
This is an automation of step 1 in https://github.com/projectmesa/mesa/pull/1917#issuecomment-1871352058. Step 2-6 shouldn't be automated, because step 5 is manual, and is tied to these steps. In order for step 2-6 to be fully automated, step 7 needs to happen first (which is doable, actually).

@EwoutH given that the file is now CONTRIBUTING.md instead of .rst, you may add the release checklist there.

github-actions[bot]: Performance benchmarks:

| Model        | Size   | Init time [95% CI]         | Run time [95% CI]       |
|--------------|--------|----------------------------|-------------------------|
| Schelling    | small  | 🔵 -0.3% [-0.6%, +0.0%]    | 🔵 -0.1% [-0.2%, +0.1%] |
| Schelling    | large  | 🔵 +12.4% [-18.7%, +54.5%] | 🟢 -6.9% [-9.3%, -4.5%] |
| WolfSheep    | small  | 🔵 -3.4% [-3.8%, -2.9%]    | 🔵 +0.4% [+0.1%, +0.6%] |
| WolfSheep    | large  | 🔵 -3.7% [-6.1%, -1.3%]    | 🟢 -7.1% [-9.9%, -4.2%] |
| BoidFlockers | small  | 🔵 +0.6% [-0.3%, +1.5%]    | 🔵 +2.7% [+2.2%, +3.3%] |
| BoidFlockers | large  | 🔵 +1.7% [+0.7%, +2.8%]    | 🔵 +2.2% [+1.6%, +2.7%] |


Patch:
diff --git a/.github/workflows/build_lint.yml b/.github/workflows/build_lint.yml
index c1dcedb8..2beefecb 100644
--- a/.github/workflows/build_lint.yml
+++ b/.github/workflows/build_lint.yml
@@ -7,11 +7,12 @@ on:
     - release**
     paths-ignore:
       - '**.md'
-      - '**.rst'
   pull_request:
     paths-ignore:
       - '**.md'
-      - '**.rst'
+  workflow_dispatch:
+  schedule:
+    - cron: '0 6 * * 1'
 
 # This will cancel previous run if a newer job that obsoletes the said previous
 # run, is started.
@@ -36,8 +37,6 @@ jobs:
             python-version: "3.11"
           - os: ubuntu
             python-version: "3.10"
-          - os: ubuntu
-            python-version: "3.9"
           # Disabled for now. See https://github.com/projectmesa/mesa/issues/1253
           #- os: ubuntu
           #  python-version: 'pypy-3.8'
@@ -48,30 +47,12 @@ jobs:
       uses: actions/setup-python@v5
       with:
         python-version: ${{ matrix.python-version }}
-    - if: ${{ runner.os == 'Windows' }}
-      # This is needed so that restoring cache on Windows is fast.
-      # See until https://github.com/actions/cache/issues/752 is resolved.
-      name: Use GNU tar
-      shell: cmd
-      run: |
-        echo "Adding GNU tar to PATH"
-        echo C:\Program Files\Git\usr\bin>>"%GITHUB_PATH%"
-    - uses: actions/cache@v4
-      with:
-        path: ${{ env.pythonLocation }}
-        key: ${{ runner.os }}-pip-${{ env.pythonLocation }}-${{ hashFiles('setup.py') }}
-    - name: Install dependencies
-      # Only if the cache misses
-      # Based on https://github.com/pypa/pip/issues/8049#issuecomment-633845028
-      # read_requirements.py should be removed once
-      # https://github.com/pypa/pip/issues/11440 is resolved.
-      if: steps.cache.outputs.cache-hit != 'true'
-      run: |
-        pip install toml
-        python tests/read_requirements.py > requirements.txt
-        pip install -r requirements.txt
+        cache: 'pip'
+    - name: Install uv
+      run: pip install uv
     - name: Install Mesa
-      run: pip install --no-deps .
+      # See https://github.com/astral-sh/uv/issues/1945
+      run: uv pip install --system .[dev]
     - name: Test with pytest
       run: pytest --durations=10 --cov=mesa tests/ --cov-report=xml
     - if: matrix.os == 'ubuntu'
@@ -86,22 +67,11 @@ jobs:
       uses: actions/setup-python@v5
       with:
         python-version: "3.12"
-    - uses: actions/cache@v4
-      with:
-        path: ${{ env.pythonLocation }}
-        key: test-examples-pip-${{ env.pythonLocation }}-${{ hashFiles('setup.py') }}
-    - name: Install dependencies
-      # Only if the cache misses
-      # Based on https://github.com/pypa/pip/issues/8049#issuecomment-633845028
-      # read_requirements.py should be removed once
-      # https://github.com/pypa/pip/issues/11440 is resolved.
-      if: steps.cache.outputs.cache-hit != 'true'
-      run: |
-        pip install toml
-        python tests/read_requirements.py > requirements.txt
-        pip install -r requirements.txt
+        cache: 'pip'
+    - name: Install uv
+      run: pip install uv
     - name: Install Mesa
-      run: pip install --no-deps .
+      run: uv pip install --system .[dev]
     - name: Checkout mesa-examples
       uses: actions/checkout@v4
       with:
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 5ce4e87b..b8f01c47 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -14,6 +14,8 @@ on:
       - '**.md'
       - '**.rst'
   workflow_dispatch:
+  schedule:
+    - cron: '0 6 * * 1'
 
 permissions:
   id-token: write
@@ -31,6 +33,7 @@ jobs:
         uses: actions/setup-python@v5
         with:
           python-version: "3.12"
+          cache: 'pip'
       - name: Install dependencies
         run: pip install -U pip hatch
       - name: Build distributions
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 32f2f7d5..0cbd083d 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -4,7 +4,7 @@ ci:
 repos:
 - repo: https://github.com/astral-sh/ruff-pre-commit
   # Ruff version.
-  rev: v0.2.0
+  rev: v0.4.3
   hooks:
     # Run the linter.
     - id: ruff
@@ -14,12 +14,12 @@ repos:
     - id: ruff-format
       types_or: [ python, pyi, jupyter ]
 -   repo: https://github.com/asottile/pyupgrade
-    rev: v3.15.0
+    rev: v3.15.2
     hooks:
     -   id: pyupgrade
-        args: [--py38-plus]
+        args: [--py310-plus]
 -   repo: https://github.com/pre-commit/pre-commit-hooks
-    rev: v4.5.0  # Use the ref you want to point at
+    rev: v4.6.0  # Use the ref you want to point at
     hooks:
     -   id: trailing-whitespace
     -   id: check-toml
diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md
index e8907553..63fd8cc5 100644
--- a/CODE_OF_CONDUCT.md
+++ b/CODE_OF_CONDUCT.md
@@ -1,46 +1,131 @@
-#  Contributor Covenant Code of Conduct
+# Contributor Covenant Code of Conduct
 
 ## Our Pledge
 
-In the interest of fostering an open and welcoming environment, we as contributors and maintainers pledge to making participation in our project and our community a harassment-free experience for everyone, regardless of age, body size, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual identity and orientation.
+We as members, contributors, and leaders pledge to make participation in our
+community a harassment-free experience for everyone, regardless of age, body
+size, visible or invisible disability, ethnicity, sex characteristics, gender
+identity and expression, level of experience, education, socio-economic status,
+nationality, personal appearance, race, caste, color, religion, or sexual
+identity and orientation.
+
+We pledge to act and interact in ways that contribute to an open, welcoming,
+diverse, inclusive, and healthy community.
 
 ## Our Standards
 
-Examples of behavior that contributes to creating a positive environment include:
+Examples of behavior that contributes to a positive environment for our
+community include:
 
-* Using welcoming and inclusive language
-* Being respectful of differing viewpoints and experiences
-* Gracefully accepting constructive criticism
-* Focusing on what is best for the community
-* Showing empathy towards other community members
+* Demonstrating empathy and kindness toward other people
+* Being respectful of differing opinions, viewpoints, and experiences
+* Giving and gracefully accepting constructive feedback
+* Accepting responsibility and apologizing to those affected by our mistakes,
+  and learning from the experience
+* Focusing on what is best not just for us as individuals, but for the overall
+  community
 
-Examples of unacceptable behavior by participants include:
+Examples of unacceptable behavior include:
 
-* The use of sexualized language or imagery and unwelcome sexual attention or advances
-* Trolling, insulting/derogatory comments, and personal or political attacks
+* The use of sexualized language or imagery, and sexual attention or advances of
+  any kind
+* Trolling, insulting or derogatory comments, and personal or political attacks
 * Public or private harassment
-* Publishing others' private information, such as a physical or electronic address, without explicit permission
-* Other conduct which could reasonably be considered inappropriate in a professional setting
+* Publishing others' private information, such as a physical or email address,
+  without their explicit permission
+* Other conduct which could reasonably be considered inappropriate in a
+  professional setting
 
-## Our Responsibilities
+## Enforcement Responsibilities
 
-Project maintainers are responsible for clarifying the standards of acceptable behavior and are expected to take appropriate and fair corrective action in response to any instances of unacceptable behavior.
+Community leaders are responsible for clarifying and enforcing our standards of
+acceptable behavior and will take appropriate and fair corrective action in
+response to any behavior that they deem inappropriate, threatening, offensive,
+or harmful.
 
-Project maintainers have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, or to ban temporarily or permanently any contributor for other behaviors that they deem inappropriate, threatening, offensive, or harmful.
+Community leaders have the right and responsibility to remove, edit, or reject
+comments, commits, code, wiki edits, issues, and other contributions that are
+not aligned to this Code of Conduct, and will communicate reasons for moderation
+decisions when appropriate.
 
 ## Scope
 
-This Code of Conduct applies both within project spaces and in public spaces when an individual is representing the project or its community. Examples of representing a project or community include using an official project e-mail address, posting via an official social media account, or acting as an appointed representative at an online or offline event. Representation of a project may be further defined and clarified by project maintainers.
+This Code of Conduct applies within all community spaces, and also applies when
+an individual is officially representing the community in public spaces.
+Examples of representing our community include using an official email address,
+posting via an official social media account, or acting as an appointed
+representative at an online or offline event.
 
 ## Enforcement
 
-Instances of abusive, harassing, or otherwise unacceptable behavior may be reported by using Matrix to direct message a [Mesa administrator](@edgeofchaos:matrix.org). The project team will review and investigate all complaints, and will respond in a way that it deems appropriate to the circumstances. The project team is obligated to maintain confidentiality with regard to the reporter of an incident. Further details of specific enforcement policies may be posted separately.
+Instances of abusive, harassing, or otherwise unacceptable behavior may be
+reported to the community leaders responsible for enforcement at [@jackiekazil](https://matrix.to/#/@jackiekazil:matrix.org) and/or [@edgeofchaos](https://matrix.to/#/@edgeofchaos:matrix.org). Violations posted as Github comments, discussion, etc maybe also be reported via the [report functionality](https://docs.github.com/en/communities/maintaining-your-safety-on-github/reporting-abuse-or-spam)
+All complaints will be reviewed and investigated promptly and fairly.
+
+All community leaders are obligated to respect the privacy and security of the
+reporter of any incident.
+
+## Enforcement Guidelines
+
+Community leaders will follow these Community Impact Guidelines in determining
+the consequences for any action they deem in violation of this Code of Conduct:
+
+### 1. Correction
+
+**Community Impact**: Use of inappropriate language or other behavior deemed
+unprofessional or unwelcome in the community.
+
+**Consequence**: A private, written warning from community leaders, providing
+clarity around the nature of the violation and an explanation of why the
+behavior was inappropriate. A public apology may be requested.
+
+### 2. Warning
+
+**Community Impact**: A violation through a single incident or series of
+actions.
 
-Project maintainers who do not follow or enforce the Code of Conduct in good faith may face temporary or permanent repercussions as determined by other members of the project's leadership.
+**Consequence**: A warning with consequences for continued behavior. No
+interaction with the people involved, including unsolicited interaction with
+those enforcing the Code of Conduct, for a specified period of time. This
+includes avoiding interactions in community spaces as well as external channels
+like social media. Violating these terms may lead to a temporary or permanent
+ban.
+
+### 3. Temporary Ban
+
+**Community Impact**: A serious violation of community standards, including
+sustained inappropriate behavior.
+
+**Consequence**: A temporary ban from any sort of interaction or public
+communication with the community for a specified period of time. No public or
+private interaction with the people involved, including unsolicited interaction
+with those enforcing the Code of Conduct, is allowed during this period.
+Violating these terms may lead to a permanent ban.
+
+### 4. Permanent Ban
+
+**Community Impact**: Demonstrating a pattern of violation of community
+standards, including sustained inappropriate behavior, harassment of an
+individual, or aggression toward or disparagement of classes of individuals.
+
+**Consequence**: A permanent ban from any sort of public interaction within the
+community.
 
 ## Attribution
 
-This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, available at [http://contributor-covenant.org/version/1/4][version]
+This Code of Conduct is adapted from the [Contributor Covenant][homepage],
+version 2.1, available at
+[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1].
+
+Community Impact Guidelines were inspired by
+[Mozilla's code of conduct enforcement ladder][Mozilla CoC].
+
+For answers to common questions about this code of conduct, see the FAQ at
+[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at
+[https://www.contributor-covenant.org/translations][translations].
 
-[homepage]: http://contributor-covenant.org
-[version]: http://contributor-covenant.org/version/1/4/
+[homepage]: https://www.contributor-covenant.org
+[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html
+[Mozilla CoC]: https://github.com/mozilla/diversity
+[FAQ]: https://www.contributor-covenant.org/faq
+[translations]: https://www.contributor-covenant.org/translations
diff --git a/HISTORY.md b/HISTORY.md
index e54e6455..191a7ebc 100644
--- a/HISTORY.md
+++ b/HISTORY.md
@@ -1,6 +1,124 @@
 ---
 title: Release History
 ---
+# 2.3.0 (2024-04-23)
+## Highlights
+Mesa 2.3.0 is a big feature release and the last feature release before 3.0.
+
+There are two main new features:
+- The experimental cell-centric discrete spaces, as added in #1994. It allows having cells with not only properties but also active behaviors: the `CellAgent`. Its inspired by NetLogo's [patches](https://ccl.northwestern.edu/netlogo/bind/primitive/patches.html) but extend and generalize this concept further.
+- Full support for discrete event scheduling, as added in #2066. It allows scheduling events (like Agent actions) at any time, including non-integer timesteps.
+
+There are a lot of other features: The Jupyter visualisation now supports easier way to specify sliders, `NetworkGrid.get_neighbors()` supports a radius, `AgentSet.get()` can retrieve multiple attributes and there are now benchmarks to track Mesa performance during development.
+
+Finally, 2.3.0 stabilizes the `AgentSet` (including `model.agents`), making it the first experimental Mesa feature that is taken out of it's experimental phase.
+
+Install this release with:
+```
+pip install --upgrade mesa
+```
+The Mesa 2.3.x-series supports Python 3.9 to 3.12. The next major release will require Python 3.10.
+
+## What's Changed
+### 🧪 Experimental features
+* Add cell-centric discrete spaces (experimental) by @Corvince in https://github.com/projectmesa/mesa/pull/1994
+### 🎉 New features added
+* Add performance benchmarking scripts by @EwoutH in https://github.com/projectmesa/mesa/pull/1979
+* feat: Implement Slider class for JupyterViz by @rht in https://github.com/projectmesa/mesa/pull/1972
+* Stabilize AgentSet by @EwoutH in https://github.com/projectmesa/mesa/pull/2065
+* Support discrete event scheduling by @quaquel in https://github.com/projectmesa/mesa/pull/2066
+### 🛠 Enhancements made
+* JupyterViz: Automatically deduce display name from model class by @rht in https://github.com/projectmesa/mesa/pull/1975
+* Add radius argument to NetworkGrid.get_neighbors() by @EwoutH in https://github.com/projectmesa/mesa/pull/1973
+* Speedup of Agentset.shuffle by @quaquel in https://github.com/projectmesa/mesa/pull/2010
+* feat: Let mesa runserver detect server.py as fallback by @rht in https://github.com/projectmesa/mesa/pull/2015
+* JupyterViz: {Convert make_plot & prepare ColorCard} to become Solara component by @rht in https://github.com/projectmesa/mesa/pull/2020
+* new feature: AgentSet.get can retrieve one or more then one attribute by @quaquel in https://github.com/projectmesa/mesa/pull/2044
+* Update CODE_OF_CONDUCT.md to version 2+ of contrib covenant by @jackiekazil in https://github.com/projectmesa/mesa/pull/2052
+* Improve flocking benchmark  by @coderbeta1 in https://github.com/projectmesa/mesa/pull/2054
+* Remove JupyterViz Altair marker overlap for huge grid size by @rht in https://github.com/projectmesa/mesa/pull/2062
+* Add tooltip option to Altair chart by @FoFFolo in https://github.com/projectmesa/mesa/pull/2082
+* feat: Display model seed & allow user to specify it in JupyterViz by @rht in https://github.com/projectmesa/mesa/pull/2069
+* warn if placing already placed agent by @puer-robustus in https://github.com/projectmesa/mesa/pull/2083
+### 🐛 Bugs fixed
+* fix: Apply default value to slider by @rht in https://github.com/projectmesa/mesa/pull/2016
+* fix: Initialize model _steps and _time during __new__ by @rht in https://github.com/projectmesa/mesa/pull/2026
+* fix: Use model.schedule only when it is not None by @rht in https://github.com/projectmesa/mesa/pull/2050
+* fix: Remove JupyterViz grid marker overlap for huge grid size by @rht in https://github.com/projectmesa/mesa/pull/2049
+### 📜 Documentation improvements
+* Improve readability of badges by @rht in https://github.com/projectmesa/mesa/pull/2009
+* More pythonic implementation of wolf sheep by @quaquel in https://github.com/projectmesa/mesa/pull/2011
+* Adding super().__init__() to MoneyModel tutorial by @sw23 in https://github.com/projectmesa/mesa/pull/2025
+* docs: Convert howto.rst -> howto.md via rst2myst by @rht in https://github.com/projectmesa/mesa/pull/2033
+* docs: Convert best-practices,overview,packages,mesa,index to .md via rst2myst by @rht in https://github.com/projectmesa/mesa/pull/2034
+* docs: Convert api/*.rst -> api/*.md via rst2myst by @rht in https://github.com/projectmesa/mesa/pull/2035
+* docs: Rewrite howto.md using ChatGPT for clarity and conciseness by @rht in https://github.com/projectmesa/mesa/pull/2037
+* docs: Corrected Contributing Guide Link to Ensure Accessibility by @sahusiddharth in https://github.com/projectmesa/mesa/pull/2057
+* Rename links to internal .rst files to .md by @rht in https://github.com/projectmesa/mesa/pull/2058
+* docs: improve introductory tutorial by @puer-robustus in https://github.com/projectmesa/mesa/pull/2087
+### 🔧 Maintenance
+* Quality of Life: Make codecov less meticulous by @Corvince in https://github.com/projectmesa/mesa/pull/1966
+* Add CI workflow for performance benchmarks by @EwoutH in https://github.com/projectmesa/mesa/pull/1983
+* tests: Resolve warnings by defining PropertyLayer dtypes by @EwoutH in https://github.com/projectmesa/mesa/pull/1987
+* benchmarks.yml: Fix PR branch checkout when triggered by comment by @EwoutH in https://github.com/projectmesa/mesa/pull/1998
+* Quality of life: automatically fix ruff errors by @Corvince in https://github.com/projectmesa/mesa/pull/2004
+* benchmarks.yml: Run on addition of label instead of comment by @EwoutH in https://github.com/projectmesa/mesa/pull/2002
+* ci: Move codespell to pre-commit by @rht in https://github.com/projectmesa/mesa/pull/2040
+* Schelling by @coderbeta1 in https://github.com/projectmesa/mesa/pull/2053
+* Move ruff lint settings into dedicated section by @Corvince in https://github.com/projectmesa/mesa/pull/2073
+* ci: Use uv pip for faster build by @rht in https://github.com/projectmesa/mesa/pull/2038
+* test: Remove place_agent duplicate warnings by @rht in https://github.com/projectmesa/mesa/pull/2086
+### Other changes
+* Minor edits to benchmarking code by @quaquel in https://github.com/projectmesa/mesa/pull/1985
+* build(deps): bump codecov/codecov-action from 3 to 4 by @dependabot in https://github.com/projectmesa/mesa/pull/2030
+* [pre-commit.ci] pre-commit autoupdate by @pre-commit-ci in https://github.com/projectmesa/mesa/pull/2029
+* tests: Speed up test_batch_run by @rht in https://github.com/projectmesa/mesa/pull/2039
+* Update benchmarks.yml by @Corvince in https://github.com/projectmesa/mesa/pull/2043
+* docs: Convert visualization .rst -> .md via rst2myst by @rht in https://github.com/projectmesa/mesa/pull/2036
+* docs: Convert CONTRIBUTING .rst -> .md via rst2myst by @rht in https://github.com/projectmesa/mesa/pull/2041
+* Correct wolf energy gained from eating sheep by @JackAtOmenApps in https://github.com/projectmesa/mesa/pull/2048
+* feat: Implement Altair version of grid visualization by @rht in https://github.com/projectmesa/mesa/pull/1991
+
+## New Contributors
+* @sw23 made their first contribution in https://github.com/projectmesa/mesa/pull/2025
+* @JackAtOmenApps made their first contribution in https://github.com/projectmesa/mesa/pull/2048
+* @coderbeta1 made their first contribution in https://github.com/projectmesa/mesa/pull/2054
+* @sahusiddharth made their first contribution in https://github.com/projectmesa/mesa/pull/2057
+* @FoFFolo made their first contribution in https://github.com/projectmesa/mesa/pull/2082
+* @puer-robustus made their first contribution in https://github.com/projectmesa/mesa/pull/2083
+
+**Full Changelog**: https://github.com/projectmesa/mesa/compare/v2.2.4...2.3.0
+
+# 2.3.0-rc1 (2024-04-18)
+Mesa 2.3.0-rc1 is pre-release in preparation for 2.3.0 stable. It had the same release notes as 2.3.0.
+
+# 2.2.4 (2024-01-26)
+## Highlights
+Mesa v2.2.4 is a small but important bugfix release for the 2.2 release series. It fixes an essential bug in where agents weren't shuffled in the `BaseScheduler`, affecting mainly the `RandomActivation` scheduler (effectively making it sequential activation)([#2007](https://github.com/projectmesa/mesa/pull/2007)). It also fixes a small behaviour change in `RandomActivationByType.agents_by_type()` ([#1996](https://github.com/projectmesa/mesa/pull/1996)). Furthermore, this release adds an internal clock to the `Model`, which allows to use a Mesa model without a scheduler (using the `AgentSet` API)([#1942](https://github.com/projectmesa/mesa/pull/1942)).
+
+Updating from previous 2.2 releases is highly recommended, especially when using the `RandomActivation` scheduler.
+
+## What's Changed
+### 🛠 Enhancements made
+* refactor: Remove dependence on model.schedule, add clock to Model by @rht in https://github.com/projectmesa/mesa/pull/1942
+### 🐛 Bugs fixed
+* Fix AgentSet inplace shuffle (and thus RandomActivation), add tests by @EwoutH and @quaquel in https://github.com/projectmesa/mesa/pull/2007
+* fix: Reverse dict key and value for agents_by_type by @rht in https://github.com/projectmesa/mesa/pull/1996
+
+**Full Changelog**: https://github.com/projectmesa/mesa/compare/v2.2.3...v2.2.4
+
+# 2.2.3 (2024-01-22)
+## Highlights
+Mesa 2.2.3 is a small release with two improvements to the experimental Solara visualisation, on request of one of our contributors. No stable features have changed.
+
+## What's Changed
+### 🧪 Experimental features
+* solara_viz: Add borders around ContinuousSpace by @EwoutH in https://github.com/projectmesa/mesa/pull/1988
+### 🐛 Bugs fixed
+* fix: Explicitly specify JupyterViz space view limits by @rht in https://github.com/projectmesa/mesa/pull/1984
+
+**Full Changelog**: https://github.com/projectmesa/mesa/compare/v2.2.2...v2.2.3
+
 # 2.2.2 (2024-01-22)
 
 ## Highlights
diff --git a/README.md b/README.md
index 78b1c5f4..424f7434 100644
--- a/README.md
+++ b/README.md
@@ -114,7 +114,7 @@ If you would like to add a feature, please reach out via [ticket](https://github
 join a dev session (see [Mesa discussions](https://github.com/projectmesa/mesa/discussions)). A feature is most likely
 to be added if you build it!
 
-Don't forget to checkout the [Contributors guide](https://github.com/projectmesa/mesa/blob/main/CONTRIBUTING.rst).
+Don't forget to checkout the [Contributors guide](https://github.com/projectmesa/mesa/blob/main/CONTRIBUTING.md).
 
 ## Citing Mesa
 
diff --git a/benchmarks/Flocking/flocking.py b/benchmarks/Flocking/flocking.py
index c45e753a..ff9f97c0 100644
--- a/benchmarks/Flocking/flocking.py
+++ b/benchmarks/Flocking/flocking.py
@@ -20,7 +20,7 @@ class Boid(mesa.Agent):
         - Alignment: try to fly in the same direction as the neighbors.
 
     Boids have a vision that defines the radius in which they look for their
-    neighbors to flock with. Their speed (a scalar) and velocity (a vector)
+    neighbors to flock with. Their speed (a scalar) and direction (a vector)
     define their movement. Separation is their desired minimum distance from
     any other Boid.
     """
@@ -29,9 +29,8 @@ class Boid(mesa.Agent):
         self,
         unique_id,
         model,
-        pos,
         speed,
-        velocity,
+        direction,
         vision,
         separation,
         cohere=0.03,
@@ -43,20 +42,18 @@ class Boid(mesa.Agent):
 
         Args:
             unique_id: Unique agent identifier.
-            pos: Starting position
             speed: Distance to move per step.
-            heading: numpy vector for the Boid's direction of movement.
+            direction: numpy vector for the Boid's direction of movement.
             vision: Radius to look around for nearby Boids.
             separation: Minimum distance to maintain from other Boids.
             cohere: the relative importance of matching neighbors' positions
             separate: the relative importance of avoiding close neighbors
-            match: the relative importance of matching neighbors' headings
+            match: the relative importance of matching neighbors' directions
 
         """
         super().__init__(unique_id, model)
-        self.pos = np.array(pos)
         self.speed = speed
-        self.velocity = velocity
+        self.direction = direction
         self.vision = vision
         self.separation = separation
         self.cohere_factor = cohere
@@ -77,14 +74,14 @@ class Boid(mesa.Agent):
             cohere += heading
             if self.model.space.get_distance(self.pos, neighbor.pos) < self.separation:
                 separation_vector -= heading
-            match_vector += neighbor.velocity
+            match_vector += neighbor.direction
         n = max(n, 1)
         cohere = cohere * self.cohere_factor
         separation_vector = separation_vector * self.separate_factor
         match_vector = match_vector * self.match_factor
-        self.velocity += (cohere + separation_vector + match_vector) / n
-        self.velocity /= np.linalg.norm(self.velocity)
-        new_pos = self.pos + self.velocity * self.speed
+        self.direction += (cohere + separation_vector + match_vector) / n
+        self.direction /= np.linalg.norm(self.direction)
+        new_pos = self.pos + self.direction * self.speed
         self.model.space.move_agent(self, new_pos)
 
 
@@ -95,16 +92,17 @@ class BoidFlockers(mesa.Model):
 
     def __init__(
         self,
-        seed,
-        population,
-        width,
-        height,
-        vision,
+        seed=None,
+        population=100,
+        width=100,
+        height=100,
+        vision=10,
         speed=1,
         separation=1,
         cohere=0.03,
         separate=0.015,
         match=0.05,
+        simulator=None,
     ):
         """
         Create a new Flockers model.
@@ -117,34 +115,34 @@ class BoidFlockers(mesa.Model):
             separation: What's the minimum distance each Boid will attempt to
                     keep from any other
             cohere, separate, match: factors for the relative importance of
-                    the three drives."""
+                    the three drives.
+        """
         super().__init__(seed=seed)
         self.population = population
-        self.vision = vision
-        self.speed = speed
-        self.separation = separation
+        self.width = width
+        self.height = height
+        self.simulator = simulator
+
         self.schedule = mesa.time.RandomActivation(self)
-        self.space = mesa.space.ContinuousSpace(width, height, True)
-        self.factors = {"cohere": cohere, "separate": separate, "match": match}
-        self.make_agents()
+        self.space = mesa.space.ContinuousSpace(self.width, self.height, True)
+        self.factors = {
+            "cohere": cohere,
+            "separate": separate,
+            "match": match,
+        }
 
-    def make_agents(self):
-        """
-        Create self.population agents, with random positions and starting headings.
-        """
         for i in range(self.population):
             x = self.random.random() * self.space.x_max
             y = self.random.random() * self.space.y_max
             pos = np.array((x, y))
-            velocity = np.random.random(2) * 2 - 1
+            direction = np.random.random(2) * 2 - 1
             boid = Boid(
-                i,
-                self,
-                pos,
-                self.speed,
-                velocity,
-                self.vision,
-                self.separation,
+                unique_id=i,
+                model=self,
+                speed=speed,
+                direction=direction,
+                vision=vision,
+                separation=separation,
                 **self.factors,
             )
             self.space.place_agent(boid, pos)
@@ -157,8 +155,8 @@ class BoidFlockers(mesa.Model):
 if __name__ == "__main__":
     import time
 
-    # model = BoidFlockers(15, 200, 100, 100, 5)
-    model = BoidFlockers(15, 400, 100, 100, 15)
+    # model = BoidFlockers(seed=15, population=200, width=100, height=100, vision=5)
+    model = BoidFlockers(seed=15, population=400, width=100, height=100, vision=15)
 
     start_time = time.perf_counter()
     for _ in range(100):
diff --git a/benchmarks/Schelling/schelling.py b/benchmarks/Schelling/schelling.py
index caf58f63..b7702a84 100644
--- a/benchmarks/Schelling/schelling.py
+++ b/benchmarks/Schelling/schelling.py
@@ -1,12 +1,14 @@
-import mesa
+from mesa import Model
+from mesa.experimental.cell_space import CellAgent, OrthogonalMooreGrid
+from mesa.time import RandomActivation
 
 
-class SchellingAgent(mesa.Agent):
+class SchellingAgent(CellAgent):
     """
     Schelling segregation agent
     """
 
-    def __init__(self, unique_id, model, agent_type):
+    def __init__(self, unique_id, model, agent_type, radius, homophily):
         """
         Create a new Schelling agent.
         Args:
@@ -16,53 +18,73 @@ class SchellingAgent(mesa.Agent):
         """
         super().__init__(unique_id, model)
         self.type = agent_type
+        self.radius = radius
+        self.homophily = homophily
 
     def step(self):
         similar = 0
-        for neighbor in self.model.grid.iter_neighbors(
-            self.pos, moore=True, radius=self.model.radius
-        ):
+        neighborhood = self.cell.neighborhood(radius=self.radius)
+        for neighbor in neighborhood.agents:
             if neighbor.type == self.type:
                 similar += 1
 
         # If unhappy, move:
-        if similar < self.model.homophily:
-            self.model.grid.move_to_empty(self)
+        if similar < self.homophily:
+            self.move_to(self.model.grid.select_random_empty_cell())
         else:
             self.model.happy += 1
 
 
-class Schelling(mesa.Model):
+class Schelling(Model):
     """
     Model class for the Schelling segregation model.
     """
 
     def __init__(
-        self, seed, height, width, homophily, radius, density, minority_pc=0.5
+        self,
+        height=40,
+        width=40,
+        homophily=3,
+        radius=1,
+        density=0.8,
+        minority_pc=0.5,
+        seed=None,
+        simulator=None,
     ):
-        """ """
+        """
+        Create a new Schelling model.
+
+        Args:
+            height, width: Size of the space.
+            density: Initial Chance for a cell to populated
+            minority_pc: Chances for an agent to be in minority class
+            homophily: Minimum number of agents of same class needed to be happy
+            radius: Search radius for checking similarity
+            seed: Seed for Reproducibility
+        """
         super().__init__(seed=seed)
-        self.height = height
-        self.width = width
-        self.density = density
         self.minority_pc = minority_pc
-        self.homophily = homophily
-        self.radius = radius
+        self.simulator = simulator
 
-        self.schedule = mesa.time.RandomActivation(self)
-        self.grid = mesa.space.SingleGrid(height, width, torus=True)
-
-        self.happy = 0
+        self.schedule = RandomActivation(self)
+        self.grid = OrthogonalMooreGrid(
+            [height, width],
+            torus=True,
+            capacity=1,
+            random=self.random,
+        )
 
         # Set up agents
         # We use a grid iterator that returns
         # the coordinates of a cell as well as
         # its contents. (coord_iter)
-        for _cont, pos in self.grid.coord_iter():
-            if self.random.random() < self.density:
+        for cell in self.grid:
+            if self.random.random() < density:
                 agent_type = 1 if self.random.random() < self.minority_pc else 0
-                agent = SchellingAgent(self.next_id(), self, agent_type)
-                self.grid.place_agent(agent, pos)
+                agent = SchellingAgent(
+                    self.next_id(), self, agent_type, radius, homophily
+                )
+                agent.move_to(cell)
                 self.schedule.add(agent)
 
     def step(self):
@@ -76,11 +98,12 @@ class Schelling(mesa.Model):
 if __name__ == "__main__":
     import time
 
-    # model = Schelling(15, 40, 40, 3, 1, 0.625)
-    model = Schelling(15, 100, 100, 8, 2, 0.8)
+    # model = Schelling(seed=15, height=40, width=40, homophily=3, radius=1, density=0.625)
+    model = Schelling(
+        seed=15, height=100, width=100, homophily=8, radius=2, density=0.8
+    )
 
     start_time = time.perf_counter()
     for _ in range(100):
         model.step()
-
     print(time.perf_counter() - start_time)
diff --git a/benchmarks/WolfSheep/wolf_sheep.py b/benchmarks/WolfSheep/wolf_sheep.py
index 90948a0c..ecb78594 100644
--- a/benchmarks/WolfSheep/wolf_sheep.py
+++ b/benchmarks/WolfSheep/wolf_sheep.py
@@ -9,41 +9,38 @@ Replication of the model found in NetLogo:
     Northwestern University, Evanston, IL.
 """
 
-import mesa
+import math
 
+from mesa import Model
+from mesa.experimental.cell_space import CellAgent, OrthogonalVonNeumannGrid
+from mesa.experimental.devs import ABMSimulator
 
-class Animal(mesa.Agent):
-    def __init__(self, unique_id, model, moore, energy, p_reproduce, energy_from_food):
+
+class Animal(CellAgent):
+    def __init__(self, unique_id, model, energy, p_reproduce, energy_from_food):
         super().__init__(unique_id, model)
         self.energy = energy
         self.p_reproduce = p_reproduce
         self.energy_from_food = energy_from_food
-        self.moore = moore
 
     def random_move(self):
-        next_moves = self.model.grid.get_neighborhood(self.pos, self.moore, True)
-        next_move = self.random.choice(next_moves)
-        # Now move:
-        self.model.grid.move_agent(self, next_move)
+        self.move_to(self.cell.neighborhood().select_random_cell())
 
     def spawn_offspring(self):
         self.energy /= 2
         offspring = self.__class__(
             self.model.next_id(),
             self.model,
-            self.moore,
             self.energy,
             self.p_reproduce,
             self.energy_from_food,
         )
-        self.model.grid.place_agent(offspring, self.pos)
-        self.model.schedule.add(offspring)
+        offspring.move_to(self.cell)
 
-    def feed(self):
-        ...
+    def feed(self): ...
 
     def die(self):
-        self.model.grid.remove_agent(self)
+        self.cell.remove_agent(self)
         self.remove()
 
     def step(self):
@@ -67,8 +64,9 @@ class Sheep(Animal):
 
     def feed(self):
         # If there is grass available, eat it
-        agents = self.model.grid.get_cell_list_contents(self.pos)
-        grass_patch = next(obj for obj in agents if isinstance(obj, GrassPatch))
+        grass_patch = next(
+            obj for obj in self.cell.agents if isinstance(obj, GrassPatch)
+        )
         if grass_patch.fully_grown:
             self.energy += self.energy_from_food
             grass_patch.fully_grown = False
@@ -80,44 +78,58 @@ class Wolf(Animal):
     """
 
     def feed(self):
-        agents = self.model.grid.get_cell_list_contents(self.pos)
-        sheep = [obj for obj in agents if isinstance(obj, Sheep)]
+        sheep = [obj for obj in self.cell.agents if isinstance(obj, Sheep)]
         if len(sheep) > 0:
             sheep_to_eat = self.random.choice(sheep)
-            self.energy += self.energy
+            self.energy += self.energy_from_food
 
             # Kill the sheep
             sheep_to_eat.die()
 
 
-class GrassPatch(mesa.Agent):
+class GrassPatch(CellAgent):
     """
     A patch of grass that grows at a fixed rate and it is eaten by sheep
     """
 
-    def __init__(self, unique_id, model, fully_grown, countdown):
+    @property
+    def fully_grown(self):
+        return self._fully_grown
+
+    @fully_grown.setter
+    def fully_grown(self, value: bool) -> None:
+        self._fully_grown = value
+
+        if not value:
+            self.model.simulator.schedule_event_relative(
+                setattr,
+                self.grass_regrowth_time,
+                function_args=[self, "fully_grown", True],
+            )
+
+    def __init__(self, unique_id, model, fully_grown, countdown, grass_regrowth_time):
         """
+        TODO:: fully grown can just be an int --> so one less param (i.e. countdown)
+
         Creates a new patch of grass
 
         Args:
-            grown: (boolean) Whether the patch of grass is fully grown or not
+            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
+            countdown : Time for the patch of grass to be fully regrown if fully grown is False
         """
         super().__init__(unique_id, model)
-        self.fully_grown = fully_grown
-        self.countdown = countdown
+        self._fully_grown = fully_grown
+        self.grass_regrowth_time = grass_regrowth_time
 
-    def step(self):
         if not self.fully_grown:
-            if self.countdown <= 0:
-                # Set as fully grown
-                self.fully_grown = True
-                self.countdown = self.model.grass_regrowth_time
-            else:
-                self.countdown -= 1
+            self.model.simulator.schedule_event_relative(
+                setattr, countdown, function_args=[self, "fully_grown", True]
+            )
 
 
-class WolfSheep(mesa.Model):
+class WolfSheep(Model):
     """
     Wolf-Sheep Predation Model
 
@@ -126,7 +138,7 @@ class WolfSheep(mesa.Model):
 
     def __init__(
         self,
-        seed,
+        simulator,
         height,
         width,
         initial_sheep,
@@ -136,34 +148,40 @@ class WolfSheep(mesa.Model):
         grass_regrowth_time,
         wolf_gain_from_food=13,
         sheep_gain_from_food=5,
-        moore=False,
+        seed=None,
     ):
         """
         Create a new Wolf-Sheep model with the given parameters.
 
         Args:
+            simulator: ABMSimulator instance
             initial_sheep: Number of sheep to start with
             initial_wolves: Number of wolves to start with
             sheep_reproduce: Probability of each sheep reproducing each step
             wolf_reproduce: Probability of each wolf reproducing each step
             wolf_gain_from_food: Energy a wolf gains from eating a sheep
-            grass: Whether to have the sheep eat grass for energy
             grass_regrowth_time: How long it takes for a grass patch to regrow
                                  once it is eaten
             sheep_gain_from_food: Energy sheep gain from grass, if enabled.
-            moore:
+            seed : the random seed
         """
         super().__init__(seed=seed)
         # Set parameters
         self.height = height
         self.width = width
+        self.simulator = simulator
+
         self.initial_sheep = initial_sheep
         self.initial_wolves = initial_wolves
-        self.grass_regrowth_time = grass_regrowth_time
 
-        self.schedule = mesa.time.RandomActivationByType(self)
-        self.grid = mesa.space.MultiGrid(self.height, self.width, torus=False)
+        self.grid = OrthogonalVonNeumannGrid(
+            [self.height, self.width],
+            torus=False,
+            capacity=math.inf,
+            random=self.random,
+        )
 
+        # Create sheep:
         for _ in range(self.initial_sheep):
             pos = (
                 self.random.randrange(self.width),
@@ -173,13 +191,11 @@ class WolfSheep(mesa.Model):
             sheep = Sheep(
                 self.next_id(),
                 self,
-                moore,
                 energy,
                 sheep_reproduce,
                 sheep_gain_from_food,
             )
-            self.grid.place_agent(sheep, pos)
-            self.schedule.add(sheep)
+            sheep.move_to(self.grid[pos])
 
         # Create wolves
         for _ in range(self.initial_wolves):
@@ -189,33 +205,50 @@ class WolfSheep(mesa.Model):
             )
             energy = self.random.randrange(2 * wolf_gain_from_food)
             wolf = Wolf(
-                self.next_id(), self, moore, energy, wolf_reproduce, wolf_gain_from_food
+                self.next_id(),
+                self,
+                energy,
+                wolf_reproduce,
+                wolf_gain_from_food,
             )
-            self.grid.place_agent(wolf, pos)
-            self.schedule.add(wolf)
+            wolf.move_to(self.grid[pos])
 
         # Create grass patches
         possibly_fully_grown = [True, False]
-        for _agent, pos in self.grid.coord_iter():
+        for cell in self.grid:
             fully_grown = self.random.choice(possibly_fully_grown)
             if fully_grown:
-                countdown = self.grass_regrowth_time
+                countdown = grass_regrowth_time
             else:
-                countdown = self.random.randrange(self.grass_regrowth_time)
-            patch = GrassPatch(self.next_id(), self, fully_grown, countdown)
-            self.grid.place_agent(patch, pos)
-            self.schedule.add(patch)
+                countdown = self.random.randrange(grass_regrowth_time)
+            patch = GrassPatch(
+                self.next_id(), self, fully_grown, countdown, grass_regrowth_time
+            )
+            patch.move_to(cell)
 
     def step(self):
-        self.schedule.step()
+        self.get_agents_of_type(Sheep).shuffle(inplace=True).do("step")
+        self.get_agents_of_type(Wolf).shuffle(inplace=True).do("step")
 
 
 if __name__ == "__main__":
     import time
 
-    model = WolfSheep(15, 25, 25, 60, 40, 0.2, 0.1, 20)
+    simulator = ABMSimulator()
+    model = WolfSheep(
+        simulator,
+        25,
+        25,
+        60,
+        40,
+        0.2,
+        0.1,
+        20,
+        seed=15,
+    )
+
+    simulator.setup(model)
 
     start_time = time.perf_counter()
-    for _ in range(100):
-        model.step()
+    simulator.run(100)
     print("Time:", time.perf_counter() - start_time)
diff --git a/benchmarks/global_benchmark.py b/benchmarks/global_benchmark.py
index 0f8d5379..539e1ca4 100644
--- a/benchmarks/global_benchmark.py
+++ b/benchmarks/global_benchmark.py
@@ -5,23 +5,28 @@ import sys
 import time
 import timeit
 
+from configurations import configurations
+
+from mesa.experimental.devs.simulator import ABMSimulator
+
 # making sure we use this version of mesa and not one
 # also installed in site_packages or so.
 sys.path.insert(0, os.path.abspath(".."))
 
-from configurations import configurations
-
 
 # Generic function to initialize and run a model
 def run_model(model_class, seed, parameters):
     start_init = timeit.default_timer()
-    model = model_class(seed=seed, **parameters)
-    #   time.sleep(0.001)
+    simulator = ABMSimulator()
+    model = model_class(simulator=simulator, seed=seed, **parameters)
+    simulator.setup(model)
 
     end_init_start_run = timeit.default_timer()
 
-    for _ in range(config["steps"]):
-        model.step()
+    simulator.run_for(config["steps"])
+
+    # for _ in range(config["steps"]):
+    #     model.step()
     #       time.sleep(0.0001)
     end_run = timeit.default_timer()
 
diff --git a/docs/apis/api_main.md b/docs/apis/api_main.md
index ae77d47a..e2e45242 100644
--- a/docs/apis/api_main.md
+++ b/docs/apis/api_main.md
@@ -9,4 +9,5 @@ space
 datacollection
 batchrunner
 visualization
+experimental
 ```
diff --git a/docs/apis/experimental.md b/docs/apis/experimental.md
new file mode 100644
index 00000000..0d20ba6f
--- /dev/null
+++ b/docs/apis/experimental.md
@@ -0,0 +1,60 @@
+# Experimental
+
+```{eval-rst}
+.. automodule:: experimental.__init__
+   :members:
+```
+
+## Cell Space
+
+```{eval-rst}
+.. automodule:: experimental.cell_space.__init__
+   :members:
+```
+
+```{eval-rst}
+.. automodule:: experimental.cell_space.cell
+   :members:
+```
+
+```{eval-rst}
+.. automodule:: experimental.cell_space.cell_agent
+   :members:
+```
+
+```{eval-rst}
+.. automodule:: experimental.cell_space.cell_collection
+   :members:
+```
+
+```{eval-rst}
+.. automodule:: experimental.cell_space.discrete_space
+   :members:
+```
+
+```{eval-rst}
+.. automodule:: experimental.cell_space.grid
+   :members:
+```
+
+```{eval-rst}
+.. automodule:: experimental.cell_space.network
+   :members:
+```
+
+## Devs
+
+```{eval-rst}
+.. automodule:: experimental.devs.__init__
+   :members:
+```
+
+```{eval-rst}
+.. automodule:: experimental.devs.eventlist
+   :members:
+```
+
+```{eval-rst}
+.. automodule:: experimental.devs.simulator
+   :members:
+```
diff --git a/docs/index.md b/docs/index.md
index 22812595..117dd906 100644
--- a/docs/index.md
+++ b/docs/index.md
@@ -20,11 +20,9 @@
 
 Mesa allows users to quickly create agent-based models using built-in core components (such as spatial grids and agent schedulers) or customized implementations; visualize them using a browser-based interface; and analyze their results using Python's data analysis tools. Its goal is to be the Python-based counterpart to NetLogo, Repast, or MASON.
 
-```{image} https://raw.githubusercontent.com/projectmesa/mesa/main/docs/images/Mesa_Screenshot.png
-:alt: A screenshot of the Schelling Model in Mesa
-:scale: 100%
-:width: 100%
-```
+
+![A screenshot of the Schelling Model in Mesa|100%](https://raw.githubusercontent.com/projectmesa/mesa/main/docs/images/Mesa_Screenshot.png)
+
 
 *Above: A Mesa implementation of the Schelling segregation model,
 being visualized in a browser window and analyzed in a Jupyter
@@ -55,7 +53,7 @@ For more help on using Mesa, check out the following resources:
 - [Mesa Introductory Tutorial]
 - [Mesa Visualization Tutorial]
 - [GitHub Issue Tracker]
-- [Email list]
+- [Matrix chat room]
 - [PyPI]
 
 ## Contributing back to Mesa
@@ -94,10 +92,10 @@ tutorials/adv_tutorial_legacy.ipynb
 - {ref}`modindex`
 - {ref}`search`
 
-[contributors guide]: https://github.com/projectmesa/mesa/blob/main/CONTRIBUTING.rst
-[email list]: https://groups.google.com/d/forum/projectmesa
+[contributors guide]: https://github.com/projectmesa/mesa/blob/main/CONTRIBUTING.md
 [github]: https://github.com/projectmesa/mesa/
 [github issue tracker]: https://github.com/projectmesa/mesa/issues
+[matrix chat room]: https://matrix.to/#/#project-mesa:matrix.org
 [mesa]: https://github.com/projectmesa/mesa/
 [mesa introductory tutorial]: tutorials/intro_tutorial.html
 [mesa visualization tutorial]: tutorials/visualization_tutorial.html
diff --git a/docs/mesa.md b/docs/mesa.md
index 08a7148c..6c3e8cc8 100644
--- a/docs/mesa.md
+++ b/docs/mesa.md
@@ -3,7 +3,7 @@
 ## Subpackages
 
 ```{toctree}
-mesa.visualization
+mesa.visualization_old
 ```
 
 ## Submodules
diff --git a/docs/mesa.visualization.md b/docs/mesa.visualization.md
deleted file mode 100644
index 5efa9662..00000000
--- a/docs/mesa.visualization.md
+++ /dev/null
@@ -1,46 +0,0 @@
-# mesa.visualization package
-
-## Subpackages
-
-```{toctree}
-mesa.visualization.modules
-```
-
-## Submodules
-
-## mesa.visualization.ModularVisualization module
-
-```{eval-rst}
-.. automodule:: mesa.visualization.ModularVisualization
-    :members:
-    :undoc-members:
-    :show-inheritance:
-```
-
-## mesa.visualization.TextVisualization module
-
-```{eval-rst}
-.. automodule:: mesa.visualization.TextVisualization
-    :members:
-    :undoc-members:
-    :show-inheritance:
-```
-
-## mesa.visualization.UserParam module
-
-```{eval-rst}
-.. automodule:: mesa.visualization.UserParam
-    :members:
-    :undoc-members:
-    :show-inheritance:
-
-```
-
-## Module contents
-
-```{eval-rst}
-.. automodule:: mesa.visualization
-    :members:
-    :undoc-members:
-    :show-inheritance:
-```
diff --git a/docs/mesa.visualization.modules.md b/docs/mesa.visualization.modules.md
deleted file mode 100644
index 6380f982..00000000
--- a/docs/mesa.visualization.modules.md
+++ /dev/null
@@ -1,76 +0,0 @@
-# mesa.visualization.modules package
-
-## Submodules
-
-## mesa.visualization.modules.BarChartVisualization module
-
-```{eval-rst}
-.. automodule:: mesa.visualization.modules.BarChartVisualization
-    :members:
-    :undoc-members:
-    :show-inheritance:
-```
-
-## mesa.visualization.modules.CanvasGridVisualization module
-
-```{eval-rst}
-.. automodule:: mesa.visualization.modules.CanvasGridVisualization
-    :members:
-    :undoc-members:
-    :show-inheritance:
-```
-
-## mesa.visualization.modules.ChartVisualization module
-
-```{eval-rst}
-.. automodule:: mesa.visualization.modules.ChartVisualization
-    :members:
-    :undoc-members:
-    :show-inheritance:
-```
-
-## mesa.visualization.modules.HexGridVisualization module
-
-```{eval-rst}
-.. automodule:: mesa.visualization.modules.HexGridVisualization
-    :members:
-    :undoc-members:
-    :show-inheritance:
-```
-
-## mesa.visualization.modules.NetworkVisualization module
-
-```{eval-rst}
-.. automodule:: mesa.visualization.modules.NetworkVisualization
-    :members:
-    :undoc-members:
-    :show-inheritance:
-```
-
-## mesa.visualization.modules.PieChartVisualization module
-
-```{eval-rst}
-.. automodule:: mesa.visualization.modules.PieChartVisualization
-    :members:
-    :undoc-members:
-    :show-inheritance:
-```
-
-## mesa.visualization.modules.TextVisualization module
-
-```{eval-rst}
-.. automodule:: mesa.visualization.modules.TextVisualization
-    :members:
-    :undoc-members:
-    :show-inheritance:
-
-```
-
-## Module contents
-
-```{eval-rst}
-.. automodule:: mesa.visualization.modules
-    :members:
-    :undoc-members:
-    :show-inheritance:
-```
diff --git a/docs/mesa.visualization_old.md b/docs/mesa.visualization_old.md
new file mode 100644
index 00000000..700eabf1
--- /dev/null
+++ b/docs/mesa.visualization_old.md
@@ -0,0 +1,46 @@
+# mesa.visualization_old package
+
+## Subpackages
+
+```{toctree}
+mesa.visualization_old.modules
+```
+
+## Submodules
+
+## mesa.visualization_old.ModularVisualization module
+
+```{eval-rst}
+.. automodule:: mesa.visualization_old.ModularVisualization
+    :members:
+    :undoc-members:
+    :show-inheritance:
+```
+
+## mesa.visualization_old.TextVisualization module
+
+```{eval-rst}
+.. automodule:: mesa.visualization_old.TextVisualization
+    :members:
+    :undoc-members:
+    :show-inheritance:
+```
+
+## mesa.visualization_old.UserParam module
+
+```{eval-rst}
+.. automodule:: mesa.visualization_old.UserParam
+    :members:
+    :undoc-members:
+    :show-inheritance:
+
+```
+
+## Module contents
+
+```{eval-rst}
+.. automodule:: mesa.visualization_old
+    :members:
+    :undoc-members:
+    :show-inheritance:
+```
diff --git a/docs/mesa.visualization_old.modules.md b/docs/mesa.visualization_old.modules.md
new file mode 100644
index 00000000..55d94b64
--- /dev/null
+++ b/docs/mesa.visualization_old.modules.md
@@ -0,0 +1,76 @@
+# mesa.visualization_old.modules package
+
+## Submodules
+
+## mesa.visualization_old.modules.BarChartVisualization module
+
+```{eval-rst}
+.. automodule:: mesa.visualization_old.modules.BarChartVisualization
+    :members:
+    :undoc-members:
+    :show-inheritance:
+```
+
+## mesa.visualization_old.modules.CanvasGridVisualization module
+
+```{eval-rst}
+.. automodule:: mesa.visualization_old.modules.CanvasGridVisualization
+    :members:
+    :undoc-members:
+    :show-inheritance:
+```
+
+## mesa.visualization_old.modules.ChartVisualization module
+
+```{eval-rst}
+.. automodule:: mesa.visualization_old.modules.ChartVisualization
+    :members:
+    :undoc-members:
+    :show-inheritance:
+```
+
+## mesa.visualization_old.modules.HexGridVisualization module
+
+```{eval-rst}
+.. automodule:: mesa.visualization_old.modules.HexGridVisualization
+    :members:
+    :undoc-members:
+    :show-inheritance:
+```
+
+## mesa.visualization_old.modules.NetworkVisualization module
+
+```{eval-rst}
+.. automodule:: mesa.visualization_old.modules.NetworkVisualization
+    :members:
+    :undoc-members:
+    :show-inheritance:
+```
+
+## mesa.visualization_old.modules.PieChartVisualization module
+
+```{eval-rst}
+.. automodule:: mesa.visualization_old.modules.PieChartVisualization
+    :members:
+    :undoc-members:
+    :show-inheritance:
+```
+
+## mesa.visualization_old.modules.TextVisualization module
+
+```{eval-rst}
+.. automodule:: mesa.visualization_old.modules.TextVisualization
+    :members:
+    :undoc-members:
+    :show-inheritance:
+
+```
+
+## Module contents
+
+```{eval-rst}
+.. automodule:: mesa.visualization_old.modules
+    :members:
+    :undoc-members:
+    :show-inheritance:
+```
diff --git a/docs/modular-visualization.md b/docs/modular-visualization.md
index 93fb4673..716c2f1b 100644
--- a/docs/modular-visualization.md
+++ b/docs/modular-visualization.md
@@ -1,4 +1,4 @@
-# Modular Visualization - An In-Depth Look
+# Modular Visualization - An In-Depth Look (Deprecated)
 
 Modular visualization is one of Mesa's core features. Mesa is designed
 to provide predefined visualization modules, which can be easily
@@ -127,7 +127,7 @@ Suppose we want a module which can get an arbitrary variable out of a
 model, and display its name and value. Let's create a new subclass:
 
 ```python
-from mesa.visualization.ModularTextVisualization import TextElement
+from mesa.visualization_old.ModularTextVisualization import TextElement
 
 class AttributeElement(TextElement):
     def __init__(self, attr_name):
@@ -205,7 +205,7 @@ Now let's take a look at the TextModule's Python counterpart,
 here's the whole thing:
 
 ```python
-from mesa.visualization.ModularVisualization import VisualizationElement
+from mesa.visualization_old.ModularVisualization import VisualizationElement
 
 class TextElement(VisualizationElement):
     js_includes = ["TextModule.js"]
@@ -240,8 +240,8 @@ the list, the code passes that element of the data to the *render*
 function of the corresponding element, in the elements array.
 
 Currently, module JavaScript files live in the
-*mesa/visualization/templates* directory, and the Python files live in
-*mesa/visualization/modules*.
+*mesa/visualization_old/templates* directory, and the Python files live in
+*mesa/visualization_old/modules*.
 
 When creating a new module, the Python and JavaScript code need to be
 written in synch: the module Python-side **render** method needs to
diff --git a/docs/overview.md b/docs/overview.md
index a384b02e..923d64d1 100644
--- a/docs/overview.md
+++ b/docs/overview.md
@@ -119,8 +119,8 @@ batch_df = batch_run.get_model_vars_dataframe()
 
 Finally, you may want to directly observe your model as it runs. Mesa's main visualization tool uses a small local web server to render the model in a browser, using JavaScript. There are different components for drawing different types of data: for example, grids for drawing agents moving around on a grid, or charts for showing how some data changes as the model runs. A few core modules are:
 
-- mesa.visualization.ModularVisualization
-- mesa.visualization.modules
+- mesa.visualization_old.ModularVisualization
+- mesa.visualization_old.modules
 
 To quickly spin up a model visualization, you might do something like:
 
@@ -135,8 +135,8 @@ def agent_portrayal(agent):
                  "r": 0.5}
     return portrayal
 
-grid = mesa.visualization.CanvasGrid(agent_portrayal, 10, 10, 500, 500)
-server = mesa.visualization.ModularServer(MyModel,
+grid = mesa.visualization_old.CanvasGrid(agent_portrayal, 10, 10, 500, 500)
+server = mesa.visualization_old.ModularServer(MyModel,
                        [grid],
                        "My Model",
                        {'n_agents': 10})
diff --git a/docs/packages.md b/docs/packages.md
index e15c6baa..8b13b30d 100644
--- a/docs/packages.md
+++ b/docs/packages.md
@@ -91,7 +91,7 @@ Most likely you created an ABM that has the code that you want to share in it, w
 >
 > 7. `git add` all the files to the repo, which means the repo starts to track the files. Then `git commit` the files with a meaningful message. To learn more about this see: [Saving changes](https://www.atlassian.com/git/tutorials/saving-changes). Finally, you will want to `git push` all your changes to GitHub, see: [Git Push](https://help.github.com/articles/pushing-to-a-remote/).
 >
-> 8. Let people know about your package on the [MESA Wiki Page](https://github.com/projectmesa/mesa/wiki) and share it on the [email list](https://groups.google.com/forum/#!forum/projectmesa). In the future, we will create more of a directory, but at this point we are not there yet.
+> 8. Let people know about your package on the [MESA Wiki Page](https://github.com/projectmesa/mesa/wiki) and share it in the [matrix chat room](https://matrix.to/#/#project-mesa:matrix.org). In the future, we will create more of a directory, but at this point we are not there yet.
 
 From this point, someone can clone your repo and then add your repo to their Python path and use it in their project. However, if you want to take your package to the next level, you will want to add more structure to your package and share it on PyPI.
 
diff --git a/docs/tutorials/adv_tutorial_legacy.ipynb b/docs/tutorials/adv_tutorial_legacy.ipynb
index c0c26e42..92b04f9b 100644
--- a/docs/tutorials/adv_tutorial_legacy.ipynb
+++ b/docs/tutorials/adv_tutorial_legacy.ipynb
@@ -86,7 +86,7 @@
    },
    "outputs": [],
    "source": [
-    "grid = mesa.visualization.CanvasGrid(agent_portrayal, 10, 10, 500, 500)"
+    "grid = mesa.visualization_old.CanvasGrid(agent_portrayal, 10, 10, 500, 500)"
    ]
   },
   {
@@ -109,7 +109,7 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "server = mesa.visualization.ModularServer(\n",
+    "server = mesa.visualization_old.ModularServer(\n",
     "    MoneyModel, [grid], \"Money Model\", {\"N\": 100, \"width\": 10, \"height\": 10}\n",
     ")\n",
     "server.port = 8521  # the default"
@@ -140,8 +140,8 @@
     "                 \"r\": 0.5}\n",
     "    return portrayal\n",
     "\n",
-    "grid = mesa.visualization.CanvasGrid(agent_portrayal, 10, 10, 500, 500)\n",
-    "server = mesa.visualization.ModularServer(MoneyModel,\n",
+    "grid = mesa.visualization_old.CanvasGrid(agent_portrayal, 10, 10, 500, 500)\n",
+    "server = mesa.visualization_old.ModularServer(MoneyModel,\n",
     "                       [grid],\n",
     "                       \"Money Model\",\n",
     "                       {\"N\":100, \"width\":10, \"height\":10})\n",
@@ -221,11 +221,11 @@
    "metadata": {},
    "outputs": [],
    "source": [
-    "chart = mesa.visualization.ChartModule(\n",
+    "chart = mesa.visualization_old.ChartModule(\n",
     "    [{\"Label\": \"Gini\", \"Color\": \"Black\"}], data_collector_name=\"datacollector\"\n",
     ")\n",
     "\n",
-    "server = mesa.visualization.ModularServer(\n",
+    "server = mesa.visualization_old.ModularServer(\n",
     "    MoneyModel, [grid, chart], \"Money Model\", {\"N\": 100, \"width\": 10, \"height\": 10}\n",
     ")"
    ]
@@ -253,12 +253,12 @@
     "    return portrayal\n",
     "\n",
     "\n",
-    "grid = mesa.visualization.CanvasGrid(agent_portrayal, 10, 10, 500, 500)\n",
-    "chart = mesa.visualization.ChartModule(\n",
+    "grid = mesa.visualization_old.CanvasGrid(agent_portrayal, 10, 10, 500, 500)\n",
+    "chart = mesa.visualization_old.ChartModule(\n",
     "    [{\"Label\": \"Gini\", \"Color\": \"Black\"}], data_collector_name=\"datacollector\"\n",
     ")\n",
     "\n",
-    "server = mesa.visualization.ModularServer(\n",
+    "server = mesa.visualization_old.ModularServer(\n",
     "    MoneyModel, [grid, chart], \"Money Model\", {\"N\": 100, \"width\": 10, \"height\": 10}\n",
     ")\n",
     "server.port = 8521  # The default\n",
@@ -421,7 +421,7 @@
     "In a Python file (either its own, or in the same file as your visualization code), import the `VisualizationElement` class we'll inherit from, and create the new visualization class.\n",
     "\n",
     "```python\n",
-    "    from mesa.visualization.ModularVisualization import VisualizationElement, CHART_JS_FILE\n",
+    "    from mesa.visualization_old.ModularVisualization import VisualizationElement, CHART_JS_FILE\n",
     "\n",
     "    class HistogramModule(VisualizationElement):\n",
     "        package_includes = [CHART_JS_FILE]\n",
@@ -438,7 +438,7 @@
     "            self.js_code = \"elements.push(\" + new_element + \");\"\n",
     "```\n",
     "\n",
-    "There are a few things going on here. `package_includes` is a list of JavaScript files that are part of Mesa itself that the visualization element relies on. You can see the included files in [mesa/visualization/templates/](https://github.com/projectmesa/mesa/tree/main/mesa/visualization/templates). Similarly, `local_includes` is a list of JavaScript files in the same directory as the class code itself. Note that both of these are class variables, not object variables -- they hold for all particular objects.\n",
+    "There are a few things going on here. `package_includes` is a list of JavaScript files that are part of Mesa itself that the visualization element relies on. You can see the included files in [mesa/visualization_old/templates/](https://github.com/projectmesa/mesa/tree/main/mesa/visualization_old/templates). Similarly, `local_includes` is a list of JavaScript files in the same directory as the class code itself. Note that both of these are class variables, not object variables -- they hold for all particular objects.\n",
     "\n",
     "Next, look at the `__init__` method. It takes three arguments: the number of bins, and the width and height for the histogram. It then uses these values to populate the `js_code` property; this is code that the server will insert into the visualization page, which will run when the page loads. In this case, it creates a new HistogramModule (the class we created in JavaScript in the step above) with the desired bins, width and height; it then appends  (`push`es) this object to `elements`, the list of visualization elements that the visualization page itself maintains.\n",
     "\n",
@@ -461,8 +461,8 @@
     "Now, you can create your new HistogramModule and add it to the server:\n",
     "\n",
     "```python\n",
-    "    histogram = mesa.visualization.HistogramModule(list(range(10)), 200, 500)\n",
-    "    server = mesa.visualization.ModularServer(MoneyModel, \n",
+    "    histogram = mesa.visualization_old.HistogramModule(list(range(10)), 200, 500)\n",
+    "    server = mesa.visualization_old.ModularServer(MoneyModel, \n",
     "                           [grid, histogram, chart], \n",
     "                           \"Money Model\", \n",
     "                           {\"N\":100, \"width\":10, \"height\":10})\n",
@@ -473,7 +473,7 @@
     "\n",
     "![Histogram Visualization](files/viz_histogram.png)\n",
     "\n",
-    "If you've felt comfortable with this section, it might be instructive to read the code for the [ModularServer](https://github.com/projectmesa/mesa/blob/main/mesa/visualization/ModularVisualization.py#L259) and the [modular_template](https://github.com/projectmesa/mesa/blob/main/mesa/visualization/templates/modular_template.html) to get a better idea of how all the pieces fit together."
+    "If you've felt comfortable with this section, it might be instructive to read the code for the [ModularServer](https://github.com/projectmesa/mesa/blob/main/mesa/visualization_old/ModularVisualization.py#L259) and the [modular_template](https://github.com/projectmesa/mesa/blob/main/mesa/visualization_old/templates/modular_template.html) to get a better idea of how all the pieces fit together."
    ]
   },
   {
diff --git a/docs/tutorials/intro_tutorial.ipynb b/docs/tutorials/intro_tutorial.ipynb
index ff915527..a9fe5687 100644
--- a/docs/tutorials/intro_tutorial.ipynb
+++ b/docs/tutorials/intro_tutorial.ipynb
@@ -60,7 +60,7 @@
    "source": [
     "### Tutorial Setup\n",
     "\n",
-    "Create and activate a [virtual environment](http://docs.python-guide.org/en/latest/dev/virtualenvs/). *Python version 3.9 or higher is required*.\n",
+    "Create and activate a [virtual environment](http://docs.python-guide.org/en/latest/dev/virtualenvs/). *Python version 3.10 or higher is required*.\n",
     "\n",
     "Install Mesa:\n",
     "\n",
@@ -136,7 +136,7 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "## Import Dependencies\n",
+    "### Import Dependencies\n",
     "This includes importing of dependencies needed for the tutorial."
    ]
   },
@@ -243,7 +243,7 @@
     "\n",
     "**Model-specific information:** A new class is named `RandomActivationByAgent` is created which extends `mesa.time.RandomActivation` creating a subclass of  the `RandomActivation` class from Mesa. This class activates all the agents once per step, in random order. Every agent is expected to have a ``step`` method. The step method is the action the agent takes when it is activated by the model schedule. We add an agent to the schedule using the `add` method; when we call the schedule's `step` method, the model shuffles the order of the agents, then activates and executes each agent's ```step``` method. The scheduler is then added to the model.\n",
     "\n",
-    "**Code implementation:** The technical details about the timer object can be found in the [mesa repo](https://github.com/projectmesa/mesa/blob/main/mesa/time.py). Mesa offers a few different built-in scheduler classes, with a common interface. That makes it easy to change the activation regime a given model uses, and see whether it changes the model behavior. The details pertaining to the scheduler interface can be located the same [mesa repo](https://github.com/projectmesa/mesa/blob/main/mesa/time.py).\n",
+    "**Code implementation:** The technical details about the timer object can be found in the [mesa repo](https://github.com/projectmesa/mesa/blob/main/mesa/time.py). Mesa offers a few different built-in scheduler classes, with a common interface. That makes it easy to change the activation regime a given model uses, and see whether it changes the model behavior. The details pertaining to the scheduler interface can be located in the same [mesa repo](https://github.com/projectmesa/mesa/blob/main/mesa/time.py).\n",
     "\n",
     "With that in mind, the `MoneyAgent` code is modified below to visually show when a new agent is created. The MoneyModel code is modified by adding the RandomActivation method to the model. with the scheduler added looks like this:"
    ]
@@ -543,7 +543,7 @@
     "\n",
     "Mesa has two main types of grids: `SingleGrid` and `MultiGrid`*. `SingleGrid` enforces at most one agent per cell; `MultiGrid` allows multiple agents to be in the same cell. Since we want agents to be able to share a cell, we use `MultiGrid`.\n",
     "\n",
-    "*However there are more types of space to include `HexGrid`, `NetworkGrid`, and the previously mentioned `ContinuousSpace`. Similar to `mesa.time` context is retained with `mesa.space.[enter class]`. You can see the different classes as [mesa.space](https://github.com/projectmesa/mesa/blob/main/mesa/space.py) "
+    "*However there are more types of space to include `HexGrid`, `NetworkGrid`, and the previously mentioned `ContinuousSpace`. Similar to `mesa.time` context is retained with `mesa.space.[enter class]`. You can inspect the different classes at [mesa.space](https://github.com/projectmesa/mesa/blob/main/mesa/space.py)."
    ]
   },
   {
@@ -829,9 +829,9 @@
    "source": [
     "At every step of the model, the datacollector will collect and store the model-level current Gini coefficient, as well as each agent's wealth, associating each with the current step.\n",
     "\n",
-    "We run the model just as we did above. Now is when an interactive session, especially via a Notebook, comes in handy: the DataCollector can export the data its collected as a pandas\\* DataFrame, for easy interactive analysis. \n",
+    "We run the model just as we did above. Now is when an interactive session, especially via a Notebook, comes in handy: the DataCollector can export the data its collected as a pandas* DataFrame, for easy interactive analysis. \n",
     "\n",
-    "\\*If you are new to Python, please be aware that pandas is already installed as a dependency of Mesa and that [pandas](https://pandas.pydata.org/docs/) is a \"fast, powerful, flexible and easy to use open source data analysis and manipulation tool\". pandas is great resource to help analyze the data collected in your models "
+    "*If you are new to Python, please be aware that pandas is already installed as a dependency of Mesa and that [pandas](https://pandas.pydata.org/docs/) is a \"fast, powerful, flexible and easy to use open source data analysis and manipulation tool\". pandas is great resource to help analyze the data collected in your models."
    ]
   },
   {
@@ -1144,7 +1144,7 @@
    "cell_type": "markdown",
    "metadata": {},
    "source": [
-    "**Note for Windows OS users:** If you are running this tutorial in Jupyter, make sure that you set `number_processes = 1` (single process). If `number_processes` is greater than 1, it is less straightforward to set up. You can read [Mesa's how-to guide](https://github.com/projectmesa/mesa/blob/main/docs/howto.rst), in 'Using multi-process `batch_run` on Windows' section for how to do it."
+    "**Note for Windows OS users:** If you are running this tutorial in Jupyter, make sure that you set `number_processes = 1` (single process). If `number_processes` is greater than 1, it is less straightforward to set up. You can read [Mesa's how-to guide](https://github.com/projectmesa/mesa/blob/main/docs/howto.md), in 'Using multi-process `batch_run` on Windows' section for how to do it."
    ]
   },
   {
@@ -1288,7 +1288,7 @@
    "cell_type": "markdown",
    "source": [
     "### Analyzing model reporters: Comparing 5 scenarios\n",
-    "Other insight might be gathered when we compare the Gini coefficient of different scenarios. For example, we can compare the Gini coefficient of a population with 25 agents to the Gini coefficient of a population with 400 agents. While doing this, we increase the number of iterations to 25 to get a better estimate of the Gini coefficient for each population size and get usable error estimations."
+    "Other insights might be gathered when we compare the Gini coefficient of different scenarios. For example, we can compare the Gini coefficient of a population with 25 agents to the Gini coefficient of a population with 400 agents. While doing this, we increase the number of iterations to 25 to get a better estimate of the Gini coefficient for each population size and get usable error estimations."
    ],
    "metadata": {
     "collapsed": false
diff --git a/docs/tutorials/visualization_tutorial.ipynb b/docs/tutorials/visualization_tutorial.ipynb
index f19b2142..493324ea 100644
--- a/docs/tutorials/visualization_tutorial.ipynb
+++ b/docs/tutorials/visualization_tutorial.ipynb
@@ -119,7 +119,7 @@
    },
    "outputs": [],
    "source": [
-    "from mesa.experimental import JupyterViz\n",
+    "from mesa.visualization import JupyterViz\n",
     "\n",
     "page = JupyterViz(\n",
     "    BoltzmannWealthModel,\n",
diff --git a/maintenance/fetch_unlabeled_prs.py b/maintenance/fetch_unlabeled_prs.py
new file mode 100644
index 00000000..0b01a291
--- /dev/null
+++ b/maintenance/fetch_unlabeled_prs.py
@@ -0,0 +1,81 @@
+import os
+from datetime import datetime
+
+import requests
+
+# Configuration
+# Your GitHub Personal Access Token
+GITHUB_TOKEN = os.getenv("GITHUB_TOKEN")
+if GITHUB_TOKEN is None:
+    print(
+        "Please specify your GitHub Personal Access Token as GITHUB_TOKEN in your .bashrc"
+    )
+    exit()
+GITHUB_USERNAME = "projectmesa"
+GITHUB_REPO = "mesa"
+HEADERS = {"Authorization": f"token {GITHUB_TOKEN}"}
+TIMEOUT = 20
+
+
+def get_latest_release_date() -> str:
+    """Fetches the latest release date from the GitHub repository."""
+    url = (
+        f"https://api.github.com/repos/{GITHUB_USERNAME}/{GITHUB_REPO}/releases/latest"
+    )
+    response = requests.get(url, headers=HEADERS, timeout=TIMEOUT)
+    response.raise_for_status()  # Raises an exception for HTTP error codes
+    return response.json()["published_at"]
+
+
+def get_closed_pull_requests_since_latest_release(
+    latest_release_date,
+) -> list[dict[str, any]]:
+    """Fetches pull requests created or updated after the latest release date, then filters by merged date."""
+    pull_requests = []
+    page = 1
+    while True:
+        # Fetch PRs that were created or updated after the latest release date
+        url = f"https://api.github.com/repos/{GITHUB_USERNAME}/{GITHUB_REPO}/pulls?state=closed&base=main&sort=updated&direction=desc&page={page}"
+        response = requests.get(url, headers=HEADERS, timeout=TIMEOUT)
+        response.raise_for_status()
+        prs = response.json()
+        if not prs:
+            break
+
+        # Convert latest release date to datetime for comparison
+        latest_release_datetime = datetime.strptime(
+            latest_release_date, "%Y-%m-%dT%H:%M:%SZ"
+        ).astimezone()
+
+        for pr in prs:
+            # Convert PR's `updated_at` to datetime for comparison
+            pr_updated_at = datetime.strptime(
+                pr["updated_at"], "%Y-%m-%dT%H:%M:%SZ"
+            ).astimezone()
+            # Stop fetching if PR was updated before the latest release
+            if pr_updated_at < latest_release_datetime:
+                return pull_requests
+
+            if pr["merged_at"]:
+                pr_merged_at = datetime.strptime(
+                    pr["merged_at"], "%Y-%m-%dT%H:%M:%SZ"
+                ).astimezone()
+                if pr_merged_at > latest_release_datetime and not pr["labels"]:
+                    pull_requests.append(pr)
+        page += 1
+    return pull_requests
+
+
+def main() -> None:
+    # Based on https://github.com/projectmesa/mesa/pull/1917#issuecomment-1871352058
+    latest_release_date = get_latest_release_date()
+    pull_requests = get_closed_pull_requests_since_latest_release(latest_release_date)
+    if len(pull_requests) <= 0:
+        return
+    print("These pull requests must be labeled:")
+    for pr in pull_requests:
+        print(f"  PR #{pr['number']}: {pr['title']} - Merged at: {pr['merged_at']}")
+
+
+if __name__ == "__main__":
+    main()
diff --git a/mesa/__init__.py b/mesa/__init__.py
index e27d7de1..529507e1 100644
--- a/mesa/__init__.py
+++ b/mesa/__init__.py
@@ -3,11 +3,12 @@ Mesa Agent-Based Modeling Framework
 
 Core Objects: Model, and Agent.
 """
+
 import datetime
 
 import mesa.space as space
 import mesa.time as time
-import mesa.visualization as visualization
+import mesa.visualization_old as visualization_old
 from mesa.agent import Agent
 from mesa.batchrunner import batch_run
 from mesa.datacollection import DataCollector
@@ -18,14 +19,14 @@ __all__ = [
     "Agent",
     "time",
     "space",
-    "visualization",
+    "visualization_old",
     "DataCollector",
     "batch_run",
     "experimental",
 ]
 
 __title__ = "mesa"
-__version__ = "2.3.0-dev"
+__version__ = "3.0.0-dev"
 __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 57edf1a0..2f0e842d 100644
--- a/mesa/agent.py
+++ b/mesa/agent.py
@@ -3,6 +3,7 @@ The agent class for Mesa framework.
 
 Core Objects: Agent
 """
+
 # Mypy; for the `|` operator purpose
 # Remove this __future__ import once the oldest supported Python is 3.10
 from __future__ import annotations
@@ -13,11 +14,11 @@ import operator
 import warnings
 import weakref
 from collections import defaultdict
-from collections.abc import Iterable, Iterator, MutableSet, Sequence
+from collections.abc import Callable, Iterable, Iterator, MutableSet, Sequence
 from random import Random
 
 # mypy
-from typing import TYPE_CHECKING, Any, Callable
+from typing import TYPE_CHECKING, Any
 
 if TYPE_CHECKING:
     # We ensure that these are not imported during runtime to prevent cyclic
@@ -81,12 +82,6 @@ class Agent:
 
 class AgentSet(MutableSet, Sequence):
     """
-    .. warning::
-        The AgentSet is experimental. It may be changed or removed in any and all future releases, including
-        patch releases.
-        We would love to hear what you think about this new feature. If you have any thoughts, share them with
-        us here: https://github.com/projectmesa/mesa/discussions/1919
-
     A collection class that represents an ordered set of agents within an agent-based model (ABM). This class
     extends both MutableSet and Sequence, providing set-like functionality with order preservation and
     sequence operations.
@@ -115,17 +110,8 @@ class AgentSet(MutableSet, Sequence):
             agents (Iterable[Agent]): An iterable of Agent objects to be included in the set.
             model (Model): The ABM model instance to which this AgentSet belongs.
         """
-        self.model = model
-
-        if not self.__class__.agentset_experimental_warning_given:
-            self.__class__.agentset_experimental_warning_given = True
-            warnings.warn(
-                "The AgentSet is experimental. It may be changed or removed in any and all future releases, including patch releases.\n"
-                "We would love to hear what you think about this new feature. If you have any thoughts, share them with us here: https://github.com/projectmesa/mesa/discussions/1919",
-                FutureWarning,
-                stacklevel=2,
-            )
 
+        self.model = model
         self._agents = weakref.WeakKeyDictionary({agent: None for agent in agents})
 
     def __len__(self) -> int:
diff --git a/mesa/batchrunner.py b/mesa/batchrunner.py
index aba96855..a74eddbe 100644
--- a/mesa/batchrunner.py
+++ b/mesa/batchrunner.py
@@ -2,7 +2,7 @@ import itertools
 from collections.abc import Iterable, Mapping
 from functools import partial
 from multiprocessing import Pool
-from typing import Any, Optional, Union
+from typing import Any
 
 from tqdm.auto import tqdm
 
@@ -11,9 +11,9 @@ from mesa.model import Model
 
 def batch_run(
     model_cls: type[Model],
-    parameters: Mapping[str, Union[Any, Iterable[Any]]],
+    parameters: Mapping[str, Any | Iterable[Any]],
     # We still retain the Optional[int] because users may set it to None (i.e. use all CPUs)
-    number_processes: Optional[int] = 1,
+    number_processes: int | None = 1,
     iterations: int = 1,
     data_collection_period: int = -1,
     max_steps: int = 1000,
@@ -76,7 +76,7 @@ def batch_run(
 
 
 def _make_model_kwargs(
-    parameters: Mapping[str, Union[Any, Iterable[Any]]],
+    parameters: Mapping[str, Any | Iterable[Any]],
 ) -> list[dict[str, Any]]:
     """Create model kwargs from parameters dictionary.
 
diff --git a/mesa/cookiecutter-mesa/{{cookiecutter.snake}}/{{cookiecutter.snake}}/server.pytemplate b/mesa/cookiecutter-mesa/{{cookiecutter.snake}}/{{cookiecutter.snake}}/server.pytemplate
index 832f2df7..7b168b6d 100644
--- a/mesa/cookiecutter-mesa/{{cookiecutter.snake}}/{{cookiecutter.snake}}/server.pytemplate
+++ b/mesa/cookiecutter-mesa/{{cookiecutter.snake}}/{{cookiecutter.snake}}/server.pytemplate
@@ -21,14 +21,14 @@ def circle_portrayal_example(agent):
     return portrayal
 
 
-canvas_element = mesa.visualization.CanvasGrid(
+canvas_element = mesa.visualization_old.CanvasGrid(
     circle_portrayal_example, 20, 20, 500, 500
 )
-chart_element = mesa.visualization.ChartModule([{"Label": "{{ cookiecutter.camel }}", "Color": "Pink"}])
+chart_element = mesa.visualization_old.ChartModule([{"Label": "{{ cookiecutter.camel }}", "Color": "Pink"}])
 
 model_kwargs = {"num_agents": 10, "width": 10, "height": 10}
 
-server = mesa.visualization.ModularServer(
+server = mesa.visualization_old.ModularServer(
     {{cookiecutter.model}},
     [canvas_element, chart_element],
     "{{ cookiecutter.camel }}",
diff --git a/mesa/datacollection.py b/mesa/datacollection.py
index 3fcd205c..712482af 100644
--- a/mesa/datacollection.py
+++ b/mesa/datacollection.py
@@ -32,9 +32,11 @@ The default DataCollector here makes several assumptions:
     * The model has an agent list called agents
     * For collecting agent-level variables, agents must have a unique_id
 """
+
 import contextlib
 import itertools
 import types
+from copy import deepcopy
 from functools import partial
 
 with contextlib.suppress(ImportError):
@@ -184,7 +186,9 @@ class DataCollector:
 
         agent_records = map(
             get_reports,
-            model.schedule.agents if hasattr(model, "schedule") else model.agents,
+            model.schedule.agents
+            if hasattr(model, "schedule") and model.schedule is not None
+            else model.agents,
         )
         return agent_records
 
@@ -193,18 +197,22 @@ class DataCollector:
         if self.model_reporters:
             for var, reporter in self.model_reporters.items():
                 # Check if lambda or partial function
-                if isinstance(reporter, (types.LambdaType, partial)):
-                    self.model_vars[var].append(reporter(model))
+                if isinstance(reporter, types.LambdaType | partial):
+                    # Use deepcopy to store a copy of the data,
+                    # preventing references from being updated across steps.
+                    self.model_vars[var].append(deepcopy(reporter(model)))
                 # Check if model attribute
                 elif isinstance(reporter, str):
-                    self.model_vars[var].append(getattr(model, reporter, None))
+                    self.model_vars[var].append(
+                        deepcopy(getattr(model, reporter, None))
+                    )
                 # Check if function with arguments
                 elif isinstance(reporter, list):
-                    self.model_vars[var].append(reporter[0](*reporter[1]))
-                # TODO: Check if method of a class, as of now it is assumed
-                # implicitly if the other checks fail.
+                    self.model_vars[var].append(deepcopy(reporter[0](*reporter[1])))
+                # Assume it's a callable otherwise (e.g., method)
+                # TODO: Check if method of a class explicitly
                 else:
-                    self.model_vars[var].append(reporter())
+                    self.model_vars[var].append(deepcopy(reporter()))
 
         if self.agent_reporters:
             agent_records = self._record_agents(model)
diff --git a/mesa/experimental/UserParam.py b/mesa/experimental/UserParam.py
deleted file mode 100644
index 58715f32..00000000
--- a/mesa/experimental/UserParam.py
+++ /dev/null
@@ -1,56 +0,0 @@
-class UserParam:
-    _ERROR_MESSAGE = "Missing or malformed inputs for '{}' Option '{}'"
-
-    def maybe_raise_error(self, param_type, valid):
-        if valid:
-            return
-        msg = self._ERROR_MESSAGE.format(param_type, self.label)
-        raise ValueError(msg)
-
-
-class Slider(UserParam):
-    """
-    A number-based slider input with settable increment.
-
-    Example:
-
-    slider_option = Slider("My Slider", value=123, min=10, max=200, step=0.1)
-
-    Args:
-        label: The displayed label in the UI
-        value: The initial value of the slider
-        min: The minimum possible value of the slider
-        max: The maximum possible value of the slider
-        step: The step between min and max for a range of possible values
-        dtype: either int or float
-    """
-
-    def __init__(
-        self,
-        label="",
-        value=None,
-        min=None,
-        max=None,
-        step=1,
-        dtype=None,
-    ):
-        self.label = label
-        self.value = value
-        self.min = min
-        self.max = max
-        self.step = step
-
-        # Validate option type to make sure values are supplied properly
-        valid = not (self.value is None or self.min is None or self.max is None)
-        self.maybe_raise_error("slider", valid)
-
-        if dtype is None:
-            self.is_float_slider = self._check_values_are_float(value, min, max, step)
-        else:
-            self.is_float_slider = dtype == float
-
-    def _check_values_are_float(self, value, min, max, step):
-        return any(isinstance(n, float) for n in (value, min, max, step))
-
-    def get(self, attr):
-        return getattr(self, attr)
diff --git a/mesa/experimental/__init__.py b/mesa/experimental/__init__.py
index c04f2258..e37cdb04 100644
--- a/mesa/experimental/__init__.py
+++ b/mesa/experimental/__init__.py
@@ -1,1 +1,3 @@
-from .jupyter_viz import JupyterViz, make_text, Slider  # noqa
+from mesa.experimental import cell_space
+
+__all__ = ["cell_space"]
diff --git a/mesa/experimental/cell_space/__init__.py b/mesa/experimental/cell_space/__init__.py
new file mode 100644
index 00000000..dce296ae
--- /dev/null
+++ b/mesa/experimental/cell_space/__init__.py
@@ -0,0 +1,23 @@
+from mesa.experimental.cell_space.cell import Cell
+from mesa.experimental.cell_space.cell_agent import CellAgent
+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 (
+    Grid,
+    HexGrid,
+    OrthogonalMooreGrid,
+    OrthogonalVonNeumannGrid,
+)
+from mesa.experimental.cell_space.network import Network
+
+__all__ = [
+    "CellCollection",
+    "Cell",
+    "CellAgent",
+    "DiscreteSpace",
+    "Grid",
+    "HexGrid",
+    "OrthogonalMooreGrid",
+    "OrthogonalVonNeumannGrid",
+    "Network",
+]
diff --git a/mesa/experimental/cell_space/cell.py b/mesa/experimental/cell_space/cell.py
new file mode 100644
index 00000000..55264f68
--- /dev/null
+++ b/mesa/experimental/cell_space/cell.py
@@ -0,0 +1,152 @@
+from __future__ import annotations
+
+from functools import cache
+from random import Random
+from typing import TYPE_CHECKING
+
+from mesa.experimental.cell_space.cell_collection import CellCollection
+
+if TYPE_CHECKING:
+    from mesa.experimental.cell_space.cell_agent import CellAgent
+
+
+class Cell:
+    """The cell represents a position in a discrete space.
+
+    Attributes:
+        coordinate (Tuple[int, int]) : the position of the cell in the discrete space
+        agents (List[Agent]): the agents occupying the cell
+        capacity (int): the maximum number of agents that can simultaneously occupy the cell
+        properties (dict[str, Any]): the properties of the cell
+        random (Random): the random number generator
+
+    """
+
+    __slots__ = [
+        "coordinate",
+        "_connections",
+        "agents",
+        "capacity",
+        "properties",
+        "random",
+    ]
+
+    # def __new__(cls,
+    #     coordinate: tuple[int, ...],
+    #     capacity: float | None = None,
+    #     random: Random | None = None,):
+    #     if capacity != 1:
+    #         return object.__new__(cls)
+    #     else:
+    #         return object.__new__(SingleAgentCell)
+
+    def __init__(
+        self,
+        coordinate: tuple[int, ...],
+        capacity: float | None = None,
+        random: Random | None = None,
+    ) -> None:
+        """ "
+
+        Args:
+            coordinate:
+            capacity (int) : the capacity of the cell. If None, the capacity is infinite
+            random (Random) : the random number generator to use
+
+        """
+        super().__init__()
+        self.coordinate = coordinate
+        self._connections: list[Cell] = []  # TODO: change to CellCollection?
+        self.agents = []  # TODO:: change to AgentSet or weakrefs? (neither is very performant, )
+        self.capacity = capacity
+        self.properties: dict[str, object] = {}
+        self.random = random
+
+    def connect(self, other: Cell) -> None:
+        """Connects this cell to another cell.
+
+        Args:
+            other (Cell): other cell to connect to
+
+        """
+        self._connections.append(other)
+
+    def disconnect(self, other: Cell) -> None:
+        """Disconnects this cell from another cell.
+
+        Args:
+            other (Cell): other cell to remove from connections
+
+        """
+        self._connections.remove(other)
+
+    def add_agent(self, agent: CellAgent) -> None:
+        """Adds an agent to the cell.
+
+        Args:
+            agent (CellAgent): agent to add to this Cell
+
+        """
+        n = len(self.agents)
+
+        if self.capacity and n >= self.capacity:
+            raise Exception(
+                "ERROR: Cell is full"
+            )  # FIXME we need MESA errors or a proper error
+
+        self.agents.append(agent)
+
+    def remove_agent(self, agent: CellAgent) -> None:
+        """Removes an agent from the cell.
+
+        Args:
+            agent (CellAgent): agent to remove from this cell
+
+        """
+        self.agents.remove(agent)
+        agent.cell = None
+
+    @property
+    def is_empty(self) -> bool:
+        """Returns a bool of the contents of a cell."""
+        return len(self.agents) == 0
+
+    @property
+    def is_full(self) -> bool:
+        """Returns a bool of the contents of a cell."""
+        return len(self.agents) == self.capacity
+
+    def __repr__(self):
+        return f"Cell({self.coordinate}, {self.agents})"
+
+    # FIXME: Revisit caching strategy on methods
+    @cache  # noqa: B019
+    def neighborhood(self, radius=1, include_center=False):
+        return CellCollection(
+            self._neighborhood(radius=radius, include_center=include_center),
+            random=self.random,
+        )
+
+    # FIXME: Revisit caching strategy on methods
+    @cache  # noqa: B019
+    def _neighborhood(self, radius=1, include_center=False):
+        # if radius == 0:
+        #     return {self: self.agents}
+        if radius < 1:
+            raise ValueError("radius must be larger than one")
+        if radius == 1:
+            neighborhood = {neighbor: neighbor.agents for neighbor in self._connections}
+            if not include_center:
+                return neighborhood
+            else:
+                neighborhood[self] = self.agents
+                return neighborhood
+        else:
+            neighborhood = {}
+            for neighbor in self._connections:
+                neighborhood.update(
+                    neighbor._neighborhood(radius - 1, include_center=True)
+                )
+            if not include_center:
+                neighborhood.pop(self, None)
+            return neighborhood
diff --git a/mesa/experimental/cell_space/cell_agent.py b/mesa/experimental/cell_space/cell_agent.py
new file mode 100644
index 00000000..abc5155a
--- /dev/null
+++ b/mesa/experimental/cell_space/cell_agent.py
@@ -0,0 +1,37 @@
+from __future__ import annotations
+
+from typing import TYPE_CHECKING
+
+from mesa import Agent, Model
+
+if TYPE_CHECKING:
+    from mesa.experimental.cell_space.cell import Cell
+
+
+class CellAgent(Agent):
+    """Cell Agent is an extension of the Agent class and adds behavior for moving in discrete spaces
+
+
+    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
+    """
+
+    def __init__(self, unique_id: int, model: Model) -> None:
+        """
+        Create a new agent.
+
+        Args:
+            unique_id (int): A unique identifier for this agent.
+            model (Model): The model instance in which the agent exists.
+        """
+        super().__init__(unique_id, model)
+        self.cell: Cell | None = None
+
+    def move_to(self, cell) -> None:
+        if self.cell is not None:
+            self.cell.remove_agent(self)
+        self.cell = cell
+        cell.add_agent(self)
diff --git a/mesa/experimental/cell_space/cell_collection.py b/mesa/experimental/cell_space/cell_collection.py
new file mode 100644
index 00000000..114301db
--- /dev/null
+++ b/mesa/experimental/cell_space/cell_collection.py
@@ -0,0 +1,81 @@
+from __future__ import annotations
+
+import itertools
+from collections.abc import Callable, Iterable, Mapping
+from functools import cached_property
+from random import Random
+from typing import TYPE_CHECKING, Generic, TypeVar
+
+if TYPE_CHECKING:
+    from mesa.experimental.cell_space.cell import Cell
+    from mesa.experimental.cell_space.cell_agent import CellAgent
+
+T = TypeVar("T", bound="Cell")
+
+
+class CellCollection(Generic[T]):
+    """An immutable collection of cells
+
+    Attributes:
+        cells (List[Cell]): The list of cells this collection represents
+        agents (List[CellAgent]) : List of agents occupying the cells in this collection
+        random (Random) : The random number generator
+
+    """
+
+    def __init__(
+        self,
+        cells: Mapping[T, list[CellAgent]] | Iterable[T],
+        random: Random | None = None,
+    ) -> None:
+        if isinstance(cells, dict):
+            self._cells = cells
+        else:
+            self._cells = {cell: cell.agents for cell in cells}
+
+        #
+        self._capacity: int = next(iter(self._cells.keys())).capacity
+
+        if random is None:
+            random = Random()  # FIXME
+        self.random = random
+
+    def __iter__(self):
+        return iter(self._cells)
+
+    def __getitem__(self, key: T) -> Iterable[CellAgent]:
+        return self._cells[key]
+
+    # @cached_property
+    def __len__(self) -> int:
+        return len(self._cells)
+
+    def __repr__(self):
+        return f"CellCollection({self._cells})"
+
+    @cached_property
+    def cells(self) -> list[T]:
+        return list(self._cells.keys())
+
+    @property
+    def agents(self) -> Iterable[CellAgent]:
+        return itertools.chain.from_iterable(self._cells.values())
+
+    def select_random_cell(self) -> T:
+        return self.random.choice(self.cells)
+
+    def select_random_agent(self) -> CellAgent:
+        return self.random.choice(list(self.agents))
+
+    def select(self, filter_func: Callable[[T], bool] | None = None, n=0):
+        # FIXME: n is not considered
+        if filter_func is None and n == 0:
+            return self
+
+        return CellCollection(
+            {
+                cell: agents
+                for cell, agents in self._cells.items()
+                if filter_func is None or filter_func(cell)
+            }
+        )
diff --git a/mesa/experimental/cell_space/discrete_space.py b/mesa/experimental/cell_space/discrete_space.py
new file mode 100644
index 00000000..6c92c320
--- /dev/null
+++ b/mesa/experimental/cell_space/discrete_space.py
@@ -0,0 +1,64 @@
+from __future__ import annotations
+
+from functools import cached_property
+from random import Random
+from typing import Generic, TypeVar
+
+from mesa.experimental.cell_space.cell import Cell
+from mesa.experimental.cell_space.cell_collection import CellCollection
+
+T = TypeVar("T", bound=Cell)
+
+
+class DiscreteSpace(Generic[T]):
+    """Base class for all discrete spaces.
+
+    Attributes:
+        capacity (int): The capacity of the cells in the discrete space
+        all_cells (CellCollection): The cells composing the discrete space
+        random (Random): The random number generator
+        cell_klass (Type) : the type of cell class
+        empties (CellCollection) : collecction of all cells that are empty
+
+    """
+
+    def __init__(
+        self,
+        capacity: int | None = None,
+        cell_klass: type[T] = Cell,
+        random: Random | None = None,
+    ):
+        super().__init__()
+        self.capacity = capacity
+        self._cells: dict[tuple[int, ...], T] = {}
+        if random is None:
+            random = Random()  # FIXME should default to default rng from model
+        self.random = random
+        self.cell_klass = cell_klass
+
+        self._empties: dict[tuple[int, ...], None] = {}
+        self._empties_initialized = False
+
+    @property
+    def cutoff_empties(self):
+        return 7.953 * len(self._cells) ** 0.384
+
+    def _connect_single_cell(self, cell: T): ...
+
+    @cached_property
+    def all_cells(self):
+        return CellCollection({cell: cell.agents for cell in self._cells.values()})
+
+    def __iter__(self):
+        return iter(self._cells.values())
+
+    def __getitem__(self, key):
+        return self._cells[key]
+
+    @property
+    def empties(self) -> CellCollection:
+        return self.all_cells.select(lambda cell: cell.is_empty)
+
+    def select_random_empty_cell(self) -> T:
+        """select random empty cell"""
+        return self.random.choice(list(self.empties))
diff --git a/mesa/experimental/cell_space/grid.py b/mesa/experimental/cell_space/grid.py
new file mode 100644
index 00000000..f08657d2
--- /dev/null
+++ b/mesa/experimental/cell_space/grid.py
@@ -0,0 +1,204 @@
+from __future__ import annotations
+
+from collections.abc import Sequence
+from itertools import product
+from random import Random
+from typing import Generic, TypeVar
+
+from mesa.experimental.cell_space import Cell, DiscreteSpace
+
+T = TypeVar("T", bound=Cell)
+
+
+class Grid(DiscreteSpace, Generic[T]):
+    """Base class for all grid classes
+
+    Attributes:
+        dimensions (Sequence[int]): the dimensions of the grid
+        torus (bool): whether the grid is a torus
+        capacity (int): the capacity of a grid cell
+        random (Random): the random number generator
+        _try_random (bool): whether to get empty cell be repeatedly trying random cell
+
+    """
+
+    def __init__(
+        self,
+        dimensions: Sequence[int],
+        torus: bool = False,
+        capacity: float | None = None,
+        random: Random | None = None,
+        cell_klass: type[T] = Cell,
+    ) -> None:
+        super().__init__(capacity=capacity, random=random, cell_klass=cell_klass)
+        self.torus = torus
+        self.dimensions = dimensions
+        self._try_random = True
+        self._ndims = len(dimensions)
+        self._validate_parameters()
+
+        coordinates = product(*(range(dim) for dim in self.dimensions))
+
+        self._cells = {
+            coord: cell_klass(coord, capacity, random=self.random)
+            for coord in coordinates
+        }
+        self._connect_cells()
+
+    def _connect_cells(self) -> None:
+        if self._ndims == 2:
+            self._connect_cells_2d()
+        else:
+            self._connect_cells_nd()
+
+    def _connect_cells_2d(self) -> None: ...
+
+    def _connect_cells_nd(self) -> None: ...
+
+    def _validate_parameters(self):
+        if not all(isinstance(dim, int) and dim > 0 for dim in self.dimensions):
+            raise ValueError("Dimensions must be a list of positive integers.")
+        if not isinstance(self.torus, bool):
+            raise ValueError("Torus must be a boolean.")
+        if self.capacity is not None and not isinstance(self.capacity, float | int):
+            raise ValueError("Capacity must be a number or None.")
+
+    def select_random_empty_cell(self) -> T:
+        # FIXME:: currently just a simple boolean to control behavior
+        # FIXME:: basically if grid is close to 99% full, creating empty list can be faster
+        # FIXME:: note however that the old results don't apply because in this implementation
+        # FIXME:: because empties list needs to be rebuild each time
+        # This method is based on Agents.jl's random_empty() implementation. See
+        # https://github.com/JuliaDynamics/Agents.jl/pull/541. For the discussion, see
+        # https://github.com/projectmesa/mesa/issues/1052 and
+        # https://github.com/projectmesa/mesa/pull/1565. The cutoff value provided
+        # is the break-even comparison with the time taken in the else branching point.
+        if self._try_random:
+            while True:
+                cell = self.all_cells.select_random_cell()
+                if cell.is_empty:
+                    return cell
+        else:
+            return super().select_random_empty_cell()
+
+    def _connect_single_cell_nd(self, cell: T, offsets: list[tuple[int, ...]]) -> None:
+        coord = cell.coordinate
+
+        for d_coord in offsets:
+            n_coord = tuple(c + dc for c, dc in zip(coord, d_coord))
+            if self.torus:
+                n_coord = tuple(nc % d for nc, d in zip(n_coord, self.dimensions))
+            if all(0 <= nc < d for nc, d in zip(n_coord, self.dimensions)):
+                cell.connect(self._cells[n_coord])
+
+    def _connect_single_cell_2d(self, cell: T, offsets: list[tuple[int, int]]) -> None:
+        i, j = cell.coordinate
+        height, width = self.dimensions
+
+        for di, dj in offsets:
+            ni, nj = (i + di, j + dj)
+            if self.torus:
+                ni, nj = ni % height, nj % width
+            if 0 <= ni < height and 0 <= nj < width:
+                cell.connect(self._cells[ni, nj])
+
+
+class OrthogonalMooreGrid(Grid[T]):
+    """Grid where cells are connected to their 8 neighbors.
+
+    Example for two dimensions:
+    directions = [
+        (-1, -1), (-1, 0), (-1, 1),
+        ( 0, -1),          ( 0, 1),
+        ( 1, -1), ( 1, 0), ( 1, 1),
+    ]
+    """
+
+    def _connect_cells_2d(self) -> None:
+        # fmt: off
+        offsets = [
+            (-1, -1), (-1, 0), (-1, 1),
+            ( 0, -1),          ( 0, 1),
+            ( 1, -1), ( 1, 0), ( 1, 1),
+        ]
+        # fmt: on
+        height, width = self.dimensions
+
+        for cell in self.all_cells:
+            self._connect_single_cell_2d(cell, offsets)
+
+    def _connect_cells_nd(self) -> None:
+        offsets = list(product([-1, 0, 1], repeat=len(self.dimensions)))
+        offsets.remove((0,) * len(self.dimensions))  # Remove the central cell
+
+        for cell in self.all_cells:
+            self._connect_single_cell_nd(cell, offsets)
+
+
+class OrthogonalVonNeumannGrid(Grid[T]):
+    """Grid where cells are connected to their 4 neighbors.
+
+    Example for two dimensions:
+    directions = [
+                (0, -1),
+        (-1, 0),         ( 1, 0),
+                (0,  1),
+    ]
+    """
+
+    def _connect_cells_2d(self) -> None:
+        # fmt: off
+        offsets = [
+                    (-1, 0),
+            (0, -1),         (0, 1),
+                    ( 1, 0),
+        ]
+        # fmt: on
+        height, width = self.dimensions
+
+        for cell in self.all_cells:
+            self._connect_single_cell_2d(cell, offsets)
+
+    def _connect_cells_nd(self) -> None:
+        offsets = []
+        dimensions = len(self.dimensions)
+        for dim in range(dimensions):
+            for delta in [
+                -1,
+                1,
+            ]:  # Move one step in each direction for the current dimension
+                offset = [0] * dimensions
+                offset[dim] = delta
+                offsets.append(tuple(offset))
+
+        for cell in self.all_cells:
+            self._connect_single_cell_nd(cell, offsets)
+
+
+class HexGrid(Grid[T]):
+    def _connect_cells_2d(self) -> None:
+        # fmt: off
+        even_offsets = [
+                        (-1, -1), (-1, 0),
+                    ( 0, -1),        ( 0, 1),
+                        ( 1, -1), ( 1, 0),
+                ]
+        odd_offsets = [
+                        (-1, 0), (-1, 1),
+                    ( 0, -1),       ( 0, 1),
+                        ( 1, 0), ( 1, 1),
+                ]
+        # fmt: on
+
+        for cell in self.all_cells:
+            i = cell.coordinate[0]
+            offsets = even_offsets if i % 2 == 0 else odd_offsets
+            self._connect_single_cell_2d(cell, offsets=offsets)
+
+    def _connect_cells_nd(self) -> None:
+        raise NotImplementedError("HexGrids are only defined for 2 dimensions")
+
+    def _validate_parameters(self):
+        super()._validate_parameters()
+        if len(self.dimensions) != 2:
+            raise ValueError("HexGrid must have exactly 2 dimensions.")
diff --git a/mesa/experimental/cell_space/network.py b/mesa/experimental/cell_space/network.py
new file mode 100644
index 00000000..3983287e
--- /dev/null
+++ b/mesa/experimental/cell_space/network.py
@@ -0,0 +1,40 @@
+from random import Random
+from typing import Any
+
+from mesa.experimental.cell_space.cell import Cell
+from mesa.experimental.cell_space.discrete_space import DiscreteSpace
+
+
+class Network(DiscreteSpace):
+    """A networked discrete space"""
+
+    def __init__(
+        self,
+        G: Any,  # noqa: N803
+        capacity: int | None = None,
+        random: Random | None = None,
+        cell_klass: type[Cell] = Cell,
+    ) -> None:
+        """A Networked grid
+
+        Args:
+            G: a NetworkX Graph instance.
+            capacity (int) : the capacity of the cell
+            random (Random):
+            CellKlass (type[Cell]): The base Cell class to use in the Network
+
+        """
+        super().__init__(capacity=capacity, random=random, cell_klass=cell_klass)
+        self.G = G
+
+        for node_id in self.G.nodes:
+            self._cells[node_id] = self.cell_klass(
+                node_id, capacity, random=self.random
+            )
+
+        for cell in self.all_cells:
+            self._connect_single_cell(cell)
+
+    def _connect_single_cell(self, cell):
+        for node_id in self.G.neighbors(cell.coordinate):
+            cell.connect(self._cells[node_id])
diff --git a/mesa/experimental/devs/__init__.py b/mesa/experimental/devs/__init__.py
new file mode 100644
index 00000000..b6dca39e
--- /dev/null
+++ b/mesa/experimental/devs/__init__.py
@@ -0,0 +1,4 @@
+from .eventlist import Priority, SimulationEvent
+from .simulator import ABMSimulator, DEVSimulator
+
+__all__ = ["ABMSimulator", "DEVSimulator", "SimulationEvent", "Priority"]
diff --git a/mesa/experimental/devs/eventlist.py b/mesa/experimental/devs/eventlist.py
new file mode 100644
index 00000000..a0ec9337
--- /dev/null
+++ b/mesa/experimental/devs/eventlist.py
@@ -0,0 +1,167 @@
+from __future__ import annotations
+
+import itertools
+from collections.abc import Callable
+from enum import IntEnum
+from heapq import heapify, heappop, heappush
+from types import MethodType
+from typing import Any
+from weakref import WeakMethod, ref
+
+
+class Priority(IntEnum):
+    LOW = 10
+    DEFAULT = 5
+    HIGH = 1
+
+
+class SimulationEvent:
+    """A simulation event
+
+    the callable is wrapped using weakref, so there is no need to explicitly cancel event if e.g., an agent
+    is removed from the simulation.
+
+    Attributes:
+        time (float): The simulation time of the event
+        fn (Callable): The function to execute for this event
+        priority (Priority): The priority of the event
+        unique_id (int) the unique identifier of the event
+        function_args (list[Any]): Argument for the function
+        function_kwargs (Dict[str, Any]): Keyword arguments for the function
+
+    """
+
+    _ids = itertools.count()
+
+    @property
+    def CANCELED(self) -> bool:
+        return self._canceled
+
+    def __init__(
+        self,
+        time: int | float,
+        function: Callable,
+        priority: Priority = Priority.DEFAULT,
+        function_args: list[Any] | None = None,
+        function_kwargs: dict[str, Any] | None = None,
+    ) -> None:
+        super().__init__()
+        if not callable(function):
+            raise Exception()
+
+        self.time = time
+        self.priority = priority.value
+        self._canceled = False
+
+        if isinstance(function, MethodType):
+            function = WeakMethod(function)
+        else:
+            function = ref(function)
+
+        self.fn = function
+        self.unique_id = next(self._ids)
+        self.function_args = function_args if function_args else []
+        self.function_kwargs = function_kwargs if function_kwargs else {}
+
+    def execute(self):
+        """execute this event"""
+        if not self._canceled:
+            fn = self.fn()
+            if fn is not None:
+                fn(*self.function_args, **self.function_kwargs)
+
+    def cancel(self) -> None:
+        """cancel this event"""
+        self._canceled = True
+        self.fn = None
+        self.function_args = []
+        self.function_kwargs = {}
+
+    def __lt__(self, other):
+        # Define a total ordering for events to be used by the heapq
+        return (self.time, self.priority, self.unique_id) < (
+            other.time,
+            other.priority,
+            other.unique_id,
+        )
+
+
+class EventList:
+    """An event list
+
+    This is a heap queue sorted list of events. Events are always removed from the left, so heapq is a performant and
+    appropriate data structure. Events are sorted based on their time stamp, their priority, and their unique_id
+    as a tie-breaker, guaranteeing a complete ordering.
+
+    """
+
+    def __init__(self):
+        self._events: list[SimulationEvent] = []
+        heapify(self._events)
+
+    def add_event(self, event: SimulationEvent):
+        """Add the event to the event list
+
+        Args:
+            event (SimulationEvent): The event to be added
+
+        """
+
+        heappush(self._events, event)
+
+    def peak_ahead(self, n: int = 1) -> list[SimulationEvent]:
+        """Look at the first n non-canceled event in the event list
+
+        Args:
+            n (int): The number of events to look ahead
+
+        Returns:
+            list[SimulationEvent]
+
+        Raises:
+            IndexError: If the eventlist is empty
+
+        Notes:
+            this method can return a list shorted then n if the number of non-canceled events on the event list
+            is less than n.
+
+        """
+        # look n events ahead
+        if self.is_empty():
+            raise IndexError("event list is empty")
+
+        peek: list[SimulationEvent] = []
+        for event in self._events:
+            if not event.CANCELED:
+                peek.append(event)
+            if len(peek) >= n:
+                return peek
+        return peek
+
+    def pop_event(self) -> SimulationEvent:
+        """pop the first element from the event list"""
+        while self._events:
+            event = heappop(self._events)
+            if not event.CANCELED:
+                return event
+        raise IndexError("Event list is empty")
+
+    def is_empty(self) -> bool:
+        return len(self) == 0
+
+    def __contains__(self, event: SimulationEvent) -> bool:
+        return event in self._events
+
+    def __len__(self) -> int:
+        return len(self._events)
+
+    def remove(self, event: SimulationEvent) -> None:
+        """remove an event from the event list"""
+        # we cannot simply remove items from _eventlist because this breaks
+        # heap structure invariant. So, we use a form of lazy deletion.
+        # SimEvents have a CANCELED flag that we set to True, while popping and peak_ahead
+        # silently ignore canceled events
+        event.cancel()
+
+    def clear(self):
+        self._events.clear()
diff --git a/mesa/experimental/devs/examples/epstein_civil_violence.py b/mesa/experimental/devs/examples/epstein_civil_violence.py
new file mode 100644
index 00000000..9a7314e0
--- /dev/null
+++ b/mesa/experimental/devs/examples/epstein_civil_violence.py
@@ -0,0 +1,273 @@
+import enum
+import math
+
+from mesa import Agent, Model
+from mesa.experimental.devs.simulator import ABMSimulator
+from mesa.space import SingleGrid
+
+
+class EpsteinAgent(Agent):
+    def __init__(self, unique_id, model, vision, movement):
+        super().__init__(unique_id, model)
+        self.vision = vision
+        self.movement = movement
+
+
+class AgentState(enum.IntEnum):
+    QUIESCENT = enum.auto()
+    ARRESTED = enum.auto()
+    ACTIVE = enum.auto()
+
+
+class Citizen(EpsteinAgent):
+    """
+    A member of the general population, may or may not be in active rebellion.
+    Summary of rule: If grievance - risk > threshold, rebel.
+
+    Attributes:
+        unique_id: unique int
+        model :
+        hardship: Agent's 'perceived hardship (i.e., physical or economic
+            privation).' Exogenous, drawn from U(0,1).
+        regime_legitimacy: Agent's perception of regime legitimacy, equal
+            across agents.  Exogenous.
+        risk_aversion: Exogenous, drawn from U(0,1).
+        threshold: if (grievance - (risk_aversion * arrest_probability)) >
+            threshold, go/remain Active
+        vision: number of cells in each direction (N, S, E and W) that agent
+            can inspect
+        condition: Can be "Quiescent" or "Active;" deterministic function of
+            greivance, perceived risk, and
+        grievance: deterministic function of hardship and regime_legitimacy;
+            how aggrieved is agent at the regime?
+        arrest_probability: agent's assessment of arrest probability, given
+            rebellion
+    """
+
+    def __init__(
+        self,
+        unique_id,
+        model,
+        vision,
+        movement,
+        hardship,
+        regime_legitimacy,
+        risk_aversion,
+        threshold,
+        arrest_prob_constant,
+    ):
+        """
+        Create a new Citizen.
+        Args:
+            unique_id: unique int
+            model : model instance
+            hardship: Agent's 'perceived hardship (i.e., physical or economic
+                privation).' Exogenous, drawn from U(0,1).
+            regime_legitimacy: Agent's perception of regime legitimacy, equal
+                across agents.  Exogenous.
+            risk_aversion: Exogenous, drawn from U(0,1).
+            threshold: if (grievance - (risk_aversion * arrest_probability)) >
+                threshold, go/remain Active
+            vision: number of cells in each direction (N, S, E and W) that
+                agent can inspect. Exogenous.
+        """
+        super().__init__(unique_id, model, vision, movement)
+        self.hardship = hardship
+        self.regime_legitimacy = regime_legitimacy
+        self.risk_aversion = risk_aversion
+        self.threshold = threshold
+        self.condition = AgentState.QUIESCENT
+        self.grievance = self.hardship * (1 - self.regime_legitimacy)
+        self.arrest_probability = None
+        self.arrest_prob_constant = arrest_prob_constant
+
+    def step(self):
+        """
+        Decide whether to activate, then move if applicable.
+        """
+        self.update_neighbors()
+        self.update_estimated_arrest_probability()
+        net_risk = self.risk_aversion * self.arrest_probability
+        if self.grievance - net_risk > self.threshold:
+            self.condition = AgentState.ACTIVE
+        else:
+            self.condition = AgentState.QUIESCENT
+        if self.movement and self.empty_neighbors:
+            new_pos = self.random.choice(self.empty_neighbors)
+            self.model.grid.move_agent(self, new_pos)
+
+    def update_neighbors(self):
+        """
+        Look around and see who my neighbors are
+        """
+        self.neighborhood = self.model.grid.get_neighborhood(
+            self.pos, moore=True, radius=self.vision
+        )
+        self.neighbors = self.model.grid.get_cell_list_contents(self.neighborhood)
+        self.empty_neighbors = [
+            c for c in self.neighborhood if self.model.grid.is_cell_empty(c)
+        ]
+
+    def update_estimated_arrest_probability(self):
+        """
+        Based on the ratio of cops to actives in my neighborhood, estimate the
+        p(Arrest | I go active).
+        """
+        cops_in_vision = len([c for c in self.neighbors if isinstance(c, Cop)])
+        actives_in_vision = 1.0  # citizen counts herself
+        for c in self.neighbors:
+            if isinstance(c, Citizen) and c.condition == AgentState.ACTIVE:
+                actives_in_vision += 1
+        self.arrest_probability = 1 - math.exp(
+            -1 * self.arrest_prob_constant * (cops_in_vision / actives_in_vision)
+        )
+
+    def sent_to_jail(self, value):
+        self.model.active_agents.remove(self)
+        self.condition = AgentState.ARRESTED
+        self.model.simulator.schedule_event_relative(self.release_from_jail, value)
+
+    def release_from_jail(self):
+        self.model.active_agents.add(self)
+        self.condition = AgentState.QUIESCENT
+
+
+class Cop(EpsteinAgent):
+    """
+    A cop for life.  No defection.
+    Summary of rule: Inspect local vision and arrest a random active agent.
+
+    Attributes:
+        unique_id: unique int
+        x, y: Grid coordinates
+        vision: number of cells in each direction (N, S, E and W) that cop is
+            able to inspect
+    """
+
+    def __init__(self, unique_id, model, vision, movement, max_jail_term):
+        super().__init__(unique_id, model, vision, movement)
+        self.max_jail_term = max_jail_term
+
+    def step(self):
+        """
+        Inspect local vision and arrest a random active agent. Move if
+        applicable.
+        """
+        self.update_neighbors()
+        active_neighbors = []
+        for agent in self.neighbors:
+            if isinstance(agent, Citizen) and agent.condition == "Active":
+                active_neighbors.append(agent)
+        if active_neighbors:
+            arrestee = self.random.choice(active_neighbors)
+            arrestee.sent_to_jail(self.random.randint(0, self.max_jail_term))
+        if self.movement and self.empty_neighbors:
+            new_pos = self.random.choice(self.empty_neighbors)
+            self.model.grid.move_agent(self, new_pos)
+
+    def update_neighbors(self):
+        """
+        Look around and see who my neighbors are.
+        """
+        self.neighborhood = self.model.grid.get_neighborhood(
+            self.pos, moore=True, radius=self.vision
+        )
+        self.neighbors = self.model.grid.get_cell_list_contents(self.neighborhood)
+        self.empty_neighbors = [
+            c for c in self.neighborhood if self.model.grid.is_cell_empty(c)
+        ]
+
+
+class EpsteinCivilViolence(Model):
+    """
+    Model 1 from "Modeling civil violence: An agent-based computational
+    approach," by Joshua Epstein.
+    http://www.pnas.org/content/99/suppl_3/7243.full
+    Attributes:
+        height: grid height
+        width: grid width
+        citizen_density: approximate % of cells occupied by citizens.
+        cop_density: approximate % of cells occupied by cops.
+        citizen_vision: number of cells in each direction (N, S, E and W) that
+            citizen can inspect
+        cop_vision: number of cells in each direction (N, S, E and W) that cop
+            can inspect
+        legitimacy:  (L) citizens' perception of regime legitimacy, equal
+            across all citizens
+        max_jail_term: (J_max)
+        active_threshold: if (grievance - (risk_aversion * arrest_probability))
+            > threshold, citizen rebels
+        arrest_prob_constant: set to ensure agents make plausible arrest
+            probability estimates
+        movement: binary, whether agents try to move at step end
+        max_iters: model may not have a natural stopping point, so we set a
+            max.
+    """
+
+    def __init__(
+        self,
+        width=40,
+        height=40,
+        citizen_density=0.7,
+        cop_density=0.074,
+        citizen_vision=7,
+        cop_vision=7,
+        legitimacy=0.8,
+        max_jail_term=1000,
+        active_threshold=0.1,
+        arrest_prob_constant=2.3,
+        movement=True,
+        max_iters=1000,
+        seed=None,
+    ):
+        super().__init__(seed)
+        if cop_density + citizen_density > 1:
+            raise ValueError("Cop density + citizen density must be less than 1")
+
+        self.width = width
+        self.height = height
+        self.citizen_density = citizen_density
+        self.cop_density = cop_density
+
+        self.max_iters = max_iters
+
+        self.grid = SingleGrid(self.width, self.height, torus=True)
+
+        for _, pos in self.grid.coord_iter():
+            if self.random.random() < self.cop_density:
+                agent = Cop(
+                    self.next_id(),
+                    self,
+                    cop_vision,
+                    movement,
+                    max_jail_term,
+                )
+            elif self.random.random() < (self.cop_density + self.citizen_density):
+                agent = Citizen(
+                    self.next_id(),
+                    self,
+                    citizen_vision,
+                    movement,
+                    hardship=self.random.random(),
+                    regime_legitimacy=legitimacy,
+                    risk_aversion=self.random.random(),
+                    threshold=active_threshold,
+                    arrest_prob_constant=arrest_prob_constant,
+                )
+            else:
+                continue
+            self.grid.place_agent(agent, pos)
+
+        self.active_agents = self.agents
+
+    def step(self):
+        self.active_agents.shuffle(inplace=True).do("step")
+
+
+if __name__ == "__main__":
+    model = EpsteinCivilViolence(seed=15)
+    simulator = ABMSimulator()
+
+    simulator.setup(model)
+
+    simulator.run(time_delta=100)
diff --git a/mesa/experimental/devs/examples/wolf_sheep.py b/mesa/experimental/devs/examples/wolf_sheep.py
new file mode 100644
index 00000000..9fa6b3d9
--- /dev/null
+++ b/mesa/experimental/devs/examples/wolf_sheep.py
@@ -0,0 +1,250 @@
+"""
+Wolf-Sheep Predation Model
+================================
+
+Replication of the model found in NetLogo:
+    Wilensky, U. (1997). NetLogo Wolf Sheep Predation model.
+    http://ccl.northwestern.edu/netlogo/models/WolfSheepPredation.
+    Center for Connected Learning and Computer-Based Modeling,
+    Northwestern University, Evanston, IL.
+"""
+
+import mesa
+from mesa.experimental.devs.simulator import ABMSimulator
+
+
+class Animal(mesa.Agent):
+    def __init__(self, unique_id, model, moore, energy, p_reproduce, energy_from_food):
+        super().__init__(unique_id, model)
+        self.energy = energy
+        self.p_reproduce = p_reproduce
+        self.energy_from_food = energy_from_food
+        self.moore = moore
+
+    def random_move(self):
+        next_moves = self.model.grid.get_neighborhood(self.pos, self.moore, True)
+        next_move = self.random.choice(next_moves)
+        # Now move:
+        self.model.grid.move_agent(self, next_move)
+
+    def spawn_offspring(self):
+        self.energy /= 2
+        offspring = self.__class__(
+            self.model.next_id(),
+            self.model,
+            self.moore,
+            self.energy,
+            self.p_reproduce,
+            self.energy_from_food,
+        )
+        self.model.grid.place_agent(offspring, self.pos)
+
+    def feed(self): ...
+
+    def die(self):
+        self.model.grid.remove_agent(self)
+        self.remove()
+
+    def step(self):
+        self.random_move()
+        self.energy -= 1
+
+        self.feed()
+
+        if self.energy < 0:
+            self.die()
+        elif self.random.random() < self.p_reproduce:
+            self.spawn_offspring()
+
+
+class Sheep(Animal):
+    """
+    A sheep that walks around, reproduces (asexually) and gets eaten.
+
+    The init is the same as the RandomWalker.
+    """
+
+    def feed(self):
+        # If there is grass available, eat it
+        agents = self.model.grid.get_cell_list_contents(self.pos)
+        grass_patch = next(obj for obj in agents if isinstance(obj, GrassPatch))
+        if grass_patch.fully_grown:
+            self.energy += self.energy_from_food
+            grass_patch.fully_grown = False
+
+
+class Wolf(Animal):
+    """
+    A wolf that walks around, reproduces (asexually) and eats sheep.
+    """
+
+    def feed(self):
+        agents = self.model.grid.get_cell_list_contents(self.pos)
+        sheep = [obj for obj in agents if isinstance(obj, Sheep)]
+        if len(sheep) > 0:
+            sheep_to_eat = self.random.choice(sheep)
+            self.energy += self.energy
+
+            # Kill the sheep
+            sheep_to_eat.die()
+
+
+class GrassPatch(mesa.Agent):
+    """
+    A patch of grass that grows at a fixed rate and it is eaten by sheep
+    """
+
+    @property
+    def fully_grown(self) -> bool:
+        return self._fully_grown
+
+    @fully_grown.setter
+    def fully_grown(self, value: bool):
+        self._fully_grown = value
+
+        if not value:
+            self.model.simulator.schedule_event_relative(
+                setattr,
+                self.grass_regrowth_time,
+                function_args=[self, "fully_grown", True],
+            )
+
+    def __init__(self, unique_id, model, fully_grown, countdown, grass_regrowth_time):
+        """
+        Creates a new patch of grass
+
+        Args:
+            grown: (boolean) Whether the patch of grass is fully grown or not
+            countdown: Time for the patch of grass to be fully grown again
+        """
+        super().__init__(unique_id, model)
+        self._fully_grown = fully_grown
+        self.grass_regrowth_time = grass_regrowth_time
+
+        if not self.fully_grown:
+            self.model.simulator.schedule_event_relative(
+                setattr, countdown, function_args=[self, "fully_grown", True]
+            )
+
+    def set_fully_grown(self):
+        self.fully_grown = True
+
+
+class WolfSheep(mesa.Model):
+    """
+    Wolf-Sheep Predation Model
+
+    A model for simulating wolf and sheep (predator-prey) ecosystem modelling.
+    """
+
+    def __init__(
+        self,
+        height,
+        width,
+        initial_sheep,
+        initial_wolves,
+        sheep_reproduce,
+        wolf_reproduce,
+        grass_regrowth_time,
+        wolf_gain_from_food=13,
+        sheep_gain_from_food=5,
+        moore=False,
+        simulator=None,
+        seed=None,
+    ):
+        """
+        Create a new Wolf-Sheep model with the given parameters.
+
+        Args:
+            initial_sheep: Number of sheep to start with
+            initial_wolves: Number of wolves to start with
+            sheep_reproduce: Probability of each sheep reproducing each step
+            wolf_reproduce: Probability of each wolf reproducing each step
+            wolf_gain_from_food: Energy a wolf gains from eating a sheep
+            grass: Whether to have the sheep eat grass for energy
+            grass_regrowth_time: How long it takes for a grass patch to regrow
+                                 once it is eaten
+            sheep_gain_from_food: Energy sheep gain from grass, if enabled.
+            moore:
+        """
+        super().__init__(seed=seed)
+        # Set parameters
+        self.height = height
+        self.width = width
+        self.initial_sheep = initial_sheep
+        self.initial_wolves = initial_wolves
+        self.simulator = simulator
+
+        # self.sheep_reproduce = sheep_reproduce
+        # self.wolf_reproduce = wolf_reproduce
+        # self.grass_regrowth_time = grass_regrowth_time
+        # self.wolf_gain_from_food = wolf_gain_from_food
+        # self.sheep_gain_from_food = sheep_gain_from_food
+        # self.moore = moore
+
+        self.grid = mesa.space.MultiGrid(self.height, self.width, torus=False)
+
+        for _ in range(self.initial_sheep):
+            pos = (
+                self.random.randrange(self.width),
+                self.random.randrange(self.height),
+            )
+            energy = self.random.randrange(2 * sheep_gain_from_food)
+            sheep = Sheep(
+                self.next_id(),
+                self,
+                moore,
+                energy,
+                sheep_reproduce,
+                sheep_gain_from_food,
+            )
+            self.grid.place_agent(sheep, pos)
+
+        # Create wolves
+        for _ in range(self.initial_wolves):
+            pos = (
+                self.random.randrange(self.width),
+                self.random.randrange(self.height),
+            )
+            energy = self.random.randrange(2 * wolf_gain_from_food)
+            wolf = Wolf(
+                self.next_id(),
+                self,
+                moore,
+                energy,
+                wolf_reproduce,
+                wolf_gain_from_food,
+            )
+            self.grid.place_agent(wolf, pos)
+
+        # Create grass patches
+        possibly_fully_grown = [True, False]
+        for _agent, pos in self.grid.coord_iter():
+            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.next_id(), self, fully_grown, countdown, grass_regrowth_time
+            )
+            self.grid.place_agent(patch, pos)
+
+    def step(self):
+        self.get_agents_of_type(Sheep).shuffle(inplace=True).do("step")
+        self.get_agents_of_type(Wolf).shuffle(inplace=True).do("step")
+
+
+if __name__ == "__main__":
+    import time
+
+    simulator = ABMSimulator()
+
+    model = WolfSheep(25, 25, 60, 40, 0.2, 0.1, 20, simulator=simulator, seed=15)
+
+    simulator.setup(model)
+
+    start_time = time.perf_counter()
+    simulator.run(100)
+    print(simulator.time)
+    print("Time:", time.perf_counter() - start_time)
diff --git a/mesa/experimental/devs/simulator.py b/mesa/experimental/devs/simulator.py
new file mode 100644
index 00000000..74f018e8
--- /dev/null
+++ b/mesa/experimental/devs/simulator.py
@@ -0,0 +1,294 @@
+from __future__ import annotations
+
+import numbers
+from collections.abc import Callable
+from typing import Any
+
+from mesa import Model
+
+from .eventlist import EventList, Priority, SimulationEvent
+
+
+class Simulator:
+    """The Simulator controls the time advancement of the model.
+
+    The simulator uses next event time progression to advance the simulation time, and execute the next event
+
+    Attributes:
+        event_list (EventList): The list of events to execute
+        time (float | int): The current simulation time
+        time_unit (type) : The unit of the simulation time
+        model (Model): The model to simulate
+
+
+    """
+
+    # TODO: add replication support
+    # TODO: add experimentation support
+
+    def __init__(self, time_unit: type, start_time: int | float):
+        # should model run in a separate thread,
+        # and we can then interact with start, stop, run_until, and step?
+        self.event_list = EventList()
+        self.start_time = start_time
+        self.time_unit = time_unit
+
+        self.time = self.start_time
+        self.model = None
+
+    def check_time_unit(self, time: int | float) -> bool: ...
+
+    def setup(self, model: Model) -> None:
+        """Set up the simulator with the model to simulate
+
+        Args:
+            model (Model): The model to simulate
+
+        """
+        self.event_list.clear()
+        self.model = model
+
+    def reset(self):
+        """Reset the simulator by clearing the event list and removing the model to simulate"""
+        self.event_list.clear()
+        self.model = None
+        self.time = self.start_time
+
+    def run_until(self, end_time: int | float) -> None:
+        while True:
+            try:
+                event = self.event_list.pop_event()
+            except IndexError:  # event list is empty
+                self.time = end_time
+                break
+
+            if event.time <= end_time:
+                self.time = event.time
+                event.execute()
+            else:
+                self.time = end_time
+                self._schedule_event(event)  # reschedule event
+                break
+
+    def run_for(self, time_delta: int | float):
+        """run the simulator for the specified time delta
+
+        Args:
+            time_delta (float| int): The time delta. The simulator is run from the current time to the current time
+                                     plus the time delta
+
+        """
+        end_time = self.time + time_delta
+        self.run_until(end_time)
+
+    def schedule_event_now(
+        self,
+        function: Callable,
+        priority: Priority = Priority.DEFAULT,
+        function_args: list[Any] | None = None,
+        function_kwargs: dict[str, Any] | None = None,
+    ) -> SimulationEvent:
+        """Schedule event for the current time instant
+
+        Args:
+            function (Callable): The callable to execute for this event
+            priority (Priority): the priority of the event, optional
+            function_args (List[Any]): list of arguments for function
+            function_kwargs (Dict[str, Any]):  dict of keyword arguments for function
+
+        Returns:
+            SimulationEvent: the simulation event that is scheduled
+
+        """
+        return self.schedule_event_relative(
+            function,
+            0.0,
+            priority=priority,
+            function_args=function_args,
+            function_kwargs=function_kwargs,
+        )
+
+    def schedule_event_absolute(
+        self,
+        function: Callable,
+        time: int | float,
+        priority: Priority = Priority.DEFAULT,
+        function_args: list[Any] | None = None,
+        function_kwargs: dict[str, Any] | None = None,
+    ) -> SimulationEvent:
+        """Schedule event for the specified time instant
+
+        Args:
+            function (Callable): The callable to execute for this event
+            time (int | float): the time for which to schedule the event
+            priority (Priority): the priority of the event, optional
+            function_args (List[Any]): list of arguments for function
+            function_kwargs (Dict[str, Any]):  dict of keyword arguments for function
+
+        Returns:
+            SimulationEvent: the simulation event that is scheduled
+
+        """
+        if self.time > time:
+            raise ValueError("trying to schedule an event in the past")
+
+        event = SimulationEvent(
+            time,
+            function,
+            priority=priority,
+            function_args=function_args,
+            function_kwargs=function_kwargs,
+        )
+        self._schedule_event(event)
+        return event
+
+    def schedule_event_relative(
+        self,
+        function: Callable,
+        time_delta: int | float,
+        priority: Priority = Priority.DEFAULT,
+        function_args: list[Any] | None = None,
+        function_kwargs: dict[str, Any] | None = None,
+    ) -> SimulationEvent:
+        """Schedule event for the current time plus the time delta
+
+        Args:
+            function (Callable): The callable to execute for this event
+            time_delta (int | float): the time delta
+            priority (Priority): the priority of the event, optional
+            function_args (List[Any]): list of arguments for function
+            function_kwargs (Dict[str, Any]):  dict of keyword arguments for function
+
+        Returns:
+            SimulationEvent: the simulation event that is scheduled
+
+        """
+        event = SimulationEvent(
+            self.time + time_delta,
+            function,
+            priority=priority,
+            function_args=function_args,
+            function_kwargs=function_kwargs,
+        )
+        self._schedule_event(event)
+        return event
+
+    def cancel_event(self, event: SimulationEvent) -> None:
+        """remove the event from the event list
+
+        Args:
+            event (SimulationEvent): The simulation event to remove
+
+        """
+
+        self.event_list.remove(event)
+
+    def _schedule_event(self, event: SimulationEvent):
+        if not self.check_time_unit(event.time):
+            raise ValueError(
+                f"time unit mismatch {event.time} is not of time unit {self.time_unit}"
+            )
+
+        # check timeunit of events
+        self.event_list.add_event(event)
+
+
+class ABMSimulator(Simulator):
+    """This simulator uses incremental time progression, while allowing for additional event scheduling.
+
+    The basic time unit of this simulator is an integer. It schedules `model.step` for each tick with the
+    highest priority. This implies that by default, `model.step` is the first event executed at a specific tick.
+    In addition, discrete event scheduling, using integer as the time unit is fully supported, paving the way
+    for hybrid ABM-DEVS simulations.
+
+    """
+
+    def __init__(self):
+        super().__init__(int, 0)
+
+    def setup(self, model):
+        super().setup(model)
+        self.schedule_event_now(self.model.step, priority=Priority.HIGH)
+
+    def check_time_unit(self, time) -> bool:
+        if isinstance(time, int):
+            return True
+        if isinstance(time, float):
+            return time.is_integer()
+        else:
+            return False
+
+    def schedule_event_next_tick(
+        self,
+        function: Callable,
+        priority: Priority = Priority.DEFAULT,
+        function_args: list[Any] | None = None,
+        function_kwargs: dict[str, Any] | None = None,
+    ) -> SimulationEvent:
+        """Schedule a SimulationEvent for the next tick
+
+        Args
+            function (Callable): the callable to execute
+            priority (Priority): the priority of the event
+            function_args (List[Any]): List of arguments to pass to the callable
+            function_kwargs (Dict[str, Any]): List of keyword arguments to pass to the callable
+
+        """
+        return self.schedule_event_relative(
+            function,
+            1,
+            priority=priority,
+            function_args=function_args,
+            function_kwargs=function_kwargs,
+        )
+
+    def run_until(self, end_time: int) -> None:
+        """run the simulator up to and included the specified end time
+
+        Args:
+            end_time (float| int): The end_time delta. The simulator is until the specified end time
+
+        """
+        while True:
+            try:
+                event = self.event_list.pop_event()
+            except IndexError:
+                self.time = end_time
+                break
+
+            if event.time <= end_time:
+                self.time = event.time
+                if event.fn() == self.model.step:
+                    self.schedule_event_next_tick(
+                        self.model.step, priority=Priority.HIGH
+                    )
+
+                event.execute()
+            else:
+                self.time = end_time
+                self._schedule_event(event)
+                break
+
+    def run_for(self, time_delta: int):
+        """run the simulator for the specified time delta
+
+        Args:
+            time_delta (float| int): The time delta. The simulator is run from the current time to the current time
+                                     plus the time delta
+
+        """
+        end_time = self.time + time_delta - 1
+        self.run_until(end_time)
+
+
+class DEVSimulator(Simulator):
+    """A simulator where the unit of time is a float. Can be used for full-blown discrete event simulating using
+    event scheduling.
+
+    """
+
+    def __init__(self):
+        super().__init__(float, 0.0)
+
+    def check_time_unit(self, time) -> bool:
+        return isinstance(time, numbers.Number)
diff --git a/mesa/model.py b/mesa/model.py
index 785ffe0c..d998e112 100644
--- a/mesa/model.py
+++ b/mesa/model.py
@@ -3,6 +3,7 @@ The model class for Mesa framework.
 
 Core Objects: Model
 """
+
 # Mypy; for the `|` operator purpose
 # Remove this __future__ import once the oldest supported Python is 3.10
 from __future__ import annotations
@@ -13,12 +14,12 @@ import warnings
 from collections import defaultdict
 
 # mypy
-from typing import Any, Union
+from typing import Any
 
 from mesa.agent import Agent, AgentSet
 from mesa.datacollection import DataCollector
 
-TimeT = Union[float, int]
+TimeT = float | int
 
 
 class Model:
@@ -55,7 +56,7 @@ class Model:
         if obj._seed is None:
             # We explicitly specify the seed here so that we know its value in
             # advance.
-            obj._seed = random.random()  # noqa: S311
+            obj._seed = random.random()
         obj.random = random.Random(obj._seed)
         # TODO: Remove these 2 lines just before Mesa 3.0
         obj._steps = 0
@@ -76,9 +77,6 @@ class Model:
         self._steps: int = 0
         self._time: TimeT = 0  # the model's clock
 
-        # Warning flags for current experimental features. These make sure a warning is only printed once per model.
-        self.agentset_experimental_warning_given = False
-
     @property
     def agents(self) -> AgentSet:
         """Provides an AgentSet of all agents in the model, combining agents from all types."""
diff --git a/mesa/space.py b/mesa/space.py
index 9be3b563..a6d070f0 100644
--- a/mesa/space.py
+++ b/mesa/space.py
@@ -23,9 +23,9 @@ import inspect
 import itertools
 import math
 import warnings
-from collections.abc import Iterable, Iterator, Sequence
+from collections.abc import Callable, Iterable, Iterator, Sequence
 from numbers import Real
-from typing import Any, Callable, TypeVar, Union, cast, overload
+from typing import Any, TypeVar, cast, overload
 from warnings import warn
 
 with contextlib.suppress(ImportError):
@@ -42,12 +42,12 @@ _types_integer = (int, np.integer)
 
 Coordinate = tuple[int, int]
 # used in ContinuousSpace
-FloatCoordinate = Union[tuple[float, float], npt.NDArray[float]]
+FloatCoordinate = tuple[float, float] | npt.NDArray[float]
 NetworkCoordinate = int
 
-Position = Union[Coordinate, FloatCoordinate, NetworkCoordinate]
+Position = Coordinate | FloatCoordinate | NetworkCoordinate
 
-GridContent = Union[Agent, None]
+GridContent = Agent | None
 MultiGridContent = list[Agent]
 
 F = TypeVar("F", bound=Callable[..., Any])
@@ -71,6 +71,21 @@ def is_integer(x: Real) -> bool:
     return isinstance(x, _types_integer)
 
 
+def warn_if_agent_has_position_already(placement_func):
+    def wrapper(self, agent, *args, **kwargs):
+        if agent.pos is not None:
+            warnings.warn(
+                f"""Agent {agent.unique_id} is being placed with
+place_agent() despite already having the position {agent.pos}. In most
+cases, you'd want to clear the current position with remove_agent()
+before placing the agent again.""",
+                stacklevel=2,
+            )
+        placement_func(self, agent, *args, **kwargs)
+
+    return wrapper
+
+
 class _Grid:
     """Base class for a rectangular grid.
 
@@ -135,14 +150,12 @@ class _Grid:
         self._empties_built = True
 
     @overload
-    def __getitem__(self, index: int | Sequence[Coordinate]) -> list[GridContent]:
-        ...
+    def __getitem__(self, index: int | Sequence[Coordinate]) -> list[GridContent]: ...
 
     @overload
     def __getitem__(
         self, index: tuple[int | slice, int | slice]
-    ) -> GridContent | list[GridContent]:
-        ...
+    ) -> GridContent | list[GridContent]: ...
 
     def __getitem__(self, index):
         """Access contents from the grid."""
@@ -405,11 +418,9 @@ class _Grid:
         """
         return list(self.iter_cell_list_contents(cell_list))
 
-    def place_agent(self, agent: Agent, pos: Coordinate) -> None:
-        ...
+    def place_agent(self, agent: Agent, pos: Coordinate) -> None: ...
 
-    def remove_agent(self, agent: Agent) -> None:
-        ...
+    def remove_agent(self, agent: Agent) -> None: ...
 
     def move_agent(self, agent: Agent, pos: Coordinate) -> None:
         """Move an agent from its current position to a new position.
@@ -450,6 +461,7 @@ class _Grid:
                 # Find the closest position without sorting all positions
                 closest_pos = None
                 min_distance = float("inf")
+                agent.random.shuffle(pos)
                 for p in pos:
                     distance = self._distance_squared(p, current_pos)
                     if distance < min_distance:
@@ -763,16 +775,14 @@ class _PropertyGrid(_Grid):
 
     Attributes:
         properties (dict): A dictionary mapping property layer names to PropertyLayer instances.
-        empty_mask: Returns a boolean mask indicating empty cells on the grid.
+        empty_mask (np.ndarray): A boolean array indicating empty cells on the grid.
 
     Methods:
         add_property_layer(property_layer): Adds a new property layer to the grid.
         remove_property_layer(property_name): Removes a property layer from the grid by its name.
         get_neighborhood_mask(pos, moore, include_center, radius): Generates a boolean mask of the neighborhood.
-        select_cells_by_properties(conditions, mask, return_list): Selects cells based on multiple property conditions,
-            optionally with a mask, returning either a list of coordinates or a mask.
-        select_extreme_value_cells(property_name, mode, mask, return_list): Selects cells with extreme values of a property,
-            optionally with a mask, returning either a list of coordinates or a mask.
+        select_cells(conditions, extreme_values, masks, only_empty, return_list): Selects cells based on multiple conditions,
+            extreme values, masks, with an option to select only empty cells, returning either a list of coordinates or a mask.
 
     Mask Usage:
         Several methods in this class accept a mask as an input, which is a NumPy ndarray of boolean values. This mask
@@ -978,6 +988,7 @@ class SingleGrid(_PropertyGrid):
                  the grid for empty spaces.
     """
 
+    @warn_if_agent_has_position_already
     def place_agent(self, agent: Agent, pos: Coordinate) -> None:
         """Place the agent at the specified location, and set its pos variable."""
         if self.is_cell_empty(pos):
@@ -1026,6 +1037,7 @@ class MultiGrid(_PropertyGrid):
         """Default value for new cell elements."""
         return []
 
+    @warn_if_agent_has_position_already
     def place_agent(self, agent: Agent, pos: Coordinate) -> None:
         """Place the agent at the specified location, and set its pos variable."""
         x, y = pos
@@ -1357,6 +1369,7 @@ class ContinuousSpace:
         self._agent_points = None
         self._index_to_agent = {}
 
+    @warn_if_agent_has_position_already
     def place_agent(self, agent: Agent, pos: FloatCoordinate) -> None:
         """Place a new agent in the space.
 
@@ -1517,6 +1530,7 @@ class NetworkGrid:
         """Default value for a new node."""
         return []
 
+    @warn_if_agent_has_position_already
     def place_agent(self, agent: Agent, node_id: int) -> None:
         """Place an agent in a node."""
         self.G.nodes[node_id]["agent"].append(agent)
diff --git a/mesa/time.py b/mesa/time.py
index 50d564be..e47563b4 100644
--- a/mesa/time.py
+++ b/mesa/time.py
@@ -25,21 +25,17 @@ Key concepts:
 # Remove this __future__ import once the oldest supported Python is 3.10
 from __future__ import annotations
 
-import heapq
 import warnings
-import weakref
 from collections import defaultdict
 from collections.abc import Iterable
 
 # mypy
-from typing import Union
-
 from mesa.agent import Agent, AgentSet
 from mesa.model import Model
 
 # BaseScheduler has a self.time of int, while
 # StagedActivation has a self.time of float
-TimeT = Union[float, int]
+TimeT = float | int
 
 
 class BaseScheduler:
@@ -393,46 +389,7 @@ class RandomActivationByType(BaseScheduler):
 
 class DiscreteEventScheduler(BaseScheduler):
     """
-    A scheduler for discrete event simulation in Mesa.
-
-    This scheduler manages events where each event is associated with a
-    specific time and agent. The scheduler advances time not in fixed
-    increments, but to the moment the next event is scheduled to occur.
-
-    This implementation uses a priority queue (heapq) to manage events. Each
-    event is a tuple of the form (time, random_value, agent), where:
-        - time (float): The scheduled time for the event.
-        - random_value (float): A secondary sorting criterion to randomize
-          the order of events that are scheduled for the same time.
-        - agent (Agent): The agent associated with the event.
-
-    The random value for secondary sorting ensures that when two events are
-    scheduled for the same time, their execution order is randomized, thus
-    preventing direct comparison issues between different types of agents and
-    maintaining the integrity of the simulation's randomness.
-
-    Attributes:
-        model (Model): The model instance associated with the scheduler.
-        event_queue (list): A priority queue of scheduled events.
-        time_step (int or float): The fixed time period by which the model advances
-                                  on each step. Defaults to 1.
-
-    Methods:
-        schedule_event(time, agent): Schedule an event for a specific time.
-        schedule_in(delay, agent): Schedule an event after a specified delay.
-        step(): Execute all events within the next time_step period.
-        get_next_event_time(): Returns the time of the next scheduled event.
-
-    Usage:
-        1. Instantiate the DiscreteEventScheduler with a model instance and a time_step period.
-        2. Add agents to the scheduler using schedule.add(). With schedule_now=True (default),
-              the first event for the agent will be scheduled immediately.
-        3. In the Agent step() method, schedule the next event for the agent
-              (using schedule_in or schedule_event).
-        3. Add self.schedule.step() to the model's step() method, as usual.
-
-    Now, with each model step, the scheduler will execute all events within the
-    next time_step period, and advance time one time_step forward.
+    This class has been deprecated and replaced by the functionality provided by experimental.devs
     """
 
     def __init__(self, model: Model, time_step: TimeT = 1) -> None:
@@ -444,72 +401,6 @@ class DiscreteEventScheduler(BaseScheduler):
 
         """
         super().__init__(model)
-        self.event_queue: list[tuple[TimeT, float, weakref.ref]] = []
-        self.time_step: TimeT = time_step  # Fixed time period for each step
-
-        warnings.warn(
-            "The DiscreteEventScheduler is experimental. It may be changed or removed in any and all future releases, including patch releases.\n"
-            "We would love to hear what you think about this new feature. If you have any thoughts, share them with us here: https://github.com/projectmesa/mesa/discussions/1923",
-            FutureWarning,
-            stacklevel=2,
+        raise Exception(
+            "DiscreteEventScheduler is deprecated in favor of the functionality provided by experimental.devs"
         )
-
-    def schedule_event(self, time: TimeT, agent: Agent) -> None:
-        """Schedule an event for an agent at a specific time."""
-        if time < self.time:
-            raise ValueError(
-                f"Scheduled time ({time}) must be >= the current time ({self.time})"
-            )
-        if agent not in self._agents:
-            raise ValueError(
-                "trying to schedule an event for agent which is not known to the scheduler"
-            )
-
-        # Create an event, sorted first on time, secondary on a random value
-        event = (time, self.model.random.random(), weakref.ref(agent))
-        heapq.heappush(self.event_queue, event)
-
-    def schedule_in(self, delay: TimeT, agent: Agent) -> None:
-        """Schedule an event for an agent after a specified delay."""
-        if delay < 0:
-            raise ValueError("Delay must be non-negative")
-        event_time = self.time + delay
-        self.schedule_event(event_time, agent)
-
-    def step(self) -> None:
-        """Execute the next event and advance the time."""
-        end_time = self.time + self.time_step
-
-        while self.event_queue and self.event_queue[0][0] <= end_time:
-            # Get the next event (ignore the random value during unpacking)
-            time, _, agent = heapq.heappop(self.event_queue)
-            agent = agent()  # unpack weakref
-
-            if agent:
-                # Advance time to the event's time
-                self.time = time
-                # Execute the event
-                agent.step()
-
-        # After processing events, advance time by the time_step
-        self.time = end_time
-        self.steps += 1
-
-    def get_next_event_time(self) -> TimeT | None:
-        """Returns the time of the next scheduled event."""
-        if not self.event_queue:
-            return None
-        return self.event_queue[0][0]
-
-    def add(self, agent: Agent, schedule_now: bool = True) -> None:
-        """Add an Agent object to the schedule and optionally schedule its first event.
-
-        Args:
-            agent: An Agent to be added to the schedule. Must have a step() method.
-            schedule_now: If True, schedules the first event for the agent immediately.
-        """
-        super().add(agent)  # Call the add method from BaseScheduler
-
-        if schedule_now:
-            # Schedule the first event immediately
-            self.schedule_event(self.time, agent)
diff --git a/mesa/visualization/UserParam.py b/mesa/visualization/UserParam.py
index d4136238..58715f32 100644
--- a/mesa/visualization/UserParam.py
+++ b/mesa/visualization/UserParam.py
@@ -1,1 +1,56 @@
-from mesa_viz_tornado.UserParam import *  # noqa
+class UserParam:
+    _ERROR_MESSAGE = "Missing or malformed inputs for '{}' Option '{}'"
+
+    def maybe_raise_error(self, param_type, valid):
+        if valid:
+            return
+        msg = self._ERROR_MESSAGE.format(param_type, self.label)
+        raise ValueError(msg)
+
+
+class Slider(UserParam):
+    """
+    A number-based slider input with settable increment.
+
+    Example:
+
+    slider_option = Slider("My Slider", value=123, min=10, max=200, step=0.1)
+
+    Args:
+        label: The displayed label in the UI
+        value: The initial value of the slider
+        min: The minimum possible value of the slider
+        max: The maximum possible value of the slider
+        step: The step between min and max for a range of possible values
+        dtype: either int or float
+    """
+
+    def __init__(
+        self,
+        label="",
+        value=None,
+        min=None,
+        max=None,
+        step=1,
+        dtype=None,
+    ):
+        self.label = label
+        self.value = value
+        self.min = min
+        self.max = max
+        self.step = step
+
+        # Validate option type to make sure values are supplied properly
+        valid = not (self.value is None or self.min is None or self.max is None)
+        self.maybe_raise_error("slider", valid)
+
+        if dtype is None:
+            self.is_float_slider = self._check_values_are_float(value, min, max, step)
+        else:
+            self.is_float_slider = dtype == float
+
+    def _check_values_are_float(self, value, min, max, step):
+        return any(isinstance(n, float) for n in (value, min, max, step))
+
+    def get(self, attr):
+        return getattr(self, attr)
diff --git a/mesa/visualization/__init__.py b/mesa/visualization/__init__.py
index e8b7256e..f6611e0a 100644
--- a/mesa/visualization/__init__.py
+++ b/mesa/visualization/__init__.py
@@ -1,7 +1,3 @@
-import contextlib
+from .jupyter_viz import JupyterViz, Slider, make_text
 
-with contextlib.suppress(ImportError):
-    from mesa_viz_tornado.ModularVisualization import *  # noqa
-    from mesa_viz_tornado.modules import *  # noqa
-    from mesa_viz_tornado.UserParam import *  # noqa
-    from mesa_viz_tornado.TextVisualization import *  # noqa
+__all__ = ["JupyterViz", "make_text", "Slider"]
diff --git a/mesa/visualization/components/altair.py b/mesa/visualization/components/altair.py
new file mode 100644
index 00000000..f9d1a81c
--- /dev/null
+++ b/mesa/visualization/components/altair.py
@@ -0,0 +1,71 @@
+import contextlib
+
+import solara
+
+with contextlib.suppress(ImportError):
+    import altair as alt
+
+
+@solara.component
+def SpaceAltair(model, agent_portrayal, dependencies: list[any] | None = None):
+    space = getattr(model, "grid", None)
+    if space is None:
+        # Sometimes the space is defined as model.space instead of model.grid
+        space = model.space
+    chart = _draw_grid(space, agent_portrayal)
+    solara.FigureAltair(chart)
+
+
+def _draw_grid(space, agent_portrayal):
+    def portray(g):
+        all_agent_data = []
+        for content, (x, y) in g.coord_iter():
+            if not content:
+                continue
+            if not hasattr(content, "__iter__"):
+                # Is a single grid
+                content = [content]  # noqa: PLW2901
+            for agent in content:
+                # use all data from agent portrayal, and add x,y coordinates
+                agent_data = agent_portrayal(agent)
+                agent_data["x"] = x
+                agent_data["y"] = y
+                all_agent_data.append(agent_data)
+        return all_agent_data
+
+    all_agent_data = portray(space)
+    invalid_tooltips = ["color", "size", "x", "y"]
+
+    encoding_dict = {
+        # no x-axis label
+        "x": alt.X("x", axis=None, type="ordinal"),
+        # no y-axis label
+        "y": alt.Y("y", axis=None, type="ordinal"),
+        "tooltip": [
+            alt.Tooltip(key, type=alt.utils.infer_vegalite_type([value]))
+            for key, value in all_agent_data[0].items()
+            if key not in invalid_tooltips
+        ],
+    }
+    has_color = "color" in all_agent_data[0]
+    if has_color:
+        encoding_dict["color"] = alt.Color("color", type="nominal")
+    has_size = "size" in all_agent_data[0]
+    if has_size:
+        encoding_dict["size"] = alt.Size("size", type="quantitative")
+
+    chart = (
+        alt.Chart(
+            alt.Data(values=all_agent_data), encoding=alt.Encoding(**encoding_dict)
+        )
+        .mark_point(filled=True)
+        .properties(width=280, height=280)
+        # .configure_view(strokeOpacity=0)  # hide grid/chart lines
+    )
+    # This is the default value for the marker size, which auto-scales
+    # according to the grid area.
+    if not has_size:
+        length = min(space.width, space.height)
+        chart = chart.mark_point(size=30000 / length**2, filled=True)
+
+    return chart
diff --git a/mesa/experimental/components/matplotlib.py b/mesa/visualization/components/matplotlib.py
similarity index 91%
rename from mesa/experimental/components/matplotlib.py
rename to mesa/visualization/components/matplotlib.py
index c43ad964..6284944a 100644
--- a/mesa/experimental/components/matplotlib.py
+++ b/mesa/visualization/components/matplotlib.py
@@ -1,5 +1,3 @@
-from typing import Optional
-
 import networkx as nx
 import solara
 from matplotlib.figure import Figure
@@ -9,7 +7,7 @@ import mesa
 
 
 @solara.component
-def SpaceMatplotlib(model, agent_portrayal, dependencies: Optional[list[any]] = None):
+def SpaceMatplotlib(model, agent_portrayal, dependencies: list[any] | None = None):
     space_fig = Figure()
     space_ax = space_fig.subplots()
     space = getattr(model, "grid", None)
@@ -48,6 +46,9 @@ def _draw_grid(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"] = (180 / min(g.width, g.height)) ** 2
         if len(s) > 0:
             out["s"] = s
         if len(c) > 0:
@@ -113,7 +114,7 @@ def _draw_continuous_space(space, space_ax, agent_portrayal):
 
 
 @solara.component
-def PlotMatplotlib(model, measure, dependencies: Optional[list[any]] = None):
+def PlotMatplotlib(model, measure, dependencies: list[any] | None = None):
     fig = Figure()
     ax = fig.subplots()
     df = model.datacollector.get_model_vars_dataframe()
@@ -124,7 +125,7 @@ def PlotMatplotlib(model, measure, dependencies: Optional[list[any]] = None):
         for m, color in measure.items():
             ax.plot(df.loc[:, m], label=m, color=color)
         fig.legend()
-    elif isinstance(measure, (list, tuple)):
+    elif isinstance(measure, list | tuple):
         for m in measure:
             ax.plot(df.loc[:, m], label=m)
         fig.legend()
diff --git a/mesa/experimental/jupyter_viz.py b/mesa/visualization/jupyter_viz.py
similarity index 82%
rename from mesa/experimental/jupyter_viz.py
rename to mesa/visualization/jupyter_viz.py
index a6ae318a..2067dcfd 100644
--- a/mesa/experimental/jupyter_viz.py
+++ b/mesa/visualization/jupyter_viz.py
@@ -6,8 +6,9 @@ import reacton.ipywidgets as widgets
 import solara
 from solara.alias import rv
 
-import mesa.experimental.components.matplotlib as components_matplotlib
-from mesa.experimental.UserParam import Slider
+import mesa.visualization.components.altair as components_altair
+import mesa.visualization.components.matplotlib as components_matplotlib
+from mesa.visualization.UserParam import Slider
 
 # Avoid interactive backend
 plt.switch_backend("agg")
@@ -16,7 +17,7 @@ plt.switch_backend("agg")
 # 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, current_step, color, layout_type
+    model, measures, agent_portrayal, space_drawer, dependencies, color, layout_type
 ):
     with rv.Card(
         style_=f"background-color: {color}; width: 100%; height: 100%"
@@ -26,7 +27,11 @@ def Card(
             if space_drawer == "default":
                 # draw with the default implementation
                 components_matplotlib.SpaceMatplotlib(
-                    model, agent_portrayal, dependencies=[current_step.value]
+                    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
@@ -39,7 +44,7 @@ def Card(
                 measure(model)
             else:
                 components_matplotlib.PlotMatplotlib(
-                    model, measure, dependencies=[current_step.value]
+                    model, measure, dependencies=dependencies
                 )
     return main
 
@@ -53,6 +58,7 @@ def JupyterViz(
     agent_portrayal=None,
     space_drawer="default",
     play_interval=150,
+    seed=None,
 ):
     """Initialize a component to visualize a model.
     Args:
@@ -66,6 +72,7 @@ def JupyterViz(
             simulations with no space to visualize should
             specify `space_drawer=False`
         play_interval: play interval (default: 150)
+        seed: the random seed used to initialize the model
     """
     if name is None:
         name = model_class.__name__
@@ -73,6 +80,7 @@ def JupyterViz(
     current_step = solara.use_reactive(0)
 
     # 1. Set up model parameters
+    reactive_seed = solara.use_reactive(0)
     user_params, fixed_params = split_model_params(model_params)
     model_parameters, set_model_parameters = solara.use_state(
         {**fixed_params, **{k: v.get("value") for k, v in user_params.items()}}
@@ -80,13 +88,21 @@ def JupyterViz(
 
     # 2. Set up Model
     def make_model():
-        model = model_class(**model_parameters)
+        model = model_class.__new__(
+            model_class, **model_parameters, seed=reactive_seed.value
+        )
+        model.__init__(**model_parameters)
         current_step.value = 0
         return model
 
     reset_counter = solara.use_reactive(0)
     model = solara.use_memo(
-        make_model, dependencies=[*list(model_parameters.values()), reset_counter.value]
+        make_model,
+        dependencies=[
+            *list(model_parameters.values()),
+            reset_counter.value,
+            reactive_seed.value,
+        ],
     )
 
     def handle_change_model_params(name: str, value: any):
@@ -98,8 +114,12 @@ def JupyterViz(
         solara.AppBarTitle(name)
 
     # render layout and plot
+    def do_reseed():
+        reactive_seed.value = model.random.random()
 
     # jupyter
+    dependencies = [current_step.value, reactive_seed.value]
+
     def render_in_jupyter():
         with solara.GridFixed(columns=2):
             UserInputs(user_params, on_change=handle_change_model_params)
@@ -111,7 +131,11 @@ def JupyterViz(
             if space_drawer == "default":
                 # draw with the default implementation
                 components_matplotlib.SpaceMatplotlib(
-                    model, agent_portrayal, dependencies=[current_step.value]
+                    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
@@ -125,7 +149,7 @@ def JupyterViz(
                     measure(model)
                 else:
                     components_matplotlib.PlotMatplotlib(
-                        model, measure, dependencies=[current_step.value]
+                        model, measure, dependencies=dependencies
                     )
 
     def render_in_browser():
@@ -135,15 +159,21 @@ def JupyterViz(
         if measures:
             layout_types += [{"Measure": elem} for elem in range(len(measures))]
 
-        grid_layout_initial = get_initial_grid_layout(layout_types=layout_types)
+        grid_layout_initial = make_initial_grid_layout(layout_types=layout_types)
         grid_layout, set_grid_layout = solara.use_state(grid_layout_initial)
 
         with solara.Sidebar():
             with solara.Card("Controls", margin=1, elevation=2):
+                solara.InputText(
+                    label="Seed",
+                    value=reactive_seed,
+                    continuous_update=True,
+                )
                 UserInputs(user_params, on_change=handle_change_model_params)
                 ModelController(model, play_interval, current_step, reset_counter)
-            with solara.Card("Progress", margin=1, elevation=2):
-                solara.Markdown(md_text=f"####Step - {current_step}")
+                solara.Button(label="Reseed", color="primary", on_click=do_reseed)
+            with solara.Card("Information", margin=1, elevation=2):
+                solara.Markdown(md_text=f"Step - {current_step}")
 
         items = [
             Card(
@@ -151,7 +181,7 @@ def JupyterViz(
                 measures,
                 agent_portrayal,
                 space_drawer,
-                current_step,
+                dependencies,
                 color="white",
                 layout_type=layout_types[i],
             )
@@ -232,7 +262,11 @@ def ModelController(model, play_interval, current_step, reset_counter):
         solara.Style(
             """
         .widget-play {
-            height: 30px;
+            height: 35px;
+        }
+        .widget-play button {
+            color: white;
+            background-color: #1976D2;  // Solara blue color
         }
         """
         )
@@ -352,20 +386,15 @@ def make_text(renderer):
     return function
 
 
-def get_initial_grid_layout(layout_types):
-    grid_lay = []
-    y_coord = 0
-    for ii in range(len(layout_types)):
-        template_layout = {"h": 10, "i": 0, "moved": False, "w": 6, "y": 0, "x": 0}
-        if ii == 0:
-            grid_lay.append(template_layout)
-        else:
-            template_layout.update({"i": ii})
-            if ii % 2 == 0:
-                template_layout.update({"x": 0})
-                y_coord += 16
-            else:
-                template_layout.update({"x": 6})
-            template_layout.update({"y": y_coord})
-            grid_lay.append(template_layout)
-    return grid_lay
+def make_initial_grid_layout(layout_types):
+    return [
+        {
+            "i": i,
+            "w": 6,
+            "h": 10,
+            "moved": False,
+            "x": 6 * (i % 2),
+            "y": 16 * (i - i % 2),
+        }
+        for i in range(len(layout_types))
+    ]
diff --git a/mesa/visualization/ModularVisualization.py b/mesa/visualization_old/ModularVisualization.py
similarity index 100%
rename from mesa/visualization/ModularVisualization.py
rename to mesa/visualization_old/ModularVisualization.py
diff --git a/mesa/visualization/TextVisualization.py b/mesa/visualization_old/TextVisualization.py
similarity index 100%
rename from mesa/visualization/TextVisualization.py
rename to mesa/visualization_old/TextVisualization.py
diff --git a/mesa/visualization_old/UserParam.py b/mesa/visualization_old/UserParam.py
new file mode 100644
index 00000000..d4136238
--- /dev/null
+++ b/mesa/visualization_old/UserParam.py
@@ -0,0 +1,1 @@
+from mesa_viz_tornado.UserParam import *  # noqa
diff --git a/mesa/visualization_old/__init__.py b/mesa/visualization_old/__init__.py
new file mode 100644
index 00000000..e8b7256e
--- /dev/null
+++ b/mesa/visualization_old/__init__.py
@@ -0,0 +1,7 @@
+import contextlib
+
+with contextlib.suppress(ImportError):
+    from mesa_viz_tornado.ModularVisualization import *  # noqa
+    from mesa_viz_tornado.modules import *  # noqa
+    from mesa_viz_tornado.UserParam import *  # noqa
+    from mesa_viz_tornado.TextVisualization import *  # noqa
diff --git a/mesa/visualization/modules.py b/mesa/visualization_old/modules.py
similarity index 100%
rename from mesa/visualization/modules.py
rename to mesa/visualization_old/modules.py
diff --git a/pyproject.toml b/pyproject.toml
index 0233bc58..0a94f491 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,7 +6,7 @@ build-backend = "hatchling.build"
 name = "Mesa"
 description = "Agent-based modeling (ABM) in Python"
 license = { text = "Apache 2.0" }
-requires-python = ">=3.9"
+requires-python = ">=3.10"
 authors = [
   { name = "Project Mesa Team", email = "projectmesa@googlegroups.com" },
 ]
@@ -25,7 +25,6 @@ classifiers = [
   "Topic :: Scientific/Engineering :: Artificial Intelligence",
   "Intended Audience :: Science/Research",
   "Programming Language :: Python :: 3 :: Only",
-  "Programming Language :: Python :: 3.9",
   "Programming Language :: Python :: 3.10",
   "Programming Language :: Python :: 3.11",
   "Programming Language :: Python :: 3.12",
@@ -50,11 +49,12 @@ dynamic = ["version"]
 
 [project.optional-dependencies]
 dev = [
-  "ruff~=0.1.1",  # Update periodically
+  "ruff~=0.1.1",   # Update periodically
   "coverage",
   "pytest >= 4.6",
   "pytest-cov",
   "sphinx",
+  "pytest-mock",
 ]
 docs = [
   "sphinx",
@@ -62,7 +62,7 @@ docs = [
   "pydata_sphinx_theme",
   "seaborn",
   "myst-nb",
-  "myst-parser",  # Markdown in Sphinx
+  "myst-parser",         # Markdown in Sphinx
 ]
 
 [project.urls]
@@ -80,30 +80,36 @@ path = "mesa/__init__.py"
 
 [tool.ruff]
 # See https://github.com/charliermarsh/ruff#rules for error code definitions.
+# Hardcode to Python 3.10.
+# Reminder to update mesa-examples if the value below is changed.
+target-version = "py310"
+extend-exclude = ["docs", "build"]
+
+[tool.ruff.lint]
 select = [
-    # "ANN", # annotations TODO
-    "B", # bugbear
-    "C4", # comprehensions
-    "DTZ", # naive datetime
-    "E", # style errors
-    "F", # flakes
-    "I", # import sorting
-    "ISC", # string concatenation
-    "N", # naming
-    "PGH", # pygrep-hooks
-    "PIE", # miscellaneous
-    "PLC", # pylint convention
-    "PLE", # pylint error
-    # "PLR", # pylint refactor TODO
-    "PLW", # pylint warning
-    "Q", # quotes
-    "RUF", # Ruff
-    "S", # security
-    "SIM", # simplify
-    "T10", # debugger
-    "UP", # upgrade
-    "W", # style warnings
-    "YTT", # sys.version
+  # "ANN", # annotations TODO
+  "B",   # bugbear
+  "C4",  # comprehensions
+  "DTZ", # naive datetime
+  "E",   # style errors
+  "F",   # flakes
+  "I",   # import sorting
+  "ISC", # string concatenation
+  "N",   # naming
+  "PGH", # pygrep-hooks
+  "PIE", # miscellaneous
+  "PLC", # pylint convention
+  "PLE", # pylint error
+  # "PLR", # pylint refactor TODO
+  "PLW", # pylint warning
+  "Q",   # quotes
+  "RUF", # Ruff
+  "S",   # security
+  "SIM", # simplify
+  "T10", # debugger
+  "UP",  # upgrade
+  "W",   # style warnings
+  "YTT", # sys.version
 ]
 # Ignore list taken from https://github.com/psf/black/blob/master/.flake8
 # E203	Whitespace before ':'
@@ -114,19 +120,16 @@ select = [
 # checks for it.
 # See https://github.com/charliermarsh/ruff/issues/1842#issuecomment-1381210185
 extend-ignore = [
-    "E501",
-    "S101", # Use of `assert` detected
-    "B017", # `assertRaises(Exception)` should be considered evil TODO
-    "PGH004", # Use specific rule codes when using `noqa` TODO
-    "B905", # `zip()` without an explicit `strict=` parameter
-    "N802", # Function name should be lowercase
-    "N999",  # Invalid module name. We should revisit this in the future, TODO
-    "RUF012",  # Mutable class attributes should be annotated with `typing.ClassVar` TODO
-    "S310",  # Audit URL open for permitted schemes. Allowing use of `file:` or custom schemes is often unexpected.
-    "S603",  # `subprocess` call: check for execution of untrusted input
-    "ISC001",  # ruff format asks to disable this feature
+  "E501",
+  "S101",   # Use of `assert` detected
+  "B017",   # `assertRaises(Exception)` should be considered evil TODO
+  "PGH004", # Use specific rule codes when using `noqa` TODO
+  "B905",   # `zip()` without an explicit `strict=` parameter
+  "N802",   # Function name should be lowercase
+  "N999",   # Invalid module name. We should revisit this in the future, TODO
+  "RUF012", # Mutable class attributes should be annotated with `typing.ClassVar` TODO
+  "S310",   # Audit URL open for permitted schemes. Allowing use of `file:` or custom schemes is often unexpected.
+  "S603",   # `subprocess` call: check for execution of untrusted input
+  "ISC001", # ruff format asks to disable this feature
+  "S311",   # Standard pseudo-random generators are not suitable for cryptographic purposes
 ]
-extend-exclude = ["docs", "build"]
-# Hardcode to Python 3.9.
-# Reminder to update mesa-examples if the value below is changed.
-target-version = "py39"


Test patch:
diff --git a/tests/test_cell_space.py b/tests/test_cell_space.py
new file mode 100644
index 00000000..050e39a0
--- /dev/null
+++ b/tests/test_cell_space.py
@@ -0,0 +1,465 @@
+import random
+
+import pytest
+
+from mesa import Model
+from mesa.experimental.cell_space import (
+    Cell,
+    CellAgent,
+    CellCollection,
+    HexGrid,
+    Network,
+    OrthogonalMooreGrid,
+    OrthogonalVonNeumannGrid,
+)
+
+
+def test_orthogonal_grid_neumann():
+    width = 10
+    height = 10
+    grid = OrthogonalVonNeumannGrid((width, height), torus=False, capacity=None)
+
+    assert len(grid._cells) == width * height
+
+    # von neumann neighborhood, torus false, top left corner
+    assert len(grid._cells[(0, 0)]._connections) == 2
+    for connection in grid._cells[(0, 0)]._connections:
+        assert connection.coordinate in {(0, 1), (1, 0)}
+
+    # von neumann neighborhood, torus false, top right corner
+    for connection in grid._cells[(0, width - 1)]._connections:
+        assert connection.coordinate in {(0, width - 2), (1, width - 1)}
+
+    # von neumann neighborhood, torus false, bottom left corner
+    for connection in grid._cells[(height - 1, 0)]._connections:
+        assert connection.coordinate in {(height - 1, 1), (height - 2, 0)}
+
+    # von neumann neighborhood, torus false, bottom right corner
+    for connection in grid._cells[(height - 1, width - 1)]._connections:
+        assert connection.coordinate in {
+            (height - 1, width - 2),
+            (height - 2, width - 1),
+        }
+
+    # von neumann neighborhood middle of grid
+    assert len(grid._cells[(5, 5)]._connections) == 4
+    for connection in grid._cells[(5, 5)]._connections:
+        assert connection.coordinate in {(4, 5), (5, 4), (5, 6), (6, 5)}
+
+    # von neumann neighborhood, torus True, top corner
+    grid = OrthogonalVonNeumannGrid((width, height), torus=True, capacity=None)
+    assert len(grid._cells[(0, 0)]._connections) == 4
+    for connection in grid._cells[(0, 0)]._connections:
+        assert connection.coordinate in {(0, 1), (1, 0), (0, 9), (9, 0)}
+
+    # von neumann neighborhood, torus True, top right corner
+    for connection in grid._cells[(0, width - 1)]._connections:
+        assert connection.coordinate in {(0, 8), (0, 0), (1, 9), (9, 9)}
+
+    # von neumann neighborhood, torus True, bottom left corner
+    for connection in grid._cells[(9, 0)]._connections:
+        assert connection.coordinate in {(9, 1), (9, 9), (0, 0), (8, 0)}
+
+    # von neumann neighborhood, torus True, bottom right corner
+    for connection in grid._cells[(9, 9)]._connections:
+        assert connection.coordinate in {(9, 0), (9, 8), (8, 9), (0, 9)}
+
+
+def test_orthogonal_grid_neumann_3d():
+    width = 10
+    height = 10
+    depth = 10
+    grid = OrthogonalVonNeumannGrid((width, height, depth), torus=False, capacity=None)
+
+    assert len(grid._cells) == width * height * depth
+
+    # von neumann neighborhood, torus false, top left corner
+    assert len(grid._cells[(0, 0, 0)]._connections) == 3
+    for connection in grid._cells[(0, 0, 0)]._connections:
+        assert connection.coordinate in {(0, 0, 1), (0, 1, 0), (1, 0, 0)}
+
+    # von neumann neighborhood, torus false, top right corner
+    for connection in grid._cells[(0, width - 1, 0)]._connections:
+        assert connection.coordinate in {
+            (0, width - 1, 1),
+            (0, width - 2, 0),
+            (1, width - 1, 0),
+        }
+
+    # von neumann neighborhood, torus false, bottom left corner
+    for connection in grid._cells[(height - 1, 0, 0)]._connections:
+        assert connection.coordinate in {
+            (height - 1, 0, 1),
+            (height - 1, 1, 0),
+            (height - 2, 0, 0),
+        }
+
+    # von neumann neighborhood, torus false, bottom right corner
+    for connection in grid._cells[(height - 1, width - 1, 0)]._connections:
+        assert connection.coordinate in {
+            (height - 1, width - 1, 1),
+            (height - 1, width - 2, 0),
+            (height - 2, width - 1, 0),
+        }
+
+    # von neumann neighborhood middle of grid
+    assert len(grid._cells[(5, 5, 5)]._connections) == 6
+    for connection in grid._cells[(5, 5, 5)]._connections:
+        assert connection.coordinate in {
+            (4, 5, 5),
+            (5, 4, 5),
+            (5, 5, 4),
+            (5, 5, 6),
+            (5, 6, 5),
+            (6, 5, 5),
+        }
+
+    # von neumann neighborhood, torus True, top corner
+    grid = OrthogonalVonNeumannGrid((width, height, depth), torus=True, capacity=None)
+    assert len(grid._cells[(0, 0, 0)]._connections) == 6
+    for connection in grid._cells[(0, 0, 0)]._connections:
+        assert connection.coordinate in {
+            (0, 0, 1),
+            (0, 1, 0),
+            (1, 0, 0),
+            (0, 0, 9),
+            (0, 9, 0),
+            (9, 0, 0),
+        }
+
+
+def test_orthogonal_grid_moore():
+    width = 10
+    height = 10
+
+    # Moore neighborhood, torus false, top corner
+    grid = OrthogonalMooreGrid((width, height), torus=False, capacity=None)
+    assert len(grid._cells[(0, 0)]._connections) == 3
+    for connection in grid._cells[(0, 0)]._connections:
+        assert connection.coordinate in {(0, 1), (1, 0), (1, 1)}
+
+    # Moore neighborhood middle of grid
+    assert len(grid._cells[(5, 5)]._connections) == 8
+    for connection in grid._cells[(5, 5)]._connections:
+        # fmt: off
+        assert connection.coordinate in {(4, 4), (4, 5), (4, 6),
+                                         (5, 4),         (5, 6),
+                                         (6, 4), (6, 5), (6, 6)}
+        # fmt: on
+
+    # Moore neighborhood, torus True, top corner
+    grid = OrthogonalMooreGrid([10, 10], torus=True, capacity=None)
+    assert len(grid._cells[(0, 0)]._connections) == 8
+    for connection in grid._cells[(0, 0)]._connections:
+        # fmt: off
+        assert connection.coordinate in {(9, 9), (9, 0), (9, 1),
+                                         (0, 9),         (0, 1),
+                                         (1, 9), (1, 0), (1, 1)}
+        # fmt: on
+
+
+def test_orthogonal_grid_moore_3d():
+    width = 10
+    height = 10
+    depth = 10
+
+    # Moore neighborhood, torus false, top corner
+    grid = OrthogonalMooreGrid((width, height, depth), torus=False, capacity=None)
+    assert len(grid._cells[(0, 0, 0)]._connections) == 7
+    for connection in grid._cells[(0, 0, 0)]._connections:
+        assert connection.coordinate in {
+            (0, 0, 1),
+            (0, 1, 0),
+            (0, 1, 1),
+            (1, 0, 0),
+            (1, 0, 1),
+            (1, 1, 0),
+            (1, 1, 1),
+        }
+
+    # Moore neighborhood middle of grid
+    assert len(grid._cells[(5, 5, 5)]._connections) == 26
+    for connection in grid._cells[(5, 5, 5)]._connections:
+        # fmt: off
+        assert connection.coordinate in {(4, 4, 4), (4, 4, 5), (4, 4, 6), (4, 5, 4), (4, 5, 5), (4, 5, 6), (4, 6, 4), (4, 6, 5), (4, 6, 6),
+                                         (5, 4, 4), (5, 4, 5), (5, 4, 6), (5, 5, 4),             (5, 5, 6), (5, 6, 4), (5, 6, 5), (5, 6, 6),
+                                         (6, 4, 4), (6, 4, 5), (6, 4, 6), (6, 5, 4), (6, 5, 5), (6, 5, 6), (6, 6, 4), (6, 6, 5), (6, 6, 6)}
+        # fmt: on
+
+    # Moore neighborhood, torus True, top corner
+    grid = OrthogonalMooreGrid((width, height, depth), torus=True, capacity=None)
+    assert len(grid._cells[(0, 0, 0)]._connections) == 26
+    for connection in grid._cells[(0, 0, 0)]._connections:
+        # fmt: off
+        assert connection.coordinate in {(9, 9, 9), (9, 9, 0), (9, 9, 1), (9, 0, 9), (9, 0, 0), (9, 0, 1), (9, 1, 9), (9, 1, 0), (9, 1, 1),
+                                         (0, 9, 9), (0, 9, 0), (0, 9, 1), (0, 0, 9),             (0, 0, 1), (0, 1, 9), (0, 1, 0), (0, 1, 1),
+                                         (1, 9, 9), (1, 9, 0), (1, 9, 1), (1, 0, 9), (1, 0, 0), (1, 0, 1), (1, 1, 9), (1, 1, 0), (1, 1, 1)}
+        # fmt: on
+
+
+def test_orthogonal_grid_moore_4d():
+    width = 10
+    height = 10
+    depth = 10
+    time = 10
+
+    # Moore neighborhood, torus false, top corner
+    grid = OrthogonalMooreGrid((width, height, depth, time), torus=False, capacity=None)
+    assert len(grid._cells[(0, 0, 0, 0)]._connections) == 15
+    for connection in grid._cells[(0, 0, 0, 0)]._connections:
+        assert connection.coordinate in {
+            (0, 0, 0, 1),
+            (0, 0, 1, 0),
+            (0, 0, 1, 1),
+            (0, 1, 0, 0),
+            (0, 1, 0, 1),
+            (0, 1, 1, 0),
+            (0, 1, 1, 1),
+            (1, 0, 0, 0),
+            (1, 0, 0, 1),
+            (1, 0, 1, 0),
+            (1, 0, 1, 1),
+            (1, 1, 0, 0),
+            (1, 1, 0, 1),
+            (1, 1, 1, 0),
+            (1, 1, 1, 1),
+        }
+
+    # Moore neighborhood middle of grid
+    assert len(grid._cells[(5, 5, 5, 5)]._connections) == 80
+    for connection in grid._cells[(5, 5, 5, 5)]._connections:
+        # fmt: off
+        assert connection.coordinate in {(4, 4, 4, 4), (4, 4, 4, 5), (4, 4, 4, 6), (4, 4, 5, 4), (4, 4, 5, 5), (4, 4, 5, 6), (4, 4, 6, 4), (4, 4, 6, 5), (4, 4, 6, 6),
+                                         (4, 5, 4, 4), (4, 5, 4, 5), (4, 5, 4, 6), (4, 5, 5, 4), (4, 5, 5, 5), (4, 5, 5, 6), (4, 5, 6, 4), (4, 5, 6, 5), (4, 5, 6, 6),
+                                            (4, 6, 4, 4), (4, 6, 4, 5), (4, 6, 4, 6), (4, 6, 5, 4), (4, 6, 5, 5), (4, 6, 5, 6), (4, 6, 6, 4), (4, 6, 6, 5), (4, 6, 6, 6),
+                                            (5, 4, 4, 4), (5, 4, 4, 5), (5, 4, 4, 6), (5, 4, 5, 4), (5, 4, 5, 5), (5, 4, 5, 6), (5, 4, 6, 4), (5, 4, 6, 5), (5, 4, 6, 6),
+                                            (5, 5, 4, 4), (5, 5, 4, 5), (5, 5, 4, 6), (5, 5, 5, 4),             (5, 5, 5, 6), (5, 5, 6, 4), (5, 5, 6, 5), (5, 5, 6, 6),
+                                            (5, 6, 4, 4), (5, 6, 4, 5), (5, 6, 4, 6), (5, 6, 5, 4), (5, 6, 5, 5), (5, 6, 5, 6), (5, 6, 6, 4), (5, 6, 6, 5), (5, 6, 6, 6),
+                                            (6, 4, 4, 4), (6, 4, 4, 5), (6, 4, 4, 6), (6, 4, 5, 4), (6, 4, 5, 5), (6, 4, 5, 6), (6, 4, 6, 4), (6, 4, 6, 5), (6, 4, 6, 6),
+                                            (6, 5, 4, 4), (6, 5, 4, 5), (6, 5, 4, 6), (6, 5, 5, 4), (6, 5, 5, 5), (6, 5, 5, 6), (6, 5, 6, 4), (6, 5, 6, 5), (6, 5, 6, 6),
+                                            (6, 6, 4, 4), (6, 6, 4, 5), (6, 6, 4, 6), (6, 6, 5, 4), (6, 6, 5, 5), (6, 6, 5, 6), (6, 6, 6, 4), (6, 6, 6, 5), (6, 6, 6, 6)}
+        # fmt: on
+
+
+def test_orthogonal_grid_moore_1d():
+    width = 10
+
+    # Moore neighborhood, torus false, left edge
+    grid = OrthogonalMooreGrid((width,), torus=False, capacity=None)
+    assert len(grid._cells[(0,)]._connections) == 1
+    for connection in grid._cells[(0,)]._connections:
+        assert connection.coordinate in {(1,)}
+
+    # Moore neighborhood middle of grid
+    assert len(grid._cells[(5,)]._connections) == 2
+    for connection in grid._cells[(5,)]._connections:
+        assert connection.coordinate in {(4,), (6,)}
+
+    # Moore neighborhood, torus True, left edge
+    grid = OrthogonalMooreGrid((width,), torus=True, capacity=None)
+    assert len(grid._cells[(0,)]._connections) == 2
+    for connection in grid._cells[(0,)]._connections:
+        assert connection.coordinate in {(1,), (9,)}
+
+
+def test_cell_neighborhood():
+    # orthogonal grid
+
+    ## von Neumann
+    width = 10
+    height = 10
+    grid = OrthogonalVonNeumannGrid((width, height), torus=False, capacity=None)
+    for radius, n in zip(range(1, 4), [2, 5, 9]):
+        neighborhood = grid._cells[(0, 0)].neighborhood(radius=radius)
+        assert len(neighborhood) == n
+
+    ## Moore
+    width = 10
+    height = 10
+    grid = OrthogonalMooreGrid((width, height), torus=False, capacity=None)
+    for radius, n in zip(range(1, 4), [3, 8, 15]):
+        neighborhood = grid._cells[(0, 0)].neighborhood(radius=radius)
+        assert len(neighborhood) == n
+
+    with pytest.raises(ValueError):
+        grid._cells[(0, 0)].neighborhood(radius=0)
+
+    # hexgrid
+    width = 10
+    height = 10
+    grid = HexGrid((width, height), torus=False, capacity=None)
+    for radius, n in zip(range(1, 4), [2, 6, 11]):
+        neighborhood = grid._cells[(0, 0)].neighborhood(radius=radius)
+        assert len(neighborhood) == n
+
+    width = 10
+    height = 10
+    grid = HexGrid((width, height), torus=False, capacity=None)
+    for radius, n in zip(range(1, 4), [5, 10, 17]):
+        neighborhood = grid._cells[(1, 0)].neighborhood(radius=radius)
+        assert len(neighborhood) == n
+
+    # networkgrid
+
+
+def test_hexgrid():
+    width = 10
+    height = 10
+
+    grid = HexGrid((width, height), torus=False)
+    assert len(grid._cells) == width * height
+
+    # first row
+    assert len(grid._cells[(0, 0)]._connections) == 2
+    for connection in grid._cells[(0, 0)]._connections:
+        assert connection.coordinate in {(0, 1), (1, 0)}
+
+    # second row
+    assert len(grid._cells[(1, 0)]._connections) == 5
+    for connection in grid._cells[(1, 0)]._connections:
+        # fmt: off
+        assert connection.coordinate in {(0, 0), (0, 1),
+                                                    (1, 1),
+                                         (2, 0), (2, 1)}
+
+    # middle odd row
+    assert len(grid._cells[(5, 5)]._connections) == 6
+    for connection in grid._cells[(5, 5)]._connections:
+        # fmt: off
+        assert connection.coordinate in {(4, 5), (4, 6),
+                                      (5, 4),       (5, 6),
+                                         (6, 5), (6, 6)}
+
+        # fmt: on
+
+    # middle even row
+    assert len(grid._cells[(4, 4)]._connections) == 6
+    for connection in grid._cells[(4, 4)]._connections:
+        # fmt: off
+        assert connection.coordinate in {(3, 3), (3, 4),
+                                      (4, 3),       (4, 5),
+                                         (5, 3), (5, 4)}
+
+        # fmt: on
+
+    grid = HexGrid((width, height), torus=True)
+    assert len(grid._cells) == width * height
+
+    # first row
+    assert len(grid._cells[(0, 0)]._connections) == 6
+    for connection in grid._cells[(0, 0)]._connections:
+        # fmt: off
+        assert connection.coordinate in {(9, 9), (9, 0),
+                                      (0, 9),       (0, 1),
+                                         (1, 9), (1, 0)}
+
+        # fmt: on
+
+
+def test_networkgrid():
+    import networkx as nx
+
+    n = 10
+    m = 20
+    seed = 42
+    G = nx.gnm_random_graph(n, m, seed=seed)  # noqa: N806
+    grid = Network(G)
+
+    assert len(grid._cells) == n
+
+    for i, cell in grid._cells.items():
+        for connection in cell._connections:
+            assert connection.coordinate in G.neighbors(i)
+
+
+def test_empties_space():
+    import networkx as nx
+
+    n = 10
+    m = 20
+    seed = 42
+    G = nx.gnm_random_graph(n, m, seed=seed)  # noqa: N806
+    grid = Network(G)
+
+    assert len(grid.empties) == n
+
+    model = Model()
+    for i in range(8):
+        grid._cells[i].add_agent(CellAgent(i, model))
+
+    cell = grid.select_random_empty_cell()
+    assert cell.coordinate in {8, 9}
+
+
+def test_cell():
+    cell1 = Cell((1,), capacity=None, random=random.Random())
+    cell2 = Cell((2,), capacity=None, random=random.Random())
+
+    # connect
+    cell1.connect(cell2)
+    assert cell2 in cell1._connections
+
+    # disconnect
+    cell1.disconnect(cell2)
+    assert cell2 not in cell1._connections
+
+    # remove cell not in connections
+    with pytest.raises(ValueError):
+        cell1.disconnect(cell2)
+
+    # add_agent
+    model = Model()
+    agent = CellAgent(1, model)
+
+    cell1.add_agent(agent)
+    assert agent in cell1.agents
+
+    # remove_agent
+    cell1.remove_agent(agent)
+    assert agent not in cell1.agents
+
+    with pytest.raises(ValueError):
+        cell1.remove_agent(agent)
+
+    cell1 = Cell((1,), capacity=1, random=random.Random())
+    cell1.add_agent(CellAgent(1, model))
+    assert cell1.is_full
+
+    with pytest.raises(Exception):
+        cell1.add_agent(CellAgent(2, model))
+
+
+def test_cell_collection():
+    cell1 = Cell((1,), capacity=None, random=random.Random())
+
+    collection = CellCollection({cell1: cell1.agents}, random=random.Random())
+    assert len(collection) == 1
+    assert cell1 in collection
+
+    rng = random.Random()
+    n = 10
+    collection = CellCollection([Cell((i,), random=rng) for i in range(n)], random=rng)
+    assert len(collection) == n
+
+    cell = collection.select_random_cell()
+    assert cell in collection
+
+    cells = collection.cells
+    assert len(cells) == n
+
+    agents = collection.agents
+    assert len(list(agents)) == 0
+
+    cells = collection.cells
+    model = Model()
+    cells[0].add_agent(CellAgent(1, model))
+    cells[3].add_agent(CellAgent(2, model))
+    cells[7].add_agent(CellAgent(3, model))
+    agents = collection.agents
+    assert len(list(agents)) == 3
+
+    agent = collection.select_random_agent()
+    assert agent in set(collection.agents)
+
+    agents = collection[cells[0]]
+    assert agents == cells[0].agents
diff --git a/tests/test_datacollector.py b/tests/test_datacollector.py
index a56ca477..bf74e379 100644
--- a/tests/test_datacollector.py
+++ b/tests/test_datacollector.py
@@ -1,6 +1,7 @@
 """
 Test the DataCollector
 """
+
 import unittest
 
 from mesa import Agent, Model
diff --git a/tests/test_devs.py b/tests/test_devs.py
new file mode 100644
index 00000000..06d3629e
--- /dev/null
+++ b/tests/test_devs.py
@@ -0,0 +1,282 @@
+from unittest.mock import MagicMock
+
+import pytest
+
+from mesa import Model
+from mesa.experimental.devs.eventlist import EventList, Priority, SimulationEvent
+from mesa.experimental.devs.simulator import ABMSimulator, DEVSimulator
+
+
+def test_devs_simulator():
+    simulator = DEVSimulator()
+
+    # setup
+    model = MagicMock(spec=Model)
+    simulator.setup(model)
+
+    assert len(simulator.event_list) == 0
+    assert simulator.model == model
+    assert simulator.time == 0
+
+    # schedule_event_now
+    fn1 = MagicMock()
+    event1 = simulator.schedule_event_now(fn1)
+    assert event1 in simulator.event_list
+    assert len(simulator.event_list) == 1
+
+    # schedule_event_absolute
+    fn2 = MagicMock()
+    event2 = simulator.schedule_event_absolute(fn2, 1.0)
+    assert event2 in simulator.event_list
+    assert len(simulator.event_list) == 2
+
+    # schedule_event_relative
+    fn3 = MagicMock()
+    event3 = simulator.schedule_event_relative(fn3, 0.5)
+    assert event3 in simulator.event_list
+    assert len(simulator.event_list) == 3
+
+    # run_for
+    simulator.run_for(0.8)
+    fn1.assert_called_once()
+    fn3.assert_called_once()
+    assert simulator.time == 0.8
+
+    simulator.run_for(0.2)
+    fn2.assert_called_once()
+    assert simulator.time == 1.0
+
+    simulator.run_for(0.2)
+    assert simulator.time == 1.2
+
+    with pytest.raises(ValueError):
+        simulator.schedule_event_absolute(fn2, 0.5)
+
+    # cancel_event
+    simulator = DEVSimulator()
+    model = MagicMock(spec=Model)
+    simulator.setup(model)
+    fn = MagicMock()
+    event = simulator.schedule_event_relative(fn, 0.5)
+    simulator.cancel_event(event)
+    assert event.CANCELED
+
+    # simulator reset
+    simulator.reset()
+    assert len(simulator.event_list) == 0
+    assert simulator.model is None
+    assert simulator.time == 0.0
+
+
+def test_abm_simulator():
+    simulator = ABMSimulator()
+
+    # setup
+    model = MagicMock(spec=Model)
+    simulator.setup(model)
+
+    # schedule_event_next_tick
+    fn = MagicMock()
+    simulator.schedule_event_next_tick(fn)
+    assert len(simulator.event_list) == 2
+
+    simulator.run_for(3)
+    assert model.step.call_count == 3
+    assert simulator.time == 2
+
+
+def test_simulation_event():
+    some_test_function = MagicMock()
+
+    time = 10
+    event = SimulationEvent(
+        time,
+        some_test_function,
+        priority=Priority.DEFAULT,
+        function_args=[],
+        function_kwargs={},
+    )
+
+    assert event.time == time
+    assert event.fn() is some_test_function
+    assert event.function_args == []
+    assert event.function_kwargs == {}
+    assert event.priority == Priority.DEFAULT
+
+    # execute
+    event.execute()
+    some_test_function.assert_called_once()
+
+    with pytest.raises(Exception):
+        SimulationEvent(
+            time, None, priority=Priority.DEFAULT, function_args=[], function_kwargs={}
+        )
+
+    # check calling with arguments
+    some_test_function = MagicMock()
+    event = SimulationEvent(
+        time,
+        some_test_function,
+        priority=Priority.DEFAULT,
+        function_args=["1"],
+        function_kwargs={"x": 2},
+    )
+    event.execute()
+    some_test_function.assert_called_once_with("1", x=2)
+
+    # check if we pass over deletion of callable silently because of weakrefs
+    def some_test_function(x, y):
+        return x + y
+
+    event = SimulationEvent(time, some_test_function, priority=Priority.DEFAULT)
+    del some_test_function
+    event.execute()
+
+    # cancel
+    some_test_function = MagicMock()
+    event = SimulationEvent(
+        time,
+        some_test_function,
+        priority=Priority.DEFAULT,
+        function_args=["1"],
+        function_kwargs={"x": 2},
+    )
+    event.cancel()
+    assert event.fn is None
+    assert event.function_args == []
+    assert event.function_kwargs == {}
+    assert event.priority == Priority.DEFAULT
+    assert event.CANCELED
+
+    # comparison for sorting
+    event1 = SimulationEvent(
+        10,
+        some_test_function,
+        priority=Priority.DEFAULT,
+        function_args=[],
+        function_kwargs={},
+    )
+    event2 = SimulationEvent(
+        10,
+        some_test_function,
+        priority=Priority.DEFAULT,
+        function_args=[],
+        function_kwargs={},
+    )
+    assert event1 < event2  # based on just unique_id as tiebraker
+
+    event1 = SimulationEvent(
+        11,
+        some_test_function,
+        priority=Priority.DEFAULT,
+        function_args=[],
+        function_kwargs={},
+    )
+    event2 = SimulationEvent(
+        10,
+        some_test_function,
+        priority=Priority.DEFAULT,
+        function_args=[],
+        function_kwargs={},
+    )
+    assert event1 > event2
+
+    event1 = SimulationEvent(
+        10,
+        some_test_function,
+        priority=Priority.DEFAULT,
+        function_args=[],
+        function_kwargs={},
+    )
+    event2 = SimulationEvent(
+        10,
+        some_test_function,
+        priority=Priority.HIGH,
+        function_args=[],
+        function_kwargs={},
+    )
+    assert event1 > event2
+
+
+def test_eventlist():
+    event_list = EventList()
+
+    assert len(event_list._events) == 0
+    assert isinstance(event_list._events, list)
+    assert event_list.is_empty()
+
+    # add event
+    some_test_function = MagicMock()
+    event = SimulationEvent(
+        1,
+        some_test_function,
+        priority=Priority.DEFAULT,
+        function_args=[],
+        function_kwargs={},
+    )
+    event_list.add_event(event)
+    assert len(event_list) == 1
+    assert event in event_list
+
+    # remove event
+    event_list.remove(event)
+    assert len(event_list) == 1
+    assert event.CANCELED
+
+    # peak ahead
+    event_list = EventList()
+    for i in range(10):
+        event = SimulationEvent(
+            i,
+            some_test_function,
+            priority=Priority.DEFAULT,
+            function_args=[],
+            function_kwargs={},
+        )
+        event_list.add_event(event)
+    events = event_list.peak_ahead(2)
+    assert len(events) == 2
+    assert events[0].time == 0
+    assert events[1].time == 1
+
+    events = event_list.peak_ahead(11)
+    assert len(events) == 10
+
+    event_list._events[6].cancel()
+    events = event_list.peak_ahead(10)
+    assert len(events) == 9
+
+    event_list = EventList()
+    with pytest.raises(Exception):
+        event_list.peak_ahead()
+
+    # pop event
+    event_list = EventList()
+    for i in range(10):
+        event = SimulationEvent(
+            i,
+            some_test_function,
+            priority=Priority.DEFAULT,
+            function_args=[],
+            function_kwargs={},
+        )
+        event_list.add_event(event)
+    event = event_list.pop_event()
+    assert event.time == 0
+
+    event_list = EventList()
+    event = SimulationEvent(
+        9,
+        some_test_function,
+        priority=Priority.DEFAULT,
+        function_args=[],
+        function_kwargs={},
+    )
+    event_list.add_event(event)
+    event.cancel()
+    with pytest.raises(Exception):
+        event_list.pop_event()
+
+    # clear
+    event_list.clear()
+    assert len(event_list) == 0
diff --git a/tests/test_examples.py b/tests/test_examples.py
index 1c149da4..e5c0381f 100644
--- a/tests/test_examples.py
+++ b/tests/test_examples.py
@@ -51,12 +51,12 @@ class TestExamples(unittest.TestCase):
             print(f"testing example {example!r}")
             with self.active_example_dir(example):
                 try:
-                    # model.py at the top level
+                    # epstein_civil_violence.py at the top level
                     mod = importlib.import_module("model")
                     server = importlib.import_module("server")
                     server.server.render_model()
                 except ImportError:
-                    # <example>/model.py
+                    # <example>/epstein_civil_violence.py
                     mod = importlib.import_module(f"{example.replace('-', '_')}.model")
                     server = importlib.import_module(
                         f"{example.replace('-', '_')}.server"
diff --git a/tests/test_grid.py b/tests/test_grid.py
index 686d8e78..be68b1ff 100644
--- a/tests/test_grid.py
+++ b/tests/test_grid.py
@@ -1,6 +1,7 @@
 """
 Test the Grid objects.
 """
+
 import random
 import unittest
 from unittest.mock import Mock, patch
@@ -24,10 +25,10 @@ class MockAgent:
     Minimalistic agent for testing purposes.
     """
 
-    def __init__(self, unique_id, pos):
+    def __init__(self, unique_id):
         self.random = random.Random(0)
         self.unique_id = unique_id
-        self.pos = pos
+        self.pos = None
 
 
 class TestSingleGrid(unittest.TestCase):
@@ -53,7 +54,7 @@ class TestSingleGrid(unittest.TestCase):
                     continue
                 counter += 1
                 # Create and place the mock agent
-                a = MockAgent(counter, None)
+                a = MockAgent(counter)
                 self.agents.append(a)
                 self.grid.place_agent(a, (x, y))
 
@@ -258,7 +259,7 @@ class TestSingleGridEnforcement(unittest.TestCase):
                     continue
                 counter += 1
                 # Create and place the mock agent
-                a = MockAgent(counter, None)
+                a = MockAgent(counter)
                 self.agents.append(a)
                 self.grid.place_agent(a, (x, y))
         self.num_agents = len(self.agents)
@@ -270,7 +271,7 @@ class TestSingleGridEnforcement(unittest.TestCase):
         """
 
         assert len(self.grid.empties) == 9
-        a = MockAgent(100, None)
+        a = MockAgent(100)
         with self.assertRaises(Exception):
             self.grid.place_agent(a, (0, 1))
 
@@ -288,12 +289,12 @@ class TestSingleGridEnforcement(unittest.TestCase):
         # Place agents until the grid is full
         empty_cells = len(self.grid.empties)
         for i in range(empty_cells):
-            a = MockAgent(101 + i, None)
+            a = MockAgent(101 + i)
             self.grid.move_to_empty(a)
             self.num_agents += 1
         assert len(self.grid.empties) == 0
 
-        a = MockAgent(110, None)
+        a = MockAgent(110)
         with self.assertRaises(Exception):
             self.grid.move_to_empty(a)
         with self.assertRaises(Exception):
@@ -334,7 +335,7 @@ class TestMultiGrid(unittest.TestCase):
                 for _i in range(TEST_MULTIGRID[x][y]):
                     counter += 1
                     # Create and place the mock agent
-                    a = MockAgent(counter, None)
+                    a = MockAgent(counter)
                     self.agents.append(a)
                     self.grid.place_agent(a, (x, y))
 
@@ -393,7 +394,7 @@ class TestHexSingleGrid(unittest.TestCase):
                     continue
                 counter += 1
                 # Create and place the mock agent
-                a = MockAgent(counter, None)
+                a = MockAgent(counter)
                 self.agents.append(a)
                 self.grid.place_agent(a, (x, y))
 
@@ -447,7 +448,7 @@ class TestHexSingleGridTorus(TestSingleGrid):
                     continue
                 counter += 1
                 # Create and place the mock agent
-                a = MockAgent(counter, None)
+                a = MockAgent(counter)
                 self.agents.append(a)
                 self.grid.place_agent(a, (x, y))
 
diff --git a/tests/test_import_namespace.py b/tests/test_import_namespace.py
index f9489711..20a06f33 100644
--- a/tests/test_import_namespace.py
+++ b/tests/test_import_namespace.py
@@ -15,9 +15,9 @@ def test_import():
     _ = MultiGrid
     _ = mf.MultiGrid
 
-    from mesa.visualization.ModularVisualization import ModularServer
+    from mesa.visualization_old.ModularVisualization import ModularServer
 
-    _ = mesa.visualization.ModularServer
+    _ = mesa.visualization_old.ModularServer
     _ = ModularServer
     _ = mf.ModularServer
 
diff --git a/tests/test_jupyter_viz.py b/tests/test_jupyter_viz.py
index 248e8c31..76ee666e 100644
--- a/tests/test_jupyter_viz.py
+++ b/tests/test_jupyter_viz.py
@@ -1,10 +1,11 @@
 import unittest
-from unittest.mock import Mock, patch
+from unittest.mock import Mock
 
 import ipyvuetify as vw
 import solara
 
-from mesa.experimental.jupyter_viz import JupyterViz, Slider, UserInputs
+import mesa
+from mesa.visualization.jupyter_viz import JupyterViz, Slider, UserInputs
 
 
 class TestMakeUserInput(unittest.TestCase):
@@ -81,58 +82,61 @@ class TestMakeUserInput(unittest.TestCase):
         assert slider_int.step is None
 
 
-class TestJupyterViz(unittest.TestCase):
-    @patch("mesa.experimental.components.matplotlib.SpaceMatplotlib")
-    def test_call_space_drawer(self, mock_space_matplotlib):
-        mock_model_class = Mock()
-        mock_model_class.__name__ = "MockModelClass"
-        agent_portrayal = {
-            "Shape": "circle",
-            "color": "gray",
-        }
-        current_step = 0
-        dependencies = [current_step]
-        # initialize with space drawer unspecified (use default)
-        # component must be rendered for code to run
-        solara.render(
-            JupyterViz(
-                model_class=mock_model_class,
-                model_params={},
-                agent_portrayal=agent_portrayal,
-            )
+def test_call_space_drawer(mocker):
+    mock_space_matplotlib = mocker.patch(
+        "mesa.visualization.components.matplotlib.SpaceMatplotlib"
+    )
+
+    model = mesa.Model()
+    mocker.patch.object(mesa.Model, "__new__", return_value=model)
+    mocker.patch.object(mesa.Model, "__init__", return_value=None)
+
+    agent_portrayal = {
+        "Shape": "circle",
+        "color": "gray",
+    }
+    current_step = 0
+    seed = 0
+    dependencies = [current_step, seed]
+    # initialize with space drawer unspecified (use default)
+    # component must be rendered for code to run
+    solara.render(
+        JupyterViz(
+            model_class=mesa.Model,
+            model_params={},
+            agent_portrayal=agent_portrayal,
         )
-        # should call default method with class instance and agent portrayal
-        mock_space_matplotlib.assert_called_with(
-            mock_model_class.return_value, agent_portrayal, dependencies=dependencies
-        )
-
-        # specify no space should be drawn; any false value should work
-        for falsy_value in [None, False, 0]:
-            mock_space_matplotlib.reset_mock()
-            solara.render(
-                JupyterViz(
-                    model_class=mock_model_class,
-                    model_params={},
-                    agent_portrayal=agent_portrayal,
-                    space_drawer=falsy_value,
-                )
-            )
-            # should call default method with class instance and agent portrayal
-            assert mock_space_matplotlib.call_count == 0
-
-        # specify a custom space method
-        altspace_drawer = Mock()
+    )
+    # should call default method with class instance and agent portrayal
+    mock_space_matplotlib.assert_called_with(
+        model, agent_portrayal, dependencies=dependencies
+    )
+
+    # specify no space should be drawn; any false value should work
+    for falsy_value in [None, False, 0]:
+        mock_space_matplotlib.reset_mock()
         solara.render(
             JupyterViz(
-                model_class=mock_model_class,
+                model_class=mesa.Model,
                 model_params={},
                 agent_portrayal=agent_portrayal,
-                space_drawer=altspace_drawer,
+                space_drawer=falsy_value,
             )
         )
-        altspace_drawer.assert_called_with(
-            mock_model_class.return_value, agent_portrayal
+        # should call default method with class instance and agent portrayal
+        assert mock_space_matplotlib.call_count == 0
+
+    # specify a custom space method
+    altspace_drawer = Mock()
+    solara.render(
+        JupyterViz(
+            model_class=mesa.Model,
+            model_params={},
+            agent_portrayal=agent_portrayal,
+            space_drawer=altspace_drawer,
         )
+    )
+    altspace_drawer.assert_called_with(model, agent_portrayal)
 
 
 def test_slider():
diff --git a/tests/test_main.py b/tests/test_main.py
index 9f7f9834..b6bba370 100644
--- a/tests/test_main.py
+++ b/tests/test_main.py
@@ -24,7 +24,7 @@ class TestCli(unittest.TestCase):
         "Skipping test_run, because examples folder was moved. More discussion needed."
     )
     def test_run(self):
-        with patch("mesa.visualization.ModularServer") as ModularServer:  # noqa: N806
+        with patch("mesa.visualization_old.ModularServer") as ModularServer:  # noqa: N806
             example_dir = os.path.abspath(
                 os.path.join(os.path.dirname(__file__), "../examples/wolf_sheep")
             )
diff --git a/tests/test_space.py b/tests/test_space.py
index aabdf98b..23cf8c95 100644
--- a/tests/test_space.py
+++ b/tests/test_space.py
@@ -42,7 +42,7 @@ class TestSpacePerformance(unittest.TestCase):
         """
         positions = np.random.rand(TEST_AGENTS_PERF, 2)
         for i in range(TEST_AGENTS_PERF):
-            a = MockAgent(i, None)
+            a = MockAgent(i)
             pos = [positions[i, 0], positions[i, 1]]
             self.space.place_agent(a, pos)
 
@@ -59,7 +59,7 @@ class TestSpaceToroidal(unittest.TestCase):
         self.space = ContinuousSpace(70, 20, True, -30, -30)
         self.agents = []
         for i, pos in enumerate(TEST_AGENTS):
-            a = MockAgent(i, None)
+            a = MockAgent(i)
             self.agents.append(a)
             self.space.place_agent(a, pos)
 
@@ -126,7 +126,7 @@ class TestSpaceToroidal(unittest.TestCase):
         """
         boundary_agents = []
         for i, pos in enumerate(OUTSIDE_POSITIONS):
-            a = MockAgent(len(self.agents) + i, None)
+            a = MockAgent(len(self.agents) + i)
             boundary_agents.append(a)
             self.space.place_agent(a, pos)
 
@@ -152,7 +152,7 @@ class TestSpaceNonToroidal(unittest.TestCase):
         self.space = ContinuousSpace(70, 20, False, -30, -30)
         self.agents = []
         for i, pos in enumerate(TEST_AGENTS):
-            a = MockAgent(i, None)
+            a = MockAgent(i)
             self.agents.append(a)
             self.space.place_agent(a, pos)
 
@@ -208,7 +208,7 @@ class TestSpaceNonToroidal(unittest.TestCase):
         Test positions outside of boundary
         """
         for i, pos in enumerate(OUTSIDE_POSITIONS):
-            a = MockAgent(len(self.agents) + i, None)
+            a = MockAgent(len(self.agents) + i)
             with self.assertRaises(Exception):
                 self.space.place_agent(a, pos)
 
@@ -231,7 +231,7 @@ class TestSpaceAgentMapping(unittest.TestCase):
         self.space = ContinuousSpace(70, 50, False, -30, -30)
         self.agents = []
         for i, pos in enumerate(REMOVAL_TEST_AGENTS):
-            a = MockAgent(i, None)
+            a = MockAgent(i)
             self.agents.append(a)
             self.space.place_agent(a, pos)
 
@@ -442,7 +442,7 @@ class TestSingleGrid(unittest.TestCase):
         self.space = SingleGrid(50, 50, False)
         self.agents = []
         for i, pos in enumerate(TEST_AGENTS_GRID):
-            a = MockAgent(i, None)
+            a = MockAgent(i)
             self.agents.append(a)
             self.space.place_agent(a, pos)
 
@@ -466,7 +466,7 @@ class TestSingleGrid(unittest.TestCase):
     def test_empty_cells(self):
         if self.space.exists_empty_cells():
             for i, pos in enumerate(list(self.space.empties)):
-                a = MockAgent(-i, pos)
+                a = MockAgent(-i)
                 self.space.place_agent(a, pos)
         with self.assertRaises(Exception):
             self.space.move_to_empty(a)
@@ -551,7 +551,7 @@ class TestSingleGridTorus(unittest.TestCase):
         self.space = SingleGrid(50, 50, True)  # Torus is True here
         self.agents = []
         for i, pos in enumerate(TEST_AGENTS_GRID):
-            a = MockAgent(i, None)
+            a = MockAgent(i)
             self.agents.append(a)
             self.space.place_agent(a, pos)
 
@@ -643,7 +643,7 @@ class TestSingleGridWithPropertyGrid(unittest.TestCase):
         self.assertTrue(np.all(empty_mask == np.ones((10, 10), dtype=bool)))
 
     def test_get_empty_mask_with_agent(self):
-        agent = MockAgent(0, self.grid)
+        agent = MockAgent(0)
         self.grid.place_agent(agent, (4, 6))
 
         empty_mask = self.grid.empty_mask
@@ -653,8 +653,8 @@ class TestSingleGridWithPropertyGrid(unittest.TestCase):
         self.assertTrue(np.all(empty_mask == expected_mask))
 
     def test_get_neighborhood_mask(self):
-        agent = MockAgent(0, self.grid)
-        agent2 = MockAgent(1, self.grid)
+        agent = MockAgent(0)
+        agent2 = MockAgent(1)
         self.grid.place_agent(agent, (5, 5))
         self.grid.place_agent(agent2, (5, 6))
         neighborhood_mask = self.grid.get_neighborhood_mask((5, 5), True, False, 1)
@@ -680,7 +680,7 @@ class TestSingleGridWithPropertyGrid(unittest.TestCase):
         self.assertTrue(selected_mask.all())
 
     def test_move_agent_to_cell_by_properties(self):
-        agent = MockAgent(1, self.grid)
+        agent = MockAgent(1)
         self.grid.place_agent(agent, (5, 5))
         conditions = {"layer1": lambda x: x == 0}
         target_cells = self.grid.select_cells(conditions)
@@ -689,7 +689,7 @@ class TestSingleGridWithPropertyGrid(unittest.TestCase):
         self.assertNotEqual(agent.pos, (5, 5))
 
     def test_move_agent_no_eligible_cells(self):
-        agent = MockAgent(3, self.grid)
+        agent = MockAgent(3)
         self.grid.place_agent(agent, (5, 5))
         conditions = {"layer1": lambda x: x != 0}
         target_cells = self.grid.select_cells(conditions)
@@ -711,7 +711,7 @@ class TestSingleGridWithPropertyGrid(unittest.TestCase):
         self.assertTrue(target_mask[3, 1])
 
     def test_move_agent_to_extreme_value_cell(self):
-        agent = MockAgent(2, self.grid)
+        agent = MockAgent(2)
         self.grid.place_agent(agent, (5, 5))
         self.grid.properties["layer2"].set_cell((3, 1), 1.1)
         target_cells = self.grid.select_cells(extreme_values={"layer2": "highest"})
@@ -721,7 +721,7 @@ class TestSingleGridWithPropertyGrid(unittest.TestCase):
     # Test using masks
     def test_select_cells_by_properties_with_empty_mask(self):
         self.grid.place_agent(
-            MockAgent(0, self.grid), (5, 5)
+            MockAgent(0), (5, 5)
         )  # Placing an agent to ensure some cells are not empty
         empty_mask = self.grid.empty_mask
 
@@ -755,10 +755,10 @@ class TestSingleGridWithPropertyGrid(unittest.TestCase):
         self.assertCountEqual(selected_cells, expected_selection)
 
     def test_move_agent_to_cell_by_properties_with_empty_mask(self):
-        agent = MockAgent(1, self.grid)
+        agent = MockAgent(1)
         self.grid.place_agent(agent, (5, 5))
         self.grid.place_agent(
-            MockAgent(2, self.grid), (4, 5)
+            MockAgent(2), (4, 5)
         )  # Placing another agent to create a non-empty cell
         empty_mask = self.grid.empty_mask
         conditions = {"layer1": lambda x: x == 0}
@@ -769,7 +769,7 @@ class TestSingleGridWithPropertyGrid(unittest.TestCase):
         )  # Agent should not move to (4, 5) as it's not empty
 
     def test_move_agent_to_cell_by_properties_with_neighborhood_mask(self):
-        agent = MockAgent(1, self.grid)
+        agent = MockAgent(1)
         self.grid.place_agent(agent, (5, 5))
         neighborhood_mask = self.grid.get_neighborhood_mask((5, 5), True, False, 1)
         conditions = {"layer1": lambda x: x == 0}
@@ -789,7 +789,7 @@ class TestSingleGridWithPropertyGrid(unittest.TestCase):
 
     # Test if coordinates means the same between the grid and the property layer
     def test_property_layer_coordinates(self):
-        agent = MockAgent(0, self.grid)
+        agent = MockAgent(0)
         correct_pos = (1, 8)
         incorrect_pos = (8, 1)
         self.grid.place_agent(agent, correct_pos)
@@ -811,14 +811,14 @@ class TestSingleGridWithPropertyGrid(unittest.TestCase):
 
     # Test selecting cells with only_empty parameter
     def test_select_cells_only_empty(self):
-        self.grid.place_agent(MockAgent(0, self.grid), (5, 5))  # Occupying a cell
+        self.grid.place_agent(MockAgent(0), (5, 5))  # Occupying a cell
         selected_cells = self.grid.select_cells(only_empty=True)
         self.assertNotIn(
             (5, 5), selected_cells
         )  # The occupied cell should not be selected
 
     def test_select_cells_only_empty_with_conditions(self):
-        self.grid.place_agent(MockAgent(1, self.grid), (5, 5))
+        self.grid.place_agent(MockAgent(1), (5, 5))
         self.grid.properties["layer1"].set_cell((5, 5), 2)
         self.grid.properties["layer1"].set_cell((6, 6), 2)
 
@@ -855,7 +855,7 @@ class TestSingleNetworkGrid(unittest.TestCase):
         self.space = NetworkGrid(G)
         self.agents = []
         for i, pos in enumerate(TEST_AGENTS_NETWORK_SINGLE):
-            a = MockAgent(i, None)
+            a = MockAgent(i)
             self.agents.append(a)
             self.space.place_agent(a, pos)
 
@@ -968,7 +968,7 @@ class TestMultipleNetworkGrid(unittest.TestCase):
         self.space = NetworkGrid(G)
         self.agents = []
         for i, pos in enumerate(TEST_AGENTS_NETWORK_MULTIPLE):
-            a = MockAgent(i, None)
+            a = MockAgent(i)
             self.agents.append(a)
             self.space.place_agent(a, pos)
 
diff --git a/tests/test_time.py b/tests/test_time.py
index 53967d1a..a9d8e2e6 100644
--- a/tests/test_time.py
+++ b/tests/test_time.py
@@ -8,7 +8,6 @@ from unittest import TestCase, mock
 from mesa import Agent, Model
 from mesa.time import (
     BaseScheduler,
-    DiscreteEventScheduler,
     RandomActivation,
     RandomActivationByType,
     SimultaneousActivation,
@@ -339,119 +338,5 @@ class TestRandomActivationByType(TestCase):
     #         model.schedule.add(b)
 
 
-class TestDiscreteEventScheduler(TestCase):
-    def setUp(self):
-        self.model = MockModel()
-        self.scheduler = DiscreteEventScheduler(self.model, time_step=1)
-        self.model.schedule = self.scheduler
-        self.agent1 = MockAgent(1, self.model)
-        self.agent2 = MockAgent(2, self.model)
-        self.model.schedule.add(self.agent1, schedule_now=False)
-        self.model.schedule.add(self.agent2, schedule_now=False)
-
-    # Testing Initialization and Attributes
-    def test_initialization(self):
-        self.assertIsInstance(self.scheduler.event_queue, list)
-        self.assertEqual(self.scheduler.time_step, 1)
-
-    # Testing Event Scheduling
-    def test_schedule_event(self):
-        self.scheduler.schedule_event(5, self.agent1)
-        self.assertEqual(len(self.scheduler.event_queue), 1)
-        event_time, _, event_agent = self.scheduler.event_queue[0]
-        self.assertEqual(event_time, 5)
-        self.assertEqual(event_agent(), self.agent1)
-
-    def test_schedule_event_with_float_time(self):
-        self.scheduler.schedule_event(5.5, self.agent1)
-        self.assertEqual(len(self.scheduler.event_queue), 1)
-        event_time, _, event_agent = self.scheduler.event_queue[0]
-        self.assertEqual(event_time, 5.5)
-        self.assertEqual(event_agent(), self.agent1)
-
-    def test_schedule_in(self):
-        self.scheduler.schedule_in(3, self.agent2)
-        _, _, event_agent = self.scheduler.event_queue[0]
-        self.assertEqual(event_agent(), self.agent2)
-        self.assertEqual(self.scheduler.get_next_event_time(), self.scheduler.time + 3)
-
-    # Testing Event Execution and Time Advancement
-    def test_step_function(self):
-        self.scheduler.schedule_event(1, self.agent1)
-        self.scheduler.schedule_event(2, self.agent2)
-        self.scheduler.step()
-        self.assertEqual(self.scheduler.time, 1)
-        self.assertEqual(self.agent1.steps, 1)
-        self.assertEqual(self.agent2.steps, 0)
-
-    def test_time_advancement(self):
-        self.scheduler.schedule_event(5, self.agent1)
-        self.scheduler.step()
-        self.assertEqual(self.scheduler.time, 1)
-        self.scheduler.step()
-        self.assertEqual(self.scheduler.time, 2)
-
-    def test_no_events(self):
-        self.scheduler.step()
-        self.assertEqual(self.scheduler.time, 1)
-
-    # Testing Edge Cases and Error Handling
-    def test_invalid_event_time(self):
-        with self.assertRaises(ValueError):
-            self.scheduler.schedule_event(-1, self.agent1)
-
-    def test_invalid_aget_time(self):
-        with self.assertRaises(ValueError):
-            agent3 = MockAgent(3, self.model)
-            self.scheduler.schedule_event(2, agent3)
-
-    def test_immediate_event_execution(self):
-        # Current time of the scheduler
-        current_time = self.scheduler.time
-
-        # Schedule an event at the current time
-        self.scheduler.schedule_event(current_time, self.agent1)
-
-        # Step the scheduler and check if the event is executed immediately
-        self.scheduler.step()
-        self.assertEqual(self.agent1.steps, 1)
-
-        # The time should advance to the next time step after execution
-        self.assertEqual(self.scheduler.time, current_time + 1)
-
-    # Testing Utility Functions
-    def test_get_next_event_time(self):
-        self.scheduler.schedule_event(10, self.agent1)
-        self.assertEqual(self.scheduler.get_next_event_time(), 10)
-
-    # Test add() method with and without immediate scheduling
-    def test_add_with_immediate_scheduling(self):
-        # Add an agent with schedule_now set to True (default behavior)
-        new_agent = MockAgent(3, self.model)
-        self.scheduler.add(new_agent)
-
-        # Check if the agent's first event is scheduled immediately
-        self.assertEqual(len(self.scheduler.event_queue), 1)
-        event_time, _, event_agent = self.scheduler.event_queue[0]
-        self.assertEqual(event_time, self.scheduler.time)
-        self.assertEqual(event_agent(), new_agent)
-
-        # Step the scheduler and check if the agent's step method is executed
-        self.scheduler.step()
-        self.assertEqual(new_agent.steps, 1)
-
-    def test_add_without_immediate_scheduling(self):
-        # Add an agent with schedule_now set to False
-        new_agent = MockAgent(4, self.model)
-        self.scheduler.add(new_agent, schedule_now=False)
-
-        # Check if the event queue is not updated
-        self.assertEqual(len(self.scheduler.event_queue), 0)
-
-        # Step the scheduler and verify that the agent's step method is not executed
-        self.scheduler.step()
-        self.assertEqual(new_agent.steps, 0)
-
-
 if __name__ == "__main__":
     unittest.main()
diff --git a/tests/test_tornado.py b/tests/test_tornado.py
index 24025a58..52f40417 100644
--- a/tests/test_tornado.py
+++ b/tests/test_tornado.py
@@ -4,7 +4,7 @@ import tornado
 from tornado.testing import AsyncHTTPTestCase
 
 from mesa import Model
-from mesa.visualization.ModularVisualization import ModularServer
+from mesa.visualization_old.ModularVisualization import ModularServer
 
 
 class TestServer(AsyncHTTPTestCase):
diff --git a/tests/test_usersettableparam.py b/tests/test_usersettableparam.py
index 8f02fa4b..52ba971e 100644
--- a/tests/test_usersettableparam.py
+++ b/tests/test_usersettableparam.py
@@ -1,6 +1,6 @@
 from unittest import TestCase
 
-from mesa.visualization.UserParam import (
+from mesa.visualization_old.UserParam import (
     Checkbox,
     Choice,
     NumberInput,
diff --git a/tests/test_visualization.py b/tests/test_visualization.py
index ca9f0949..b04a7624 100644
--- a/tests/test_visualization.py
+++ b/tests/test_visualization.py
@@ -5,9 +5,9 @@ import mesa
 from mesa.model import Model
 from mesa.space import MultiGrid
 from mesa.time import SimultaneousActivation
-from mesa.visualization.ModularVisualization import ModularServer
-from mesa.visualization.modules import CanvasGrid, TextElement
-from mesa.visualization.UserParam import (
+from mesa.visualization_old.ModularVisualization import ModularServer
+from mesa.visualization_old.modules import CanvasGrid, TextElement
+from mesa.visualization_old.UserParam import (
     NumberInput,
     Slider,
 )