Repository: projectmesa/mesa
Pull request #2263 (2024-08-30 19:52:39)

Solaraviz api
This is the start of a new API for the Solara frontend. See https://github.com/projectmesa/mesa/discussions/2236 for more background information. I will eventually update this intro to accommodate more information, but I wanted to start this draft PR now and not wait longer just because I am too lazy right now to write more sentences.

So far everything worked out nicely. The interesting changes are in the `myviz.py`  file. This is where the new entrypoint can be found. The other components ModelController and UserParams only received very minor changes and were just moved to other files to not interefere with the current SolaraViz (during development).

MyViz also includes a minimal example, if someone wants to try this out just run `solara run myviz.py`

![image](https://github.com/user-attachments/assets/2e68ec19-9014-4116-b1df-5c3ba78c2754)

github-actions[bot]: Performance benchmarks:

| Model           | Size   | Init time [95% CI]        | Run time [95% CI]       |
|-----------------|--------|---------------------------|-------------------------|
| BoltzmannWealth | small  | 🔵 -1.7% [-4.0%, +0.4%]   | 🔵 -0.9% [-1.0%, -0.7%] |
| BoltzmannWealth | large  | 🔵 -0.7% [-34.8%, +41.1%] | 🔵 -0.9% [-3.7%, +2.1%] |
| Schelling       | small  | 🔵 -0.8% [-1.1%, -0.5%]   | 🔵 -1.5% [-1.8%, -1.2%] |
| Schelling       | large  | 🔵 -1.7% [-3.1%, -0.3%]   | 🔵 -4.2% [-9.5%, +1.0%] |
| WolfSheep       | small  | 🔵 +2.3% [+0.7%, +3.9%]   | 🔵 +2.0% [+1.6%, +2.4%] |
| WolfSheep       | large  | 🔵 +0.0% [-1.9%, +2.1%]   | 🔵 +1.6% [-1.7%, +5.8%] |
| BoidFlockers    | small  | 🔵 -1.2% [-2.1%, -0.5%]   | 🔵 +0.4% [-0.2%, +1.0%] |
| BoidFlockers    | large  | 🔵 -1.1% [-1.6%, -0.5%]   | 🔵 -0.1% [-0.6%, +0.4%] |

EwoutH: Exciting!

I tried both
```bash
mesa>solara run mesa/visualization/myviz.py
```
and 
```bash
mesa\mesa\visualization>solara run myviz.py
```
But for some reason I can't get the imports working. Will take a better look tomorrow.
rht: I tried it and it worked. Will add inline comments.
EwoutH: @rht from where did you run it? I would also like to try it.
rht: I ran it In the visualization directory.
EwoutH: Maybe something with working directories? Hate this kind of stuff, and imports in general. So many inexplainable issues.

![image](https://github.com/user-attachments/assets/e4ced153-9fed-41bf-bc43-43308bf54689)

EwoutH: It was a namespace conflict between the installed Mesa package and the GitHub repo. Removing mesa and adding

```Python
import sys
import os

sys.path.insert(0, os.path.abspath('C:/Users/Ewout/Documents/GitHub/mesa'))
```
solved it. Let's take a look!
EwoutH: Looks clean, feels fast and responsive!

What's the difference between these two buttons? When running they both seem to pauze the simulation in the same way.

![image](https://github.com/user-attachments/assets/ff733fe1-07de-438d-8fea-08da83807a7a)

rht: The 2 buttons having the same functionality is out of scope for the PR. It is due to ipywidget displaying both and there is no way to turn either off.
Corvince: I have updated the code so it now updates solara_viz.py instead of creating a new myviz. Also moved back the modified ModelController.

All in all the diff should be much more reflective of the actual code changes. 

Things that are still missing (please add if you notice something):

- [ ] Layout stuff (draggable grid layout)
- [ ] reactive seed
- [ ] Easy way to plot simple measures just by their attribute name
Corvince: > I really love that you can just input an model instance, and if I'm correct, acces that model instance from the interpreter.
> 
> This would solve a huge part of https://github.com/projectmesa/mesa/issues/2176, right? And would [this](https://github.com/projectmesa/mesa/issues/2176#issuecomment-2282808460) be possible?

Yes, exactly! Although for it to fully work, even if you reset the model or change parameters, you should pass in the model as a reactive model, i.e. call `solara.reactive(model)` on it. Inside the SolaraViz it also attaches a call to `force_update()` after each step, which triggers a viz update.
EwoutH: If you need any input let me know!
Corvince: Now that you say it, what's your opinion on the layout? I thought at first to implement how it's currently done. But maybe we can be more unopinionated. In my implementation all components are just displayed with no surrounding layout. But the components themselves can of course include any layout. We could keep it that way and provide some layout components. So users could just pass `solaraviz(model, components=GridDraggable(componentA, componentB)` or something like that.

Also, regarding naming. Is Components a good enough name? 
EwoutH: I think the current layout is quite good. I like controls on the left, visualisation and output in the center/right.
- Maybe it would be nice if you could specify is you want things below or right of previous components, but maybe keeping it adaptive is more flexible.
- It might be nice to be able to specify an aspect ratio per component. Some graphs you might want wide, some tall etc.
  - For the `space_drawer` this might be done automatically based on the grid width and height.

> Also, regarding naming. Is Components a good enough name?

Many names could work here, but Components is quite good.
rht: I actually find the naming to be quite restrictive in order to encompass other Solara components, because it needs to be general, e.g. if you want to use the `DataFrame`/`PivotTable` component in https://solara.dev/documentation/components. "Measures" doesn't cover this. Meanwhile, "components" is what is already used by Solara.
quaquel: This is just a clarifying question from my end. In the various conversations on data collection, we converged on building this on top of some pub-sub or signal-based API. How might that choice affect this effort, or vice versa, what would be convenient to use inside MESA such that both data collection and model visualization can utilize it?

In #2176, @maartenbreddels suggested using either Solara reactive variables or psygnal. I have looked at the latter, and it is very elegant and does a lot of what we want for data collection. Importantly, it also allows for subscribing to updates in collections. However, it would add an additional dependency to MESA. I also started exploring Solara's reactive variables. It appears this covers a subset of what we would need for data collection, but I am not sure until I actually play around with it more. 

In my vision, both the visualization side and the data collection side of MESA use the same mechanism for being informed/getting state information from a MESA model. 
Corvince: > This is just a clarifying question from my end. In the various conversations on data collection, we converged on building this on top of some pub-sub or signal-based API. How might that choice affect this effort, or vice versa, what would be convenient to use inside MESA such that both data collection and model visualization can utilize it?
> 
> In #2176, @maartenbreddels suggested using either Solara reactive variables or psygnal. I have looked at the latter, and it is very elegant and does a lot of what we want for data collection. Importantly, it also allows for subscribing to updates in collections. However, it would add an additional dependency to MESA. I also started exploring Solara's reactive variables. It appears this covers a subset of what we would need for data collection, but I am not sure until I actually play around with it more.
> 
> In my vision, both the visualization side and the data collection side of MESA use the same mechanism for being informed/getting state information from a MESA model.

The top level view on how it currently works (in this PR) is that each component registers a "update_counter" as a render dependency. Whenever this counter increases the component rerenders. Currently the counter is increased after each mode..step.

Of course it would be much more elegant to auto-update whenever an attribute changes, for example not through a call to step, but maybe through some form of user interactions. If the variable would be a reactive variable this would already happen automatically. 

So from a visualization point of view the use of solaras reactive values would be very charming. So I would appreciate it if you further investigate solaras reactive suitability. 

But overall this PR is mostly neutral to how we handle this - it mostly deals with the surroundings and establishing a cleaner API. The use of signals/reactive values/pub-sub will mostly play some role for individual components (e.g. a plot of a specifc variable)
github-actions[bot]: Performance benchmarks:

| Model           | Size   | Init time [95% CI]        | Run time [95% CI]       |
|-----------------|--------|---------------------------|-------------------------|
| BoltzmannWealth | small  | 🔵 +1.1% [-0.0%, +2.3%]   | 🔵 -0.1% [-0.2%, +0.0%] |
| BoltzmannWealth | large  | 🔵 -1.0% [-29.8%, +32.3%] | 🔵 +0.1% [-2.3%, +2.2%] |
| Schelling       | small  | 🔵 +0.9% [+0.7%, +1.2%]   | 🔵 -0.5% [-0.7%, -0.3%] |
| Schelling       | large  | 🔵 +1.1% [+0.4%, +2.0%]   | 🔵 +3.7% [+1.3%, +6.7%] |
| WolfSheep       | small  | 🔵 +3.5% [+1.8%, +5.1%]   | 🔵 +0.1% [-0.1%, +0.5%] |
| WolfSheep       | large  | 🔵 +3.3% [+1.7%, +4.7%]   | 🔵 +3.1% [+1.2%, +5.3%] |
| BoidFlockers    | small  | 🔵 +0.6% [-0.4%, +1.7%]   | 🔵 -0.3% [-1.1%, +0.6%] |
| BoidFlockers    | large  | 🔵 +0.6% [-0.3%, +1.7%]   | 🔵 -0.5% [-1.3%, +0.4%] |

Corvince: I just marked this as ready for review! Even though the tests are still failing, this should probably also be fixed as part of this PR? 

Otherwise I think this is good to review as a first PR just for the API changes.

Next todos are:

- [ ] Update example models
- [ ] Write migration guide from current API to this PR. You should be able to have the same viz as before with small adjustemnts, but those adjustments need to be written out.
- [ ] Bring back GridDraggable layout. This was used before, but I found the code a bit confusing so I didn't manage to adapt just yet. Per default the layout now is unopinionated, but you can bring back the old style with `SolaraViz(model, components=[GridDraggable(...components)]`. But the GridDraggable component needs to be written in a further PR
- [ ] Decide which subcomponents will be part of the public API for Mesa v3. 

Not so immediate, but also a nice todo: Rewrite ModelController. There is already code for a much simpler threaded version and from a first test this finally seems to work. Maybe it doesn't even need to be threaded and just works. In any case it can be vastly simplified (and no more unnecessary pause and stop button)

But first lets get this PR through! 
EwoutH: @Corvince looks awesome! Any specific things you would like me to test or review?
Corvince: Yes! You can try to use it 🙈 

Heres a quick migration guide. Where previous you could have something like this for a minimal frontend
```python
SolaraViz(model_cls, model_params, agent_portrayal=agent_portrayal)
```

you would now do

```python
SolaraViz(model, components=[make_space_altair(agent_portrayal)])
```

you can test if that works for `make_space_altair` and `make_space_matplotlib` and then additionally add `model_params` to SolaraViz. And then test if everything works (start, stop, reset, reseed, change parameters).

That would be awesome!
EwoutH: Awesome, I'm going to 
```
pip install -U -e git+https://github.com/projectmesa/mesa@solaraviz-api#egg=mesa
```
and test some examples.

I just talked to Jan, he is going to try to dust of hist pub-sub efforts to see if that will integrate nicely with this.
EwoutH: Playing a bit around! Got `boltzmann_wealth_model_experimental` working:

![image](https://github.com/user-attachments/assets/6e99b870-61a4-42b3-a1e6-0ac317231734)

> * Easy way to plot simple measures just by their attribute name

Yeah this would be really useful. We could think of another/more robust API to parse measures other than just a string name (@quaquel?), but I think this is important to get right.

I like that's way more flexible: You don't need to input `model_params` for values you don't need as sliders and such. You can just use the default values or override them.

Another this that's interesting is that the slider overrides default/other values. I think that's good to document.

![image](https://github.com/user-attachments/assets/e24910ef-0114-481b-b193-ff6f90707ad4)

It does show that something like 
- https://github.com/projectmesa/mesa/issues/2268

can be useful.
EwoutH: I also tried [`virus_on_network`](https://github.com/projectmesa/mesa-examples/tree/main/examples/virus_on_network). However, it has a custom [`make_plot`](https://github.com/projectmesa/mesa-examples/blob/330b4d9c8e44127b3c4b89603972a46dd5ec39b9/examples/virus_on_network/app.py#L47C1-L60C33) function, that seems difficult to adapt, and I didn't get it to work.

```Python
def make_plot(model):
    # This is for the case when we want to plot multiple measures in 1 figure.
    fig = Figure()
    ax = fig.subplots()
    measures = ["Infected", "Susceptible", "Resistant"]
    colors = ["tab:red", "tab:green", "tab:gray"]
    for i, m in enumerate(measures):
        color = colors[i]
        df = model.datacollector.get_model_vars_dataframe()
        ax.plot(df.loc[:, m], label=m, color=color)
    fig.legend()
    # Set integer x axis
    ax.xaxis.set_major_locator(MaxNLocator(integer=True))
    solara.FigureMatplotlib(fig)
```

```Python
page = SolaraViz(
    VirusOnNetwork,
    model_params,
    measures=[
        make_plot,
        make_text(get_resistant_susceptible_ratio),
    ],
    name="Virus Model",
    agent_portrayal=agent_portrayal,
)
page  # noqa
```
Corvince: Awesome, thank you so much for testing this out!

> > * Easy way to plot simple measures just by their attribute name
> 
> Yeah this would be really useful. We could think of another/more robust API to parse measures other than just a string name (@quaquel?), but I think this is important to get right.

Yes, this needs some rework. Currently it takes its data from the data collector, but I think it would make more sense to get the values directly. But I already implemented the current approach with `make_plot_measure("foo")`


> I also tried [`virus_on_network`](https://github.com/projectmesa/mesa-examples/tree/main/examples/virus_on_network). However, it has a custom [`make_plot`](https://github.com/projectmesa/mesa-examples/blob/330b4d9c8e44127b3c4b89603972a46dd5ec39b9/examples/virus_on_network/app.py#L47C1-L60C33) function, that seems difficult to adapt, and I didn't get it to work.

It should be as easy as turning it into a solara component and adding the update_counter:

```python
@solara.component  # first change
def make_plot(model):
    update_counter.get() # second change
    # This is for the case when we want to plot multiple measures in 1 figure.
    fig = Figure()
    ax = fig.subplots()
    measures = ["Infected", "Susceptible", "Resistant"]
    colors = ["tab:red", "tab:green", "tab:gray"]
    for i, m in enumerate(measures):
        color = colors[i]
        df = model.datacollector.get_model_vars_dataframe()
        ax.plot(df.loc[:, m], label=m, color=color)
    fig.legend()
    # Set integer x axis
    ax.xaxis.set_major_locator(MaxNLocator(integer=True))
    solara.FigureMatplotlib(fig)
```

But yes, documenting how to add author custom components should als be pretty high on the follow up todo list
EwoutH: The only question I now have, is do we want to directly replace the current viz or do we want to have some deprecation scheme and have them parallel for a bit?
maartenbreddels: > this could be done using Solara's `Reactive`, but this is closely wrapped up with all kinds of GUI stuff that we don't need.

Why do you think that is the case? They are, I think, quite separated from the UI part (except being in the same package).

Something to also consider is 'scope'. In solara server (if you plan to use that), you can have multiple browser pages (often seen as 'users') connected to the same runtime. Reactive variables understand that, similar to threading.local for thread local storage, Solara reactive variables use kernel-local storage.
quaquel: > Why do you think that is the case? They are, I think, quite separated from the UI part (except being in the same package).

Let me clarify. I have been trying to figure out which parts from Solara I can use to reimplement my existing pubsub idea and envisioned API. The documentation for Solara focusses on "GUI stuff". So, I have been diving into the source code. This has taken me to parts of the code base that are not documented (e.g.,`Reactive`), so I have been trying to figure out what is going on and how to utilize it for MESA purposes.

 In short, my impression is based perhaps more on the existing Solara documentation and my struggle to parse from the entire code base the parts I would need. As you said yourself in #2176, ideally the reactive part should be its own library. 
Corvince: > The only question I now have, is do we want to directly replace the current viz or do we want to have some deprecation scheme and have them parallel for a bit?

Thats a good question and one I have been pondering for a while. Technically the solaraviz has always been experimental and so we don't break any contract with the changes. However, there hasn't been an alternative built-in frontend for a while now and we have been pointing towards the new viz for some time. It will be a bit annoying for everyone to rebuild there modesl yet again. But then again we have several breaking changes with mesa 3.0, so people will probably have to rebuild their models anyway. 

One difficulty with deprecation is how to handle it gracefully. I think the code for that will be a bit ugly and then is it really necessary to deprecate experimental features? The most elegant deprecation would of course be to find a new name for "SolaraViz". I think its not perfect at all, because its neither specific to mesa, nor generic enough. Sure, currently it only supports solara components, but maybe this could change in the future (e.g. you can already use vue components, but you need to wrap them with `@solara.vue_component`). 

So maybe there is a better name to be discovered? Then we could leave the old code intact (deprecated) and write nice migration guides to the new viz. 
EwoutH: Since we haven't clear policy, versioning systems and integration testing in our examples, I would propose placing this in the `.experimental` space for now. That way we can continue to develop this at a faster pace without being afraid that we have to update everything continuously.

Furthermore, I really want to be able to test the visualisation in CI. I think this will really help with that:

> I want to directly trigger run the Solara visualisation from the Python script. So doing something like `page.run()` or whatever.

Would something like this be possible?
EwoutH: > One difficulty with deprecation is how to handle it gracefully.

1. Add new stuff in new location
2. Develop it further
3. Declare it stable
4. Start pointing to it, write docs, etc.
5. Start deprecating the old one (throw a warning)
6. Wait a few versions
7. Throw error for the old one
8. Wait a few versions
9. Remove the old one
10. If preferred, move the new one. Point from the initial new location to the new initial location not to break things.

Best for it would be to have a new name to make it clearly distinct.
Corvince: > > I want to directly trigger run the Solara visualisation from the Python script. So doing something like `page.run()` or whatever.

I am not sure I understand this correctly. Whats the workflow here? Normally you would execute `solara run file.py`. Then you can edit the file and solara will reload and reflect all changes. 
Or you can use the solaraviz in a notebook. Then (with this PR) if you pass in a reactive model you can modify the model outside the solaraviz display, and it will update anytime you call `model.value.force_update()`. This is automatically done for `model.step`

Or do you mean in an interactive python session? Not sure if this is possible, but since solara automatically reloads, I think its easier to just start it on a file. 
EwoutH: I just want to press run here (which does `python app.py`) and have the visualisation start.

![image](https://github.com/user-attachments/assets/5c4e9ee3-19d7-474d-ada8-389d992ee5e1)
Corvince: I think you just need some custom settings in your editor. Have a look at

https://solara.dev/documentation/advanced/howto/debugging
EwoutH: I tried a lot of things but unfortunately none worked.

The point it, I just want to run a Python script when I'm normally do, and then, when the SolaraViz instance is called (`page()`, `page.run()`, whatever), Solara starts. Then, I do my stuff in Solara, at some point I'm done, press something like "Close", and now I'm back in the Python script and if there's any code left, it continues running.

(we can have this discussion in a separate thread if you want, since it's not directly linked to this PR)
Corvince: > Since we haven't clear policy, versioning systems and integration testing in our examples, I would propose placing this in the `.experimental` space for now. That way we can continue to develop this at a faster pace without being afraid that we have to update everything continuously.

Disagree here. We currently don't have a frontend currently. The latest stable release (2.3) still has the "old" SolaraViz under experimantal. The next release (3.0) will have the "old" solaraviz marked as stable. But the whole purpose of this PR was to have a good, stable API for 3.0. Dont like the idea of moving this to experimental and then soon deprecating the "old" SolaraViz, when its not out of experimental yet. 



EwoutH: Yeah that's a good point. I'm inclined to agree.

But then the docs need to be on-par before we get into Mesa 3.0 beta territory. Do we have the capacity to do that (@projectmesa/maintainers)?
rht: > But then the docs, tutorial and examples need to be on-par before we get into Mesa 3.0 beta territory. Do we have the capacity to do that (@projectmesa/maintainers)?

It may seem to be a lot of work, but given that in Mesa 2.x it is still an experimental API, users of the JupyterViz (now SolaraViz) were already expecting API changes. It's less work overall to include the API changes altogether now, than to delay it to Mesa 4.0.
Corvince: Exactly. I think it may make sense to actually move the JupyterViz/SolaraViz back into experimental. This way 2.x users who already use the experimental SolaraViz module don't *need* to change anything. They can still `import SolaraViz from mesa.experimental`  But at the same time we have the new, stable API `import SolaraViz from mesa.visualization`.  How does that sound? This way we only need changes from people on the 3.x pre release channel, but changes are expected there. 

Still we should consider a new name, but this can come later (before 3.0 stable of course. But if we have a new name we can alias it to SolaraViz, like we currently do for JupyterViz)
Corvince: I rebased this PR. Hopefully tonight or tomorrow at the latest I will update the tests, so ci will be happy again 
EwoutH: Leftover from #2278, but could you fix this import?

https://github.com/projectmesa/mesa/blob/cfa26359b3aecef12636031e08230d38768ae4b9/tests/test_solara_viz.py#L8

(I already approved)
Corvince: Yes, see my last comment

Patch:
diff --git a/.github/ISSUE_TEMPLATE/asking-help.md b/.github/ISSUE_TEMPLATE/asking-help.md
deleted file mode 100644
index 0a35b36a..00000000
--- a/.github/ISSUE_TEMPLATE/asking-help.md
+++ /dev/null
@@ -1,9 +0,0 @@
----
-name: Asking for help
-about: If you need help using Mesa, you should post in https://github.com/projectmesa/mesa/discussions
----
-
-<!--
-    ATTENTION: Don't raise an issue here!
-    If you need help, ask in https://github.com/projectmesa/mesa/discussions
--->
diff --git a/.github/ISSUE_TEMPLATE/bug-report.md b/.github/ISSUE_TEMPLATE/bug-report.md
index 9ae0cda6..52b662e5 100644
--- a/.github/ISSUE_TEMPLATE/bug-report.md
+++ b/.github/ISSUE_TEMPLATE/bug-report.md
@@ -11,7 +11,8 @@ about: Let us know if something is broken on Mesa
 <!-- A clear and concise description of what you expected to happen -->
 
 **To Reproduce**
-<!-- Steps to reproduce the bug, or a link to a project where the bug is visible -->
+<!-- Steps to reproduce the bug, or a link to a project where the bug is visible
+Include a Minimal reproducible example: https://stackoverflow.com/help/minimal-reproducible-example -->
 
 **Additional context**
 <!--
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 00000000..473d9f11
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,5 @@
+blank_issues_enabled: true
+contact_links:
+  - name: Questions, ideas, showcases and more
+    url: https://github.com/projectmesa/mesa/discussions
+    about: Discuss Mesa, ask questions, share ideas, and showcase your projects
diff --git a/benchmarks/BoltzmannWealth/boltzmann_wealth.py b/benchmarks/BoltzmannWealth/boltzmann_wealth.py
index f6b1bf3d..56443c6f 100644
--- a/benchmarks/BoltzmannWealth/boltzmann_wealth.py
+++ b/benchmarks/BoltzmannWealth/boltzmann_wealth.py
@@ -27,8 +27,8 @@ class BoltzmannWealth(mesa.Model):
             model_reporters={"Gini": compute_gini}, agent_reporters={"Wealth": "wealth"}
         )
         # Create agents
-        for i in range(self.num_agents):
-            a = MoneyAgent(i, self)
+        for _ in range(self.num_agents):
+            a = MoneyAgent(self)
             # Add the agent to a random grid cell
             x = self.random.randrange(self.grid.width)
             y = self.random.randrange(self.grid.height)
@@ -50,8 +50,8 @@ class BoltzmannWealth(mesa.Model):
 class MoneyAgent(mesa.Agent):
     """An agent with fixed initial wealth."""
 
-    def __init__(self, unique_id, model):
-        super().__init__(unique_id, model)
+    def __init__(self, model):
+        super().__init__(model)
         self.wealth = 1
 
     def move(self):
diff --git a/benchmarks/Flocking/flocking.py b/benchmarks/Flocking/flocking.py
index ff9f97c0..284d8318 100644
--- a/benchmarks/Flocking/flocking.py
+++ b/benchmarks/Flocking/flocking.py
@@ -27,7 +27,6 @@ class Boid(mesa.Agent):
 
     def __init__(
         self,
-        unique_id,
         model,
         speed,
         direction,
@@ -41,7 +40,6 @@ class Boid(mesa.Agent):
         Create a new Boid flocker agent.
 
         Args:
-            unique_id: Unique agent identifier.
             speed: Distance to move per step.
             direction: numpy vector for the Boid's direction of movement.
             vision: Radius to look around for nearby Boids.
@@ -51,7 +49,7 @@ class Boid(mesa.Agent):
             match: the relative importance of matching neighbors' directions
 
         """
-        super().__init__(unique_id, model)
+        super().__init__(model)
         self.speed = speed
         self.direction = direction
         self.vision = vision
@@ -131,13 +129,12 @@ class BoidFlockers(mesa.Model):
             "match": match,
         }
 
-        for i in range(self.population):
+        for _ in range(self.population):
             x = self.random.random() * self.space.x_max
             y = self.random.random() * self.space.y_max
             pos = np.array((x, y))
             direction = np.random.random(2) * 2 - 1
             boid = Boid(
-                unique_id=i,
                 model=self,
                 speed=speed,
                 direction=direction,
diff --git a/benchmarks/Schelling/schelling.py b/benchmarks/Schelling/schelling.py
index b7702a84..b582e7af 100644
--- a/benchmarks/Schelling/schelling.py
+++ b/benchmarks/Schelling/schelling.py
@@ -8,15 +8,14 @@ class SchellingAgent(CellAgent):
     Schelling segregation agent
     """
 
-    def __init__(self, unique_id, model, agent_type, radius, homophily):
+    def __init__(self, model, agent_type, radius, homophily):
         """
         Create a new Schelling agent.
         Args:
-           unique_id: Unique identifier for the agent.
            x, y: Agent initial location.
            agent_type: Indicator for the agent's type (minority=1, majority=0)
         """
-        super().__init__(unique_id, model)
+        super().__init__(model)
         self.type = agent_type
         self.radius = radius
         self.homophily = homophily
@@ -81,9 +80,7 @@ class Schelling(Model):
         for cell in self.grid:
             if self.random.random() < density:
                 agent_type = 1 if self.random.random() < self.minority_pc else 0
-                agent = SchellingAgent(
-                    self.next_id(), self, agent_type, radius, homophily
-                )
+                agent = SchellingAgent(self, agent_type, radius, homophily)
                 agent.move_to(cell)
                 self.schedule.add(agent)
 
diff --git a/benchmarks/WolfSheep/wolf_sheep.py b/benchmarks/WolfSheep/wolf_sheep.py
index 655d51bb..ceb6efef 100644
--- a/benchmarks/WolfSheep/wolf_sheep.py
+++ b/benchmarks/WolfSheep/wolf_sheep.py
@@ -17,8 +17,8 @@ from mesa.experimental.devs import ABMSimulator
 
 
 class Animal(CellAgent):
-    def __init__(self, unique_id, model, energy, p_reproduce, energy_from_food):
-        super().__init__(unique_id, model)
+    def __init__(self, model, energy, p_reproduce, energy_from_food):
+        super().__init__(model)
         self.energy = energy
         self.p_reproduce = p_reproduce
         self.energy_from_food = energy_from_food
@@ -29,7 +29,6 @@ class Animal(CellAgent):
     def spawn_offspring(self):
         self.energy /= 2
         offspring = self.__class__(
-            self.model.next_id(),
             self.model,
             self.energy,
             self.p_reproduce,
@@ -107,7 +106,7 @@ class GrassPatch(CellAgent):
                 function_args=[self, "fully_grown", True],
             )
 
-    def __init__(self, unique_id, model, fully_grown, countdown, grass_regrowth_time):
+    def __init__(self, model, fully_grown, countdown, grass_regrowth_time):
         """
         TODO:: fully grown can just be an int --> so one less param (i.e. countdown)
 
@@ -119,7 +118,7 @@ class GrassPatch(CellAgent):
             grass_regrowth_time : time to fully regrow grass
             countdown : Time for the patch of grass to be fully regrown if fully grown is False
         """
-        super().__init__(unique_id, model)
+        super().__init__(model)
         self._fully_grown = fully_grown
         self.grass_regrowth_time = grass_regrowth_time
 
@@ -189,7 +188,6 @@ class WolfSheep(Model):
             )
             energy = self.random.randrange(2 * sheep_gain_from_food)
             sheep = Sheep(
-                self.next_id(),
                 self,
                 energy,
                 sheep_reproduce,
@@ -205,7 +203,6 @@ class WolfSheep(Model):
             )
             energy = self.random.randrange(2 * wolf_gain_from_food)
             wolf = Wolf(
-                self.next_id(),
                 self,
                 energy,
                 wolf_reproduce,
@@ -221,9 +218,7 @@ class WolfSheep(Model):
                 countdown = grass_regrowth_time
             else:
                 countdown = self.random.randrange(grass_regrowth_time)
-            patch = GrassPatch(
-                self.next_id(), self, fully_grown, countdown, grass_regrowth_time
-            )
+            patch = GrassPatch(self, fully_grown, countdown, grass_regrowth_time)
             patch.move_to(cell)
 
     def step(self):
diff --git a/docs/tutorials/MoneyModel.py b/docs/tutorials/MoneyModel.py
index 94555f98..93e19dec 100644
--- a/docs/tutorials/MoneyModel.py
+++ b/docs/tutorials/MoneyModel.py
@@ -15,8 +15,8 @@ def compute_gini(model):
 class MoneyAgent(mesa.Agent):
     """An agent with fixed initial wealth."""
 
-    def __init__(self, unique_id, model):
-        super().__init__(unique_id, model)
+    def __init__(self, model):
+        super().__init__(model)
         self.wealth = 1
 
     def move(self):
diff --git a/mesa/agent.py b/mesa/agent.py
index 8d04df9e..225e89cd 100644
--- a/mesa/agent.py
+++ b/mesa/agent.py
@@ -10,6 +10,8 @@ from __future__ import annotations
 
 import contextlib
 import copy
+import functools
+import itertools
 import operator
 import warnings
 import weakref
@@ -32,21 +34,49 @@ class Agent:
     Base class for a model agent in Mesa.
 
     Attributes:
-        unique_id (int): A unique identifier for this agent.
         model (Model): A reference to the model instance.
-        self.pos: Position | None = None
+        unique_id (int): A unique identifier for this agent.
+        pos (Position): A reference to the position where this agent is located.
+
+    Notes:
+          unique_id is unique relative to a model instance and starts from 1
+
     """
 
-    def __init__(self, unique_id: int, model: Model) -> None:
+    # this is a class level attribute
+    # it is a dictionary, indexed by model instance
+    # so, unique_id is unique relative to a model, and counting starts from 1
+    _ids = defaultdict(functools.partial(itertools.count, 1))
+
+    def __init__(self, *args, **kwargs) -> None:
         """
         Create a new agent.
 
         Args:
-            unique_id (int): A unique identifier for this agent.
             model (Model): The model instance in which the agent exists.
         """
-        self.unique_id = unique_id
-        self.model = model
+        # TODO: Cleanup in future Mesa version (3.1+)
+        match args:
+            # Case 1: Only the model is provided. The new correct behavior.
+            case [model]:
+                self.model = model
+                self.unique_id = next(self._ids[model])
+            # Case 2: Both unique_id and model are provided, deprecated
+            case [_, model]:
+                warnings.warn(
+                    "unique ids are assigned automatically to Agents in Mesa 3. The use of custom unique_id is "
+                    "deprecated. Only input a model when calling `super()__init__(model)`. The unique_id inputted is not used.",
+                    DeprecationWarning,
+                    stacklevel=2,
+                )
+                self.model = model
+                self.unique_id = next(self._ids[model])
+            # Case 3: Anything else, raise an error
+            case _:
+                raise ValueError(
+                    "Invalid arguments provided to initialize the Agent. Only input a model: `super()__init__(model)`."
+                )
+
         self.pos: Position | None = None
 
         self.model.register_agent(self)
diff --git a/mesa/experimental/cell_space/cell_agent.py b/mesa/experimental/cell_space/cell_agent.py
index abc5155a..5f1cca5c 100644
--- a/mesa/experimental/cell_space/cell_agent.py
+++ b/mesa/experimental/cell_space/cell_agent.py
@@ -19,7 +19,7 @@ class CellAgent(Agent):
         cell: (Cell | None): the cell which the agent occupies
     """
 
-    def __init__(self, unique_id: int, model: Model) -> None:
+    def __init__(self, model: Model) -> None:
         """
         Create a new agent.
 
@@ -27,7 +27,7 @@ class CellAgent(Agent):
             unique_id (int): A unique identifier for this agent.
             model (Model): The model instance in which the agent exists.
         """
-        super().__init__(unique_id, model)
+        super().__init__(model)
         self.cell: Cell | None = None
 
     def move_to(self, cell) -> None:
diff --git a/mesa/experimental/devs/examples/epstein_civil_violence.py b/mesa/experimental/devs/examples/epstein_civil_violence.py
index 9a7314e0..c976bb73 100644
--- a/mesa/experimental/devs/examples/epstein_civil_violence.py
+++ b/mesa/experimental/devs/examples/epstein_civil_violence.py
@@ -7,8 +7,8 @@ from mesa.space import SingleGrid
 
 
 class EpsteinAgent(Agent):
-    def __init__(self, unique_id, model, vision, movement):
-        super().__init__(unique_id, model)
+    def __init__(self, model, vision, movement):
+        super().__init__(model)
         self.vision = vision
         self.movement = movement
 
@@ -46,7 +46,6 @@ class Citizen(EpsteinAgent):
 
     def __init__(
         self,
-        unique_id,
         model,
         vision,
         movement,
@@ -59,7 +58,6 @@ class Citizen(EpsteinAgent):
         """
         Create a new Citizen.
         Args:
-            unique_id: unique int
             model : model instance
             hardship: Agent's 'perceived hardship (i.e., physical or economic
                 privation).' Exogenous, drawn from U(0,1).
@@ -71,7 +69,7 @@ class Citizen(EpsteinAgent):
             vision: number of cells in each direction (N, S, E and W) that
                 agent can inspect. Exogenous.
         """
-        super().__init__(unique_id, model, vision, movement)
+        super().__init__(model, vision, movement)
         self.hardship = hardship
         self.regime_legitimacy = regime_legitimacy
         self.risk_aversion = risk_aversion
@@ -144,8 +142,8 @@ class Cop(EpsteinAgent):
             able to inspect
     """
 
-    def __init__(self, unique_id, model, vision, movement, max_jail_term):
-        super().__init__(unique_id, model, vision, movement)
+    def __init__(self, model, vision, movement, max_jail_term):
+        super().__init__(model, vision, movement)
         self.max_jail_term = max_jail_term
 
     def step(self):
@@ -236,7 +234,6 @@ class EpsteinCivilViolence(Model):
         for _, pos in self.grid.coord_iter():
             if self.random.random() < self.cop_density:
                 agent = Cop(
-                    self.next_id(),
                     self,
                     cop_vision,
                     movement,
@@ -244,7 +241,6 @@ class EpsteinCivilViolence(Model):
                 )
             elif self.random.random() < (self.cop_density + self.citizen_density):
                 agent = Citizen(
-                    self.next_id(),
                     self,
                     citizen_vision,
                     movement,
@@ -270,4 +266,4 @@ if __name__ == "__main__":
 
     simulator.setup(model)
 
-    simulator.run(time_delta=100)
+    simulator.run_for(time_delta=100)
diff --git a/mesa/experimental/devs/examples/wolf_sheep.py b/mesa/experimental/devs/examples/wolf_sheep.py
index 99fde6a9..cd81cb2f 100644
--- a/mesa/experimental/devs/examples/wolf_sheep.py
+++ b/mesa/experimental/devs/examples/wolf_sheep.py
@@ -14,8 +14,8 @@ from mesa.experimental.devs.simulator import ABMSimulator
 
 
 class Animal(mesa.Agent):
-    def __init__(self, unique_id, model, moore, energy, p_reproduce, energy_from_food):
-        super().__init__(unique_id, model)
+    def __init__(self, model, moore, energy, p_reproduce, energy_from_food):
+        super().__init__(model)
         self.energy = energy
         self.p_reproduce = p_reproduce
         self.energy_from_food = energy_from_food
@@ -30,7 +30,6 @@ class Animal(mesa.Agent):
     def spawn_offspring(self):
         self.energy /= 2
         offspring = self.__class__(
-            self.model.next_id(),
             self.model,
             self.moore,
             self.energy,
@@ -109,7 +108,7 @@ class GrassPatch(mesa.Agent):
                 function_args=[self, "fully_grown", True],
             )
 
-    def __init__(self, unique_id, model, fully_grown, countdown, grass_regrowth_time):
+    def __init__(self, model, fully_grown, countdown, grass_regrowth_time):
         """
         Creates a new patch of grass
 
@@ -117,7 +116,7 @@ class GrassPatch(mesa.Agent):
             grown: (boolean) Whether the patch of grass is fully grown or not
             countdown: Time for the patch of grass to be fully grown again
         """
-        super().__init__(unique_id, model)
+        super().__init__(model)
         self._fully_grown = fully_grown
         self.grass_regrowth_time = grass_regrowth_time
 
@@ -191,7 +190,6 @@ class WolfSheep(mesa.Model):
             )
             energy = self.random.randrange(2 * sheep_gain_from_food)
             sheep = Sheep(
-                self.next_id(),
                 self,
                 moore,
                 energy,
@@ -208,7 +206,6 @@ class WolfSheep(mesa.Model):
             )
             energy = self.random.randrange(2 * wolf_gain_from_food)
             wolf = Wolf(
-                self.next_id(),
                 self,
                 moore,
                 energy,
@@ -225,9 +222,7 @@ class WolfSheep(mesa.Model):
                 countdown = grass_regrowth_time
             else:
                 countdown = self.random.randrange(grass_regrowth_time)
-            patch = GrassPatch(
-                self.next_id(), self, fully_grown, countdown, grass_regrowth_time
-            )
+            patch = GrassPatch(self, fully_grown, countdown, grass_regrowth_time)
             self.grid.place_agent(patch, pos)
 
     def step(self):
diff --git a/mesa/model.py b/mesa/model.py
index f276cc82..f859aea3 100644
--- a/mesa/model.py
+++ b/mesa/model.py
@@ -28,7 +28,6 @@ class Model:
     Attributes:
         running: A boolean indicating if the model should continue running.
         schedule: An object to manage the order and execution of agent steps.
-        current_id: A counter for assigning unique IDs to agents.
 
     Properties:
         agents: An AgentSet containing all agents in the model
@@ -74,7 +73,6 @@ class Model:
 
         self.running = True
         self.schedule = None
-        self.current_id = 0
         self.steps: int = 0
 
         self._setup_agent_registration()
@@ -90,6 +88,14 @@ class Model:
         # Call the original user-defined step method
         self._user_step(*args, **kwargs)
 
+    def next_id(self) -> int:
+        warnings.warn(
+            "using model.next_id() is deprecated. Agents track their unique ID automatically",
+            DeprecationWarning,
+            stacklevel=2,
+        )
+        return 0
+
     @property
     def agents(self) -> AgentSet:
         """Provides an AgentSet of all agents in the model, combining agents from all types."""
@@ -189,11 +195,6 @@ class Model:
     def step(self) -> None:
         """A single step. Fill in here."""
 
-    def next_id(self) -> int:
-        """Return the next unique ID for agents, increment current_id"""
-        self.current_id += 1
-        return self.current_id
-
     def reset_randomizer(self, seed: int | None = None) -> None:
         """Reset the model random number generator.
 
diff --git a/mesa/visualization/UserParam.py b/mesa/visualization/UserParam.py
new file mode 100644
index 00000000..5b342471
--- /dev/null
+++ b/mesa/visualization/UserParam.py
@@ -0,0 +1,56 @@
+class UserParam:
+    _ERROR_MESSAGE = "Missing or malformed inputs for '{}' Option '{}'"
+
+    def maybe_raise_error(self, param_type, valid):
+        if valid:
+            return
+        msg = self._ERROR_MESSAGE.format(param_type, self.label)
+        raise ValueError(msg)
+
+
+class Slider(UserParam):
+    """
+    A number-based slider input with settable increment.
+
+    Example:
+
+    slider_option = Slider("My Slider", value=123, min=10, max=200, step=0.1)
+
+    Args:
+        label: The displayed label in the UI
+        value: The initial value of the slider
+        min: The minimum possible value of the slider
+        max: The maximum possible value of the slider
+        step: The step between min and max for a range of possible values
+        dtype: either int or float
+    """
+
+    def __init__(
+        self,
+        label="",
+        value=None,
+        min=None,
+        max=None,
+        step=1,
+        dtype=None,
+    ):
+        self.label = label
+        self.value = value
+        self.min = min
+        self.max = max
+        self.step = step
+
+        # Validate option type to make sure values are supplied properly
+        valid = not (self.value is None or self.min is None or self.max is None)
+        self.maybe_raise_error("slider", valid)
+
+        if dtype is None:
+            self.is_float_slider = self._check_values_are_float(value, min, max, step)
+        else:
+            self.is_float_slider = dtype is float
+
+    def _check_values_are_float(self, value, min, max, step):
+        return any(isinstance(n, float) for n in (value, min, max, step))
+
+    def get(self, attr):
+        return getattr(self, attr)
diff --git a/mesa/visualization/__init__.py b/mesa/visualization/__init__.py
index e69de29b..d8a0ebec 100644
--- a/mesa/visualization/__init__.py
+++ b/mesa/visualization/__init__.py
@@ -0,0 +1,14 @@
+from .components.altair import make_space_altair
+from .components.matplotlib import make_plot_measure, make_space_matplotlib
+from .solara_viz import JupyterViz, SolaraViz, make_text
+from .UserParam import Slider
+
+__all__ = [
+    "JupyterViz",
+    "SolaraViz",
+    "make_text",
+    "Slider",
+    "make_space_altair",
+    "make_space_matplotlib",
+    "make_plot_measure",
+]
diff --git a/mesa/visualization/components/altair.py b/mesa/visualization/components/altair.py
new file mode 100644
index 00000000..1d23b170
--- /dev/null
+++ b/mesa/visualization/components/altair.py
@@ -0,0 +1,86 @@
+import contextlib
+
+import solara
+
+with contextlib.suppress(ImportError):
+    import altair as alt
+
+from mesa.visualization.utils import update_counter
+
+
+def make_space_altair(agent_portrayal=None):
+    if agent_portrayal is None:
+
+        def agent_portrayal(a):
+            return {"id": a.unique_id}
+
+    def MakeSpaceAltair(model):
+        return SpaceAltair(model, agent_portrayal)
+
+    return MakeSpaceAltair
+
+
+@solara.component
+def SpaceAltair(model, agent_portrayal, dependencies: list[any] | None = None):
+    update_counter.get()
+    space = getattr(model, "grid", None)
+    if space is None:
+        # Sometimes the space is defined as model.space instead of model.grid
+        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/visualization/components/matplotlib.py b/mesa/visualization/components/matplotlib.py
new file mode 100644
index 00000000..b1c61581
--- /dev/null
+++ b/mesa/visualization/components/matplotlib.py
@@ -0,0 +1,246 @@
+from collections import defaultdict
+
+import networkx as nx
+import solara
+from matplotlib.figure import Figure
+from matplotlib.ticker import MaxNLocator
+
+import mesa
+from mesa.experimental.cell_space import VoronoiGrid
+from mesa.visualization.utils import update_counter
+
+
+def make_space_matplotlib(agent_portrayal=None):
+    if agent_portrayal is None:
+
+        def agent_portrayal(a):
+            return {"id": a.unique_id}
+
+    def MakeSpaceMatplotlib(model):
+        return SpaceMatplotlib(model, agent_portrayal)
+
+    return MakeSpaceMatplotlib
+
+
+@solara.component
+def SpaceMatplotlib(model, agent_portrayal, dependencies: list[any] | None = None):
+    update_counter.get()
+    space_fig = Figure()
+    space_ax = space_fig.subplots()
+    space = getattr(model, "grid", None)
+    if space is None:
+        # Sometimes the space is defined as model.space instead of model.grid
+        space = model.space
+    if isinstance(space, mesa.space.NetworkGrid):
+        _draw_network_grid(space, space_ax, agent_portrayal)
+    elif isinstance(space, mesa.space.ContinuousSpace):
+        _draw_continuous_space(space, space_ax, agent_portrayal)
+    elif isinstance(space, VoronoiGrid):
+        _draw_voronoi(space, space_ax, agent_portrayal)
+    else:
+        _draw_grid(space, space_ax, agent_portrayal)
+    solara.FigureMatplotlib(space_fig, format="png", dependencies=dependencies)
+
+
+# matplotlib scatter does not allow for multiple shapes in one call
+def _split_and_scatter(portray_data, space_ax):
+    grouped_data = defaultdict(lambda: {"x": [], "y": [], "s": [], "c": []})
+
+    # Extract data from the dictionary
+    x = portray_data["x"]
+    y = portray_data["y"]
+    s = portray_data["s"]
+    c = portray_data["c"]
+    m = portray_data["m"]
+
+    if not (len(x) == len(y) == len(s) == len(c) == len(m)):
+        raise ValueError(
+            "Length mismatch in portrayal data lists: "
+            f"x: {len(x)}, y: {len(y)}, size: {len(s)}, "
+            f"color: {len(c)}, marker: {len(m)}"
+        )
+
+    # Group the data by marker
+    for i in range(len(x)):
+        marker = m[i]
+        grouped_data[marker]["x"].append(x[i])
+        grouped_data[marker]["y"].append(y[i])
+        grouped_data[marker]["s"].append(s[i])
+        grouped_data[marker]["c"].append(c[i])
+
+    # Plot each group with the same marker
+    for marker, data in grouped_data.items():
+        space_ax.scatter(data["x"], data["y"], s=data["s"], c=data["c"], marker=marker)
+
+
+def _draw_grid(space, space_ax, agent_portrayal):
+    def portray(g):
+        x = []
+        y = []
+        s = []  # size
+        c = []  # color
+        m = []  # shape
+        for i in range(g.width):
+            for j in range(g.height):
+                content = g._grid[i][j]
+                if not content:
+                    continue
+                if not hasattr(content, "__iter__"):
+                    # Is a single grid
+                    content = [content]
+                for agent in content:
+                    data = agent_portrayal(agent)
+                    x.append(i)
+                    y.append(j)
+
+                    # This is the default value for the marker size, which auto-scales
+                    # according to the grid area.
+                    default_size = (180 / max(g.width, g.height)) ** 2
+                    # establishing a default prevents misalignment if some agents are not given size, color, etc.
+                    size = data.get("size", default_size)
+                    s.append(size)
+                    color = data.get("color", "tab:blue")
+                    c.append(color)
+                    mark = data.get("shape", "o")
+                    m.append(mark)
+        out = {"x": x, "y": y, "s": s, "c": c, "m": m}
+        return out
+
+    space_ax.set_xlim(-1, space.width)
+    space_ax.set_ylim(-1, space.height)
+    _split_and_scatter(portray(space), space_ax)
+
+
+def _draw_network_grid(space, space_ax, agent_portrayal):
+    graph = space.G
+    pos = nx.spring_layout(graph, seed=0)
+    nx.draw(
+        graph,
+        ax=space_ax,
+        pos=pos,
+        **agent_portrayal(graph),
+    )
+
+
+def _draw_continuous_space(space, space_ax, agent_portrayal):
+    def portray(space):
+        x = []
+        y = []
+        s = []  # size
+        c = []  # color
+        m = []  # shape
+        for agent in space._agent_to_index:
+            data = agent_portrayal(agent)
+            _x, _y = agent.pos
+            x.append(_x)
+            y.append(_y)
+
+            # This is matplotlib's default marker size
+            default_size = 20
+            # establishing a default prevents misalignment if some agents are not given size, color, etc.
+            size = data.get("size", default_size)
+            s.append(size)
+            color = data.get("color", "tab:blue")
+            c.append(color)
+            mark = data.get("shape", "o")
+            m.append(mark)
+        out = {"x": x, "y": y, "s": s, "c": c, "m": m}
+        return out
+
+    # Determine border style based on space.torus
+    border_style = "solid" if not space.torus else (0, (5, 10))
+
+    # Set the border of the plot
+    for spine in space_ax.spines.values():
+        spine.set_linewidth(1.5)
+        spine.set_color("black")
+        spine.set_linestyle(border_style)
+
+    width = space.x_max - space.x_min
+    x_padding = width / 20
+    height = space.y_max - space.y_min
+    y_padding = height / 20
+    space_ax.set_xlim(space.x_min - x_padding, space.x_max + x_padding)
+    space_ax.set_ylim(space.y_min - y_padding, space.y_max + y_padding)
+
+    # Portray and scatter the agents in the space
+    _split_and_scatter(portray(space), space_ax)
+
+
+def _draw_voronoi(space, space_ax, agent_portrayal):
+    def portray(g):
+        x = []
+        y = []
+        s = []  # size
+        c = []  # color
+
+        for cell in g.all_cells:
+            for agent in cell.agents:
+                data = agent_portrayal(agent)
+                x.append(cell.coordinate[0])
+                y.append(cell.coordinate[1])
+                if "size" in data:
+                    s.append(data["size"])
+                if "color" in data:
+                    c.append(data["color"])
+        out = {"x": x, "y": y}
+        # This is the default value for the marker size, which auto-scales
+        # according to the grid area.
+        out["s"] = s
+        if len(c) > 0:
+            out["c"] = c
+
+        return out
+
+    x_list = [i[0] for i in space.centroids_coordinates]
+    y_list = [i[1] for i in space.centroids_coordinates]
+    x_max = max(x_list)
+    x_min = min(x_list)
+    y_max = max(y_list)
+    y_min = min(y_list)
+
+    width = x_max - x_min
+    x_padding = width / 20
+    height = y_max - y_min
+    y_padding = height / 20
+    space_ax.set_xlim(x_min - x_padding, x_max + x_padding)
+    space_ax.set_ylim(y_min - y_padding, y_max + y_padding)
+    space_ax.scatter(**portray(space))
+
+    for cell in space.all_cells:
+        polygon = cell.properties["polygon"]
+        space_ax.fill(
+            *zip(*polygon),
+            alpha=min(1, cell.properties[space.cell_coloring_property]),
+            c="red",
+        )  # Plot filled polygon
+        space_ax.plot(*zip(*polygon), color="black")  # Plot polygon edges in red
+
+
+def make_plot_measure(measure: str | dict[str, str] | list[str] | tuple[str]):
+    def MakePlotMeasure(model):
+        return PlotMatplotlib(model, measure)
+
+    return MakePlotMeasure
+
+
+@solara.component
+def PlotMatplotlib(model, measure, dependencies: list[any] | None = None):
+    update_counter.get()
+    fig = Figure()
+    ax = fig.subplots()
+    df = model.datacollector.get_model_vars_dataframe()
+    if isinstance(measure, str):
+        ax.plot(df.loc[:, measure])
+        ax.set_ylabel(measure)
+    elif isinstance(measure, dict):
+        for m, color in measure.items():
+            ax.plot(df.loc[:, m], label=m, color=color)
+        fig.legend()
+    elif isinstance(measure, list | tuple):
+        for m in measure:
+            ax.plot(df.loc[:, m], label=m)
+        fig.legend()
+    # Set integer x axis
+    ax.xaxis.set_major_locator(MaxNLocator(integer=True))
+    solara.FigureMatplotlib(fig, dependencies=dependencies)
diff --git a/mesa/visualization/solara_viz.py b/mesa/visualization/solara_viz.py
new file mode 100644
index 00000000..0d91adfb
--- /dev/null
+++ b/mesa/visualization/solara_viz.py
@@ -0,0 +1,452 @@
+"""
+Mesa visualization module for creating interactive model visualizations.
+
+This module provides components to create browser- and Jupyter notebook-based visualizations of
+Mesa models, allowing users to watch models run step-by-step and interact with model parameters.
+
+Key features:
+    - SolaraViz: Main component for creating visualizations, supporting grid displays and plots
+    - ModelController: Handles model execution controls (step, play, pause, reset)
+    - UserInputs: Generates UI elements for adjusting model parameters
+    - Card: Renders individual visualization elements (space, measures)
+
+The module uses Solara for rendering in Jupyter notebooks or as standalone web applications.
+It supports various types of visualizations including matplotlib plots, agent grids, and
+custom visualization components.
+
+Usage:
+    1. Define an agent_portrayal function to specify how agents should be displayed
+    2. Set up model_params to define adjustable parameters
+    3. Create a SolaraViz instance with your model, parameters, and desired measures
+    4. Display the visualization in a Jupyter notebook or run as a Solara app
+
+See the Visualization Tutorial and example models for more details.
+"""
+
+import copy
+import threading
+from typing import TYPE_CHECKING
+
+import reacton.ipywidgets as widgets
+import solara
+from solara.alias import rv
+
+import mesa.visualization.components.altair as components_altair
+import mesa.visualization.components.matplotlib as components_matplotlib
+from mesa.visualization.UserParam import Slider
+from mesa.visualization.utils import force_update, update_counter
+
+if TYPE_CHECKING:
+    from mesa.model import Model
+
+
+# TODO: Turn this function into a Solara component once the current_step.value
+# dependency is passed to measure()
+def Card(
+    model, measures, agent_portrayal, space_drawer, dependencies, color, layout_type
+):
+    """
+    Create a card component for visualizing model space or measures.
+
+    Args:
+        model: The Mesa model instance
+        measures: List of measures to be plotted
+        agent_portrayal: Function to define agent appearance
+        space_drawer: Method to render agent space
+        dependencies: List of dependencies for updating the visualization
+        color: Background color of the card
+        layout_type: Type of layout (Space or Measure)
+
+    Returns:
+        rv.Card: A card component containing the visualization
+    """
+    with rv.Card(
+        style_=f"background-color: {color}; width: 100%; height: 100%"
+    ) as main:
+        if "Space" in layout_type:
+            rv.CardTitle(children=["Space"])
+            if space_drawer == "default":
+                # draw with the default implementation
+                components_matplotlib.SpaceMatplotlib(
+                    model, agent_portrayal, dependencies=dependencies
+                )
+            elif space_drawer == "altair":
+                components_altair.SpaceAltair(
+                    model, agent_portrayal, dependencies=dependencies
+                )
+            elif space_drawer:
+                # if specified, draw agent space with an alternate renderer
+                space_drawer(model, agent_portrayal, dependencies=dependencies)
+        elif "Measure" in layout_type:
+            rv.CardTitle(children=["Measure"])
+            measure = measures[layout_type["Measure"]]
+            if callable(measure):
+                # Is a custom object
+                measure(model)
+            else:
+                components_matplotlib.PlotMatplotlib(
+                    model, measure, dependencies=dependencies
+                )
+    return main
+
+
+@solara.component
+def SolaraViz(
+    model: "Model" | solara.Reactive["Model"],
+    components: list[solara.component] | None = None,
+    *args,
+    play_interval=150,
+    model_params=None,
+    seed=0,
+    name: str | None = None,
+):
+    if components is None:
+        components = []
+
+    # Convert model to reactive
+    if not isinstance(model, solara.Reactive):
+        model = solara.use_reactive(model)
+
+    def connect_to_model():
+        # Patch the step function to force updates
+        original_step = model.value.step
+
+        def step():
+            original_step()
+            force_update()
+
+        model.value.step = step
+        # Add a trigger to model itself
+        model.value.force_update = force_update
+        force_update()
+
+    solara.use_effect(connect_to_model, [model.value])
+
+    with solara.AppBar():
+        solara.AppBarTitle(name if name else model.value.__class__.__name__)
+
+    with solara.Sidebar():
+        with solara.Card("Controls", margin=1, elevation=2):
+            if model_params is not None:
+                ModelCreator(
+                    model,
+                    model_params,
+                    seed=seed,
+                )
+            ModelController(model, play_interval)
+        with solara.Card("Information", margin=1, elevation=2):
+            ShowSteps(model.value)
+
+    solara.Column(
+        [
+            *(component(model.value) for component in components),
+        ]
+    )
+
+
+JupyterViz = SolaraViz
+
+
+@solara.component
+def ModelController(model: solara.Reactive["Model"], play_interval):
+    """
+    Create controls for model execution (step, play, pause, reset).
+
+    Args:
+        model: The model being visualized
+        play_interval: Interval between steps during play
+        current_step: Reactive value for the current step
+        reset_counter: Counter to trigger model reset
+    """
+    playing = solara.use_reactive(False)
+    thread = solara.use_reactive(None)
+    # We track the previous step to detect if user resets the model via
+    # clicking the reset button or changing the parameters. If previous_step >
+    # current_step, it means a model reset happens while the simulation is
+    # still playing.
+    previous_step = solara.use_reactive(0)
+    original_model = solara.use_reactive(None)
+
+    def save_initial_model():
+        """Save the initial model for comparison."""
+        original_model.set(copy.deepcopy(model.value))
+
+    solara.use_effect(save_initial_model, [model.value])
+
+    def on_value_play(change):
+        """Handle play/pause state changes."""
+        if previous_step.value > model.value.steps and model.value.steps == 0:
+            # We add extra checks for model.value.steps == 0, just to be sure.
+            # We automatically stop the playing if a model is reset.
+            playing.value = False
+        elif model.value.running:
+            do_step()
+        else:
+            playing.value = False
+
+    def do_step():
+        """Advance the model by one step."""
+        previous_step.value = model.value.steps
+        model.value.step()
+
+    def do_play():
+        """Run the model continuously."""
+        model.value.running = True
+        while model.value.running:
+            do_step()
+
+    def threaded_do_play():
+        """Start a new thread for continuous model execution."""
+        if thread is not None and thread.is_alive():
+            return
+        thread.value = threading.Thread(target=do_play)
+        thread.start()
+
+    def do_pause():
+        """Pause the model execution."""
+        if (thread is None) or (not thread.is_alive()):
+            return
+        model.value.running = False
+        thread.join()
+
+    def do_reset():
+        """Reset the model"""
+        model.value = copy.deepcopy(original_model.value)
+        previous_step.value = 0
+        force_update()
+
+    def do_set_playing(value):
+        """Set the playing state."""
+        if model.value.steps == 0:
+            # This means the model has been recreated, and the step resets to
+            # 0. We want to avoid triggering the playing.value = False in the
+            # on_value_play function.
+            previous_step.value = model.value.steps
+        playing.set(value)
+
+    with solara.Row():
+        solara.Button(label="Step", color="primary", on_click=do_step)
+        # This style is necessary so that the play widget has almost the same
+        # height as typical Solara buttons.
+        solara.Style(
+            """
+        .widget-play {
+            height: 35px;
+        }
+        .widget-play button {
+            color: white;
+            background-color: #1976D2;  // Solara blue color
+        }
+        """
+        )
+        widgets.Play(
+            value=0,
+            interval=play_interval,
+            repeat=True,
+            show_repeat=False,
+            on_value=on_value_play,
+            playing=playing.value,
+            on_playing=do_set_playing,
+        )
+        solara.Button(label="Reset", color="primary", on_click=do_reset)
+        # threaded_do_play is not used for now because it
+        # doesn't work in Google colab. We use
+        # ipywidgets.Play until it is fixed. The threading
+        # version is definite a much better implementation,
+        # if it works.
+        # solara.Button(label="▶", color="primary", on_click=viz.threaded_do_play)
+        # solara.Button(label="⏸︎", color="primary", on_click=viz.do_pause)
+        # solara.Button(label="Reset", color="primary", on_click=do_reset)
+
+
+def split_model_params(model_params):
+    """
+    Split model parameters into user-adjustable and fixed parameters.
+
+    Args:
+        model_params: Dictionary of all model parameters
+
+    Returns:
+        tuple: (user_adjustable_params, fixed_params)
+    """
+    model_params_input = {}
+    model_params_fixed = {}
+    for k, v in model_params.items():
+        if check_param_is_fixed(v):
+            model_params_fixed[k] = v
+        else:
+            model_params_input[k] = v
+    return model_params_input, model_params_fixed
+
+
+def check_param_is_fixed(param):
+    """
+    Check if a parameter is fixed (not user-adjustable).
+
+    Args:
+        param: Parameter to check
+
+    Returns:
+        bool: True if parameter is fixed, False otherwise
+    """
+    if isinstance(param, Slider):
+        return False
+    if not isinstance(param, dict):
+        return True
+    if "type" not in param:
+        return True
+
+
+@solara.component
+def ModelCreator(model, model_params, seed=1):
+    user_params, fixed_params = split_model_params(model_params)
+
+    reactive_seed = solara.use_reactive(seed)
+
+    model_parameters, set_model_parameters = solara.use_state(
+        {
+            **fixed_params,
+            **{k: v.get("value") for k, v in user_params.items()},
+        }
+    )
+
+    def do_reseed():
+        """Update the random seed for the model."""
+        reactive_seed.value = model.value.random.random()
+
+    def on_change(name, value):
+        set_model_parameters({**model_parameters, name: value})
+
+    def create_model():
+        model.value = model.value.__class__.__new__(
+            model.value.__class__, **model_parameters, seed=reactive_seed.value
+        )
+        model.value.__init__(**model_parameters)
+
+    solara.use_effect(create_model, [model_parameters, reactive_seed.value])
+
+    solara.InputText(
+        label="Seed",
+        value=reactive_seed,
+        continuous_update=True,
+    )
+
+    solara.Button(label="Reseed", color="primary", on_click=do_reseed)
+
+    UserInputs(user_params, on_change=on_change)
+
+
+@solara.component
+def UserInputs(user_params, on_change=None):
+    """
+    Initialize user inputs for configurable model parameters.
+    Currently supports :class:`solara.SliderInt`, :class:`solara.SliderFloat`,
+    :class:`solara.Select`, and :class:`solara.Checkbox`.
+
+    Args:
+        user_params: Dictionary with options for the input, including label,
+        min and max values, and other fields specific to the input type.
+        on_change: Function to be called with (name, value) when the value of an input changes.
+    """
+
+    for name, options in user_params.items():
+
+        def change_handler(value, name=name):
+            on_change(name, value)
+
+        if isinstance(options, Slider):
+            slider_class = (
+                solara.SliderFloat if options.is_float_slider else solara.SliderInt
+            )
+            slider_class(
+                options.label,
+                value=options.value,
+                on_value=change_handler,
+                min=options.min,
+                max=options.max,
+                step=options.step,
+            )
+            continue
+
+        # label for the input is "label" from options or name
+        label = options.get("label", name)
+        input_type = options.get("type")
+        if input_type == "SliderInt":
+            solara.SliderInt(
+                label,
+                value=options.get("value"),
+                on_value=change_handler,
+                min=options.get("min"),
+                max=options.get("max"),
+                step=options.get("step"),
+            )
+        elif input_type == "SliderFloat":
+            solara.SliderFloat(
+                label,
+                value=options.get("value"),
+                on_value=change_handler,
+                min=options.get("min"),
+                max=options.get("max"),
+                step=options.get("step"),
+            )
+        elif input_type == "Select":
+            solara.Select(
+                label,
+                value=options.get("value"),
+                on_value=change_handler,
+                values=options.get("values"),
+            )
+        elif input_type == "Checkbox":
+            solara.Checkbox(
+                label=label,
+                on_value=change_handler,
+                value=options.get("value"),
+            )
+        else:
+            raise ValueError(f"{input_type} is not a supported input type")
+
+
+def make_text(renderer):
+    """
+    Create a function that renders text using Markdown.
+
+    Args:
+        renderer: Function that takes a model and returns a string
+
+    Returns:
+        function: A function that renders the text as Markdown
+    """
+
+    def function(model):
+        solara.Markdown(renderer(model))
+
+    return function
+
+
+def make_initial_grid_layout(layout_types):
+    """
+    Create an initial grid layout for visualization components.
+
+    Args:
+        layout_types: List of layout types (Space or Measure)
+
+    Returns:
+        list: Initial grid layout configuration
+    """
+    return [
+        {
+            "i": i,
+            "w": 6,
+            "h": 10,
+            "moved": False,
+            "x": 6 * (i % 2),
+            "y": 16 * (i - i % 2),
+        }
+        for i in range(len(layout_types))
+    ]
+
+
+@solara.component
+def ShowSteps(model):
+    update_counter.get()
+    return solara.Text(f"Step: {model.steps}")
diff --git a/mesa/visualization/utils.py b/mesa/visualization/utils.py
new file mode 100644
index 00000000..c49b35e3
--- /dev/null
+++ b/mesa/visualization/utils.py
@@ -0,0 +1,7 @@
+import solara
+
+update_counter = solara.reactive(0)
+
+
+def force_update():
+    update_counter.value += 1


Test patch:
diff --git a/tests/test_agent.py b/tests/test_agent.py
index bb61ae1c..e78a4dce 100644
--- a/tests/test_agent.py
+++ b/tests/test_agent.py
@@ -15,17 +15,16 @@ class TestAgent(Agent):
 class TestAgentDo(Agent):
     def __init__(
         self,
-        unique_id,
         model,
     ):
-        super().__init__(unique_id, model)
+        super().__init__(model)
         self.agent_set = None
 
     def get_unique_identifier(self):
         return self.unique_id
 
     def do_add(self):
-        agent = TestAgentDo(self.model.next_id(), self.model)
+        agent = TestAgentDo(self.model)
         self.agent_set.add(agent)
 
     def do_remove(self):
@@ -34,7 +33,7 @@ class TestAgentDo(Agent):
 
 def test_agent_removal():
     model = Model()
-    agent = TestAgent(model.next_id(), model)
+    agent = TestAgent(model)
     # Check if the agent is added
     assert agent in model.agents
 
@@ -46,7 +45,7 @@ def test_agent_removal():
 def test_agentset():
     # create agentset
     model = Model()
-    agents = [TestAgent(model.next_id(), model) for _ in range(10)]
+    agents = [TestAgent(model) for _ in range(10)]
 
     agentset = AgentSet(agents, model)
 
@@ -117,14 +116,14 @@ def test_agentset_initialization():
     empty_agentset = AgentSet([], model)
     assert len(empty_agentset) == 0
 
-    agents = [TestAgent(model.next_id(), model) for _ in range(10)]
+    agents = [TestAgent(model) for _ in range(10)]
     agentset = AgentSet(agents, model)
     assert len(agentset) == 10
 
 
 def test_agentset_serialization():
     model = Model()
-    agents = [TestAgent(model.next_id(), model) for _ in range(5)]
+    agents = [TestAgent(model) for _ in range(5)]
     agentset = AgentSet(agents, model)
 
     serialized = pickle.dumps(agentset)
@@ -138,16 +137,16 @@ def test_agentset_serialization():
 
 def test_agent_membership():
     model = Model()
-    agents = [TestAgent(model.next_id(), model) for _ in range(5)]
+    agents = [TestAgent(model) for _ in range(5)]
     agentset = AgentSet(agents, model)
 
     assert agents[0] in agentset
-    assert TestAgent(model.next_id(), model) not in agentset
+    assert TestAgent(model) not in agentset
 
 
 def test_agent_add_remove_discard():
     model = Model()
-    agent = TestAgent(model.next_id(), model)
+    agent = TestAgent(model)
     agentset = AgentSet([], model)
 
     agentset.add(agent)
@@ -166,7 +165,7 @@ def test_agent_add_remove_discard():
 
 def test_agentset_get_item():
     model = Model()
-    agents = [TestAgent(model.next_id(), model) for _ in range(10)]
+    agents = [TestAgent(model) for _ in range(10)]
     agentset = AgentSet(agents, model)
 
     assert agentset[0] == agents[0]
@@ -179,7 +178,7 @@ def test_agentset_get_item():
 
 def test_agentset_do_str():
     model = Model()
-    agents = [TestAgent(model.next_id(), model) for _ in range(10)]
+    agents = [TestAgent(model) for _ in range(10)]
     agentset = AgentSet(agents, model)
 
     with pytest.raises(AttributeError):
@@ -192,7 +191,7 @@ def test_agentset_do_str():
     # setup
     n = 10
     model = Model()
-    agents = [TestAgentDo(model.next_id(), model) for _ in range(n)]
+    agents = [TestAgentDo(model) for _ in range(n)]
     agentset = AgentSet(agents, model)
     for agent in agents:
         agent.agent_set = agentset
@@ -202,7 +201,7 @@ def test_agentset_do_str():
 
     # setup
     model = Model()
-    agents = [TestAgentDo(model.next_id(), model) for _ in range(10)]
+    agents = [TestAgentDo(model) for _ in range(10)]
     agentset = AgentSet(agents, model)
     for agent in agents:
         agent.agent_set = agentset
@@ -213,7 +212,7 @@ def test_agentset_do_str():
 
 def test_agentset_do_callable():
     model = Model()
-    agents = [TestAgent(model.next_id(), model) for _ in range(10)]
+    agents = [TestAgent(model) for _ in range(10)]
     agentset = AgentSet(agents, model)
 
     # Test callable with non-existent function
@@ -227,7 +226,7 @@ def test_agentset_do_callable():
     # setup for lambda function tests
     n = 10
     model = Model()
-    agents = [TestAgentDo(model.next_id(), model) for _ in range(n)]
+    agents = [TestAgentDo(model) for _ in range(n)]
     agentset = AgentSet(agents, model)
     for agent in agents:
         agent.agent_set = agentset
@@ -238,7 +237,7 @@ def test_agentset_do_callable():
 
     # setup again for lambda function tests
     model = Model()
-    agents = [TestAgentDo(model.next_id(), model) for _ in range(10)]
+    agents = [TestAgentDo(model) for _ in range(10)]
     agentset = AgentSet(agents, model)
     for agent in agents:
         agent.agent_set = agentset
@@ -256,7 +255,7 @@ def test_agentset_do_callable():
 
     # setup again for actual function tests
     model = Model()
-    agents = [TestAgentDo(model.next_id(), model) for _ in range(n)]
+    agents = [TestAgentDo(model) for _ in range(n)]
     agentset = AgentSet(agents, model)
     for agent in agents:
         agent.agent_set = agentset
@@ -267,7 +266,7 @@ def test_agentset_do_callable():
 
     # setup again for actual function tests
     model = Model()
-    agents = [TestAgentDo(model.next_id(), model) for _ in range(10)]
+    agents = [TestAgentDo(model) for _ in range(10)]
     agentset = AgentSet(agents, model)
     for agent in agents:
         agent.agent_set = agentset
@@ -279,7 +278,7 @@ def test_agentset_do_callable():
 
 def test_agentset_agg():
     model = Model()
-    agents = [TestAgent(i, model) for i in range(10)]
+    agents = [TestAgent(model) for i in range(10)]
 
     # Assign some values to attributes
     for i, agent in enumerate(agents):
@@ -315,12 +314,12 @@ def test_agentset_agg():
 def test_agentset_set_method():
     # Initialize the model and agents with and without existing attributes
     class TestAgentWithAttribute(Agent):
-        def __init__(self, unique_id, model, age=None):
-            super().__init__(unique_id, model)
+        def __init__(self, model, age=None):
+            super().__init__(model)
             self.age = age
 
     model = Model()
-    agents = [TestAgentWithAttribute(model.next_id(), model, age=i) for i in range(5)]
+    agents = [TestAgentWithAttribute(model, age=i) for i in range(5)]
     agentset = AgentSet(agents, model)
 
     # Set a new attribute "health" and an existing attribute "age" for all agents
@@ -338,7 +337,7 @@ def test_agentset_set_method():
 
 def test_agentset_map_str():
     model = Model()
-    agents = [TestAgent(model.next_id(), model) for _ in range(10)]
+    agents = [TestAgent(model) for _ in range(10)]
     agentset = AgentSet(agents, model)
 
     with pytest.raises(AttributeError):
@@ -350,7 +349,7 @@ def test_agentset_map_str():
 
 def test_agentset_map_callable():
     model = Model()
-    agents = [TestAgent(model.next_id(), model) for _ in range(10)]
+    agents = [TestAgent(model) for _ in range(10)]
     agentset = AgentSet(agents, model)
 
     # Test callable with non-existent function
@@ -367,7 +366,7 @@ def test_agentset_map_callable():
 
 def test_agentset_get_attribute():
     model = Model()
-    agents = [TestAgent(model.next_id(), model) for _ in range(10)]
+    agents = [TestAgent(model) for _ in range(10)]
     agentset = AgentSet(agents, model)
 
     unique_ids = agentset.get("unique_id")
@@ -379,7 +378,7 @@ def test_agentset_get_attribute():
     model = Model()
     agents = []
     for i in range(10):
-        agent = TestAgent(model.next_id(), model)
+        agent = TestAgent(model)
         agent.i = i**2
         agents.append(agent)
     agentset = AgentSet(agents, model)
@@ -403,8 +402,8 @@ class OtherAgentType(Agent):
 def test_agentset_select_by_type():
     model = Model()
     # Create a mix of agents of two different types
-    test_agents = [TestAgent(model.next_id(), model) for _ in range(4)]
-    other_agents = [OtherAgentType(model.next_id(), model) for _ in range(6)]
+    test_agents = [TestAgent(model) for _ in range(4)]
+    other_agents = [OtherAgentType(model) for _ in range(6)]
 
     # Combine the two types of agents
     mixed_agents = test_agents + other_agents
@@ -428,7 +427,7 @@ def test_agentset_select_by_type():
 
 def test_agentset_shuffle():
     model = Model()
-    test_agents = [TestAgent(model.next_id(), model) for _ in range(12)]
+    test_agents = [TestAgent(model) for _ in range(12)]
 
     agentset = AgentSet(test_agents, model=model)
     agentset = agentset.shuffle()
@@ -441,15 +440,15 @@ def test_agentset_shuffle():
 
 def test_agentset_groupby():
     class TestAgent(Agent):
-        def __init__(self, unique_id, model):
-            super().__init__(unique_id, model)
+        def __init__(self, model):
+            super().__init__(model)
             self.even = self.unique_id % 2 == 0
 
         def get_unique_identifier(self):
             return self.unique_id
 
     model = Model()
-    agents = [TestAgent(model.next_id(), model) for _ in range(10)]
+    agents = [TestAgent(model) for _ in range(10)]
     agentset = AgentSet(agents, model)
 
     groups = agentset.groupby("even")
@@ -479,3 +478,14 @@ def test_agentset_groupby():
     groups = agentset.groupby("even", result_type="agentset")
     another_ref_to_groups = groups.do(lambda x: x.do("step"))
     assert groups == another_ref_to_groups
+
+
+def test_oldstyle_agent_instantiation():
+    """Old behavior of Agent creation with unique_id and model as positional arguments.
+    Can be removed/updated in the future."""
+    model = Model()
+    agent = Agent("some weird unique id", model)
+    assert isinstance(agent.unique_id, int)
+    assert agent.model == model
+    assert isinstance(agent.model, Model)
+    assert agent.unique_id == 1  # test that we ignore unique ID that is passed
diff --git a/tests/test_batch_run.py b/tests/test_batch_run.py
index 2de7c447..784c0416 100644
--- a/tests/test_batch_run.py
+++ b/tests/test_batch_run.py
@@ -28,9 +28,8 @@ class MockAgent(Agent):
     Minimalistic agent implementation for testing purposes
     """
 
-    def __init__(self, unique_id, model, val):
-        super().__init__(unique_id, model)
-        self.unique_id = unique_id
+    def __init__(self, model, val):
+        super().__init__(model)
         self.val = val
         self.local = 0
 
@@ -76,8 +75,8 @@ class MockModel(Model):
             agent_val = 1
         else:
             agent_val = self.variable_agent_param
-        for i in range(self.n_agents):
-            self.schedule.add(MockAgent(i, self, agent_val))
+        for _ in range(self.n_agents):
+            self.schedule.add(MockAgent(self, agent_val))
 
     def get_local_model_param(self):
         return 42
@@ -95,8 +94,8 @@ def test_batch_run():
             "iteration": 0,
             "Step": 1000,
             "reported_model_param": 42,
-            "AgentID": 0,
-            "agent_id": 0,
+            "AgentID": 1,
+            "agent_id": 1,
             "agent_local": 250.0,
         },
         {
@@ -104,8 +103,8 @@ def test_batch_run():
             "iteration": 0,
             "Step": 1000,
             "reported_model_param": 42,
-            "AgentID": 1,
-            "agent_id": 1,
+            "AgentID": 2,
+            "agent_id": 2,
             "agent_local": 250.0,
         },
         {
@@ -113,8 +112,8 @@ def test_batch_run():
             "iteration": 0,
             "Step": 1000,
             "reported_model_param": 42,
-            "AgentID": 2,
-            "agent_id": 2,
+            "AgentID": 3,
+            "agent_id": 3,
             "agent_local": 250.0,
         },
     ]
@@ -172,29 +171,29 @@ def test_batch_run_unhashable_param():
         {
             "RunId": 0,
             "iteration": 0,
-            "AgentID": 0,
-            "agent_id": 0,
+            "AgentID": 1,
+            "agent_id": 1,
             **template,
         },
         {
             "RunId": 0,
             "iteration": 0,
-            "AgentID": 1,
-            "agent_id": 1,
+            "AgentID": 2,
+            "agent_id": 2,
             **template,
         },
         {
             "RunId": 1,
             "iteration": 1,
-            "AgentID": 0,
-            "agent_id": 0,
+            "AgentID": 1,
+            "agent_id": 1,
             **template,
         },
         {
             "RunId": 1,
             "iteration": 1,
-            "AgentID": 1,
-            "agent_id": 1,
+            "AgentID": 2,
+            "agent_id": 2,
             **template,
         },
     ]
diff --git a/tests/test_cell_space.py b/tests/test_cell_space.py
index a918f59e..4127847d 100644
--- a/tests/test_cell_space.py
+++ b/tests/test_cell_space.py
@@ -408,7 +408,7 @@ def test_empties_space():
 
     model = Model()
     for i in range(8):
-        grid._cells[i].add_agent(CellAgent(i, model))
+        grid._cells[i].add_agent(CellAgent(model))
 
     cell = grid.select_random_empty_cell()
     assert cell.coordinate in {8, 9}
@@ -432,7 +432,7 @@ def test_cell():
 
     # add_agent
     model = Model()
-    agent = CellAgent(1, model)
+    agent = CellAgent(model)
 
     cell1.add_agent(agent)
     assert agent in cell1.agents
@@ -445,11 +445,11 @@ def test_cell():
         cell1.remove_agent(agent)
 
     cell1 = Cell((1,), capacity=1, random=random.Random())
-    cell1.add_agent(CellAgent(1, model))
+    cell1.add_agent(CellAgent(model))
     assert cell1.is_full
 
     with pytest.raises(Exception):
-        cell1.add_agent(CellAgent(2, model))
+        cell1.add_agent(CellAgent(model))
 
 
 def test_cell_collection():
@@ -475,9 +475,9 @@ def test_cell_collection():
 
     cells = collection.cells
     model = Model()
-    cells[0].add_agent(CellAgent(1, model))
-    cells[3].add_agent(CellAgent(2, model))
-    cells[7].add_agent(CellAgent(3, model))
+    cells[0].add_agent(CellAgent(model))
+    cells[3].add_agent(CellAgent(model))
+    cells[7].add_agent(CellAgent(model))
     agents = collection.agents
     assert len(list(agents)) == 3
 
diff --git a/tests/test_datacollector.py b/tests/test_datacollector.py
index bf74e379..94e7742a 100644
--- a/tests/test_datacollector.py
+++ b/tests/test_datacollector.py
@@ -13,8 +13,8 @@ class MockAgent(Agent):
     Minimalistic agent for testing purposes.
     """
 
-    def __init__(self, unique_id, model, val=0):
-        super().__init__(unique_id, model)
+    def __init__(self, model, val=0):
+        super().__init__(model)
         self.val = val
         self.val2 = val
 
@@ -40,14 +40,6 @@ def agent_function_with_params(agent, multiplier, offset):
     return (agent.val * multiplier) + offset
 
 
-class DifferentMockAgent(MockAgent):
-    # We define a different MockAgent to test for attributes that are present
-    # only in 1 type of agent, but not the other.
-    def __init__(self, unique_id, model, val=0):
-        super().__init__(unique_id, model, val=val)
-        self.val3 = val + 42
-
-
 class MockModel(Model):
     """
     Minimalistic model for testing purposes.
@@ -61,8 +53,8 @@ class MockModel(Model):
         self.model_val = 100
 
         self.n = 10
-        for i in range(self.n):
-            self.schedule.add(MockAgent(i, self, val=i))
+        for i in range(1, self.n + 1):
+            self.schedule.add(MockAgent(self, val=i))
         self.initialize_data_collector(
             model_reporters={
                 "total_agents": lambda m: m.schedule.get_agent_count(),
diff --git a/tests/test_lifespan.py b/tests/test_lifespan.py
index cfd60cde..00dd878c 100644
--- a/tests/test_lifespan.py
+++ b/tests/test_lifespan.py
@@ -29,9 +29,7 @@ class LifeTimeModel(Model):
         self.schedule = RandomActivation(self)
 
         for _ in range(n_agents):
-            self.schedule.add(
-                FiniteLifeAgent(self.next_id(), self.agent_lifetime, self)
-            )
+            self.schedule.add(FiniteLifeAgent(self.agent_lifetime, self))
 
     def step(self):
         """Add agents back to n_agents in each step"""
@@ -40,9 +38,7 @@ class LifeTimeModel(Model):
 
         if len(self.schedule.agents) < self.n_agents:
             for _ in range(self.n_agents - len(self.schedule.agents)):
-                self.schedule.add(
-                    FiniteLifeAgent(self.next_id(), self.agent_lifetime, self)
-                )
+                self.schedule.add(FiniteLifeAgent(self.agent_lifetime, self))
 
     def run_model(self, step_count=100):
         for _ in range(step_count):
@@ -54,8 +50,8 @@ class FiniteLifeAgent(Agent):
     Also has a 10% chance of dying in each tick.
     """
 
-    def __init__(self, unique_id, lifetime, model):
-        super().__init__(unique_id, model)
+    def __init__(self, lifetime, model):
+        super().__init__(model)
         self.remaining_life = lifetime
         self.steps = 0
         self.model = model
diff --git a/tests/test_model.py b/tests/test_model.py
index c92fcb2e..016185e6 100644
--- a/tests/test_model.py
+++ b/tests/test_model.py
@@ -6,9 +6,6 @@ def test_model_set_up():
     model = Model()
     assert model.running is True
     assert model.schedule is None
-    assert model.current_id == 0
-    assert model.current_id + 1 == model.next_id()
-    assert model.current_id == 1
     assert model.steps == 0
     model.step()
     assert model.steps == 1
@@ -50,7 +47,7 @@ def test_agent_types():
         pass
 
     model = Model()
-    test_agent = TestAgent(model.next_id(), model)
+    test_agent = TestAgent(model)
     assert test_agent in model.agents
     assert type(test_agent) in model.agent_types
 
@@ -63,8 +60,8 @@ def test_agents_by_type():
         pass
 
     model = Model()
-    wolf = Wolf(1, model)
-    sheep = Sheep(2, model)
+    wolf = Wolf(model)
+    sheep = Sheep(model)
 
     assert model.agents_by_type[Wolf] == AgentSet([wolf], model)
     assert model.agents_by_type[Sheep] == AgentSet([sheep], model)
diff --git a/tests/test_solara_viz.py b/tests/test_solara_viz.py
index 660277b3..3ff81640 100644
--- a/tests/test_solara_viz.py
+++ b/tests/test_solara_viz.py
@@ -5,6 +5,7 @@ import ipyvuetify as vw
 import solara
 
 import mesa
+from mesa.visualization.components.matplotlib import make_space_matplotlib
 from mesa.visualization.solara_viz import Slider, SolaraViz, UserInputs
 
 
@@ -95,50 +96,22 @@ def test_call_space_drawer(mocker):
         "Shape": "circle",
         "color": "gray",
     }
-    current_step = 0
-    seed = 0
-    dependencies = [current_step, seed]
     # initialize with space drawer unspecified (use default)
     # component must be rendered for code to run
-    solara.render(
-        SolaraViz(
-            model_class=mesa.Model,
-            model_params={},
-            agent_portrayal=agent_portrayal,
-        )
-    )
+    solara.render(SolaraViz(model, components=[make_space_matplotlib(agent_portrayal)]))
     # should call default method with class instance and agent portrayal
-    mock_space_matplotlib.assert_called_with(
-        model, agent_portrayal, dependencies=dependencies
-    )
+    mock_space_matplotlib.assert_called_with(model, agent_portrayal)
 
-    # specify no space should be drawn; any false value should work
-    for falsy_value in [None, False, 0]:
-        mock_space_matplotlib.reset_mock()
-        solara.render(
-            SolaraViz(
-                model_class=mesa.Model,
-                model_params={},
-                agent_portrayal=agent_portrayal,
-                space_drawer=falsy_value,
-            )
-        )
-        # should call default method with class instance and agent portrayal
-        assert mock_space_matplotlib.call_count == 0
+    # specify no space should be drawn
+    mock_space_matplotlib.reset_mock()
+    solara.render(SolaraViz(model, components=[]))
+    # should call default method with class instance and agent portrayal
+    assert mock_space_matplotlib.call_count == 0
 
     # specify a custom space method
     altspace_drawer = Mock()
-    solara.render(
-        SolaraViz(
-            model_class=mesa.Model,
-            model_params={},
-            agent_portrayal=agent_portrayal,
-            space_drawer=altspace_drawer,
-        )
-    )
-    altspace_drawer.assert_called_with(
-        model, agent_portrayal, dependencies=dependencies
-    )
+    solara.render(SolaraViz(model, components=[altspace_drawer]))
+    altspace_drawer.assert_called_with(model)
 
     # check voronoi space drawer
     voronoi_model = mesa.Model()
@@ -146,9 +119,7 @@ def test_call_space_drawer(mocker):
         centroids_coordinates=[(0, 1), (0, 0), (1, 0)],
     )
     solara.render(
-        SolaraViz(
-            model_class=voronoi_model, model_params={}, agent_portrayal=agent_portrayal
-        )
+        SolaraViz(voronoi_model, components=[make_space_matplotlib(agent_portrayal)])
     )
 
 
diff --git a/tests/test_time.py b/tests/test_time.py
index 4203ca5d..13d71d22 100644
--- a/tests/test_time.py
+++ b/tests/test_time.py
@@ -25,8 +25,8 @@ class MockAgent(Agent):
     Minimalistic agent for testing purposes.
     """
 
-    def __init__(self, unique_id, model):
-        super().__init__(unique_id, model)
+    def __init__(self, model):
+        super().__init__(model)
         self.steps = 0
         self.advances = 0
 
@@ -38,10 +38,10 @@ class MockAgent(Agent):
     def stage_one(self):
         if self.model.enable_kill_other_agent:
             self.kill_other_agent()
-        self.model.log.append(self.unique_id + "_1")
+        self.model.log.append(f"{self.unique_id}_1")
 
     def stage_two(self):
-        self.model.log.append(self.unique_id + "_2")
+        self.model.log.append(f"{self.unique_id}_2")
 
     def advance(self):
         self.advances += 1
@@ -89,8 +89,8 @@ class MockModel(Model):
             self.schedule = BaseScheduler(self)
 
         # Make agents
-        for name in ["A", "B"]:
-            agent = MockAgent(name, self)
+        for _ in range(2):
+            agent = MockAgent(self)
             self.schedule.add(agent)
 
     def step(self):
@@ -105,7 +105,7 @@ class TestStagedActivation(TestCase):
     Test the staged activation.
     """
 
-    expected_output = ["A_1", "B_1", "model_stage", "A_2", "B_2"]
+    expected_output = ["1_1", "1_1", "model_stage", "1_2", "1_2"]
 
     def test_no_shuffle(self):
         """
@@ -168,7 +168,7 @@ class TestRandomActivation(TestCase):
 
     def test_init(self):
         model = Model()
-        agents = [MockAgent(model.next_id(), model) for _ in range(10)]
+        agents = [MockAgent(model) for _ in range(10)]
 
         scheduler = RandomActivation(model, agents)
         assert all(agent in scheduler.agents for agent in agents)
@@ -227,7 +227,7 @@ class TestRandomActivation(TestCase):
         model = MockModel(activation=RANDOM)
         # Create 10 agents
         for _ in range(10):
-            model.schedule.add(MockAgent(model.next_id(), model))
+            model.schedule.add(MockAgent(model))
         # Run 3 steps
         for _ in range(3):
             model.step()
@@ -273,8 +273,8 @@ class TestRandomActivationByType(TestCase):
 
     def test_init(self):
         model = Model()
-        agents = [MockAgent(model.next_id(), model) for _ in range(10)]
-        agents += [Agent(model.next_id(), model) for _ in range(10)]
+        agents = [MockAgent(model) for _ in range(10)]
+        agents += [Agent(model) for _ in range(10)]
 
         scheduler = RandomActivationByType(model, agents)
         assert all(agent in scheduler.agents for agent in agents)