What Is It?
Model–View–Presenter divides a screen into three roles. The Model holds state and rules (gold, items, "can I afford this?") as plain C#. The View is a MonoBehaviour that owns the Buttons and TMP_Texts and nothing else. The Presenter sits in between and is the only piece that talks to both.
Traffic flows in a loop. A click in the View raises an event like BuyClicked; the Presenter turns that into model.TryBuy(potion); the Model changes and raises Changed; the Presenter reads the new state and calls view.Render(gold, items). The View never holds a reference to the Model.
Think of a shop screen: the shopkeeper prices, the player wallet and the inventory rules live in an InventoryModel you can unit test without a scene, while the InventoryView could be swapped from uGUI to UI Toolkit without touching a single game rule.
When Is It Used?
Use it for any UI with real logic behind it — shops, inventories, crafting screens, settings menus with validation.
It pays off when you want automated tests for UI rules, or when designers frequently rework layouts and you do not want those changes to break gameplay code.
For a static pause menu with three buttons that just call SceneManager.LoadScene, MVP is ceremony. Keep it simple until state and rules appear.
Interactive Demo
Click the controls and watch the objects collaborate. The console mirrors what the C# code below would log.
Code
using UnityEngine;
namespace Patterns.Architectural.MVP
{
/// <summary>
/// Builds the triad for the shop screen: creates the Model, finds the
/// View in the scene and hands both to a new Presenter.
/// </summary>
public class InventoryBootstrap : MonoBehaviour
{
[SerializeField] private InventoryView view;
[SerializeField] private int startingGold = 50;
private InventoryPresenter presenter;
private void Start()
{
var model = new InventoryModel(startingGold);
presenter = new InventoryPresenter(model, view);
}
private void OnDestroy()
{
presenter?.Dispose();
presenter = null;
}
}
}using System;
using System.Collections.Generic;
namespace Patterns.Architectural.MVP
{
/// <summary>
/// The Model: pure C#, no UnityEngine dependency. Owns state and rules
/// and announces every change through an event.
/// </summary>
public class InventoryModel
{
private readonly List<string> items = new();
public int Gold { get; private set; }
public IReadOnlyList<string> Items => items;
public int Capacity { get; } = 6;
public event Action Changed;
public InventoryModel(int startingGold) => Gold = startingGold;
public void AddGold(int amount)
{
if (amount <= 0) return;
Gold += amount;
Changed?.Invoke();
}
public bool TryBuy(string itemId, int price)
{
if (Gold < price || items.Count >= Capacity)
return false;
Gold -= price;
items.Add(itemId);
Changed?.Invoke();
return true;
}
}
}using System;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace Patterns.Architectural.MVP
{
/// <summary>
/// The View: owns UI references, forwards clicks as events and draws
/// whatever it is told to. It never sees the Model.
/// </summary>
public class InventoryView : MonoBehaviour
{
[SerializeField] private Button buyButton;
[SerializeField] private Button addGoldButton;
[SerializeField] private TMP_Text goldLabel;
[SerializeField] private TMP_Text itemsLabel;
[SerializeField] private TMP_Text feedbackLabel;
public event Action BuyClicked;
public event Action AddGoldClicked;
private void OnEnable()
{
buyButton.onClick.AddListener(OnBuy);
addGoldButton.onClick.AddListener(OnAddGold);
}
private void OnDisable()
{
buyButton.onClick.RemoveListener(OnBuy);
addGoldButton.onClick.RemoveListener(OnAddGold);
}
private void OnBuy() => BuyClicked?.Invoke();
private void OnAddGold() => AddGoldClicked?.Invoke();
public void Render(int gold, IReadOnlyList<string> items, bool canBuy)
{
goldLabel.text = $"{gold} g";
itemsLabel.text = items.Count == 0 ? "(empty)" : string.Join(", ", items);
buyButton.interactable = canBuy;
}
public void ShowFeedback(string message) => feedbackLabel.text = message;
}
}using System;
namespace Patterns.Architectural.MVP
{
/// <summary>
/// The Presenter: translates View events into Model calls and Model
/// changes into View renders. The only class that knows both sides.
/// </summary>
public class InventoryPresenter : IDisposable
{
private const string PotionId = "Potion";
private const int PotionPrice = 30;
private const int GoldPerClick = 25;
private readonly InventoryModel model;
private readonly InventoryView view;
public InventoryPresenter(InventoryModel model, InventoryView view)
{
this.model = model;
this.view = view;
view.BuyClicked += HandleBuy;
view.AddGoldClicked += HandleAddGold;
model.Changed += Refresh;
Refresh();
}
private void HandleBuy()
{
if (!model.TryBuy(PotionId, PotionPrice))
view.ShowFeedback("Not enough gold or bag is full.");
}
private void HandleAddGold() => model.AddGold(GoldPerClick);
private void Refresh()
{
bool canBuy = model.Gold >= PotionPrice && model.Items.Count < model.Capacity;
view.Render(model.Gold, model.Items, canBuy);
}
public void Dispose()
{
view.BuyClicked -= HandleBuy;
view.AddGoldClicked -= HandleAddGold;
model.Changed -= Refresh;
}
}
}Advantages & Disadvantages
+ Advantages
- Game rules live in a plain C# Model that can be tested in Edit Mode.
- Views become thin and replaceable — swap uGUI for UI Toolkit by writing a new View.
- The data flow is explicit and one-directional: click → presenter → model → event → presenter → view.
− Disadvantages
- More classes and more wiring per screen than a single MonoBehaviour would need.
- Presenters can bloat into "god objects" if every screen concern is dumped into them.
- Forgetting to unsubscribe in
Disposeleaves presenters alive after their screen is gone.
Tips
- 01Give the View an interface (
IInventoryView) so the Presenter can be tested with a fake view. - 02Have the View expose C# events (
event Action BuyClicked) rather than publicButtonfields, so the Presenter never touches Unity UI types. - 03Make the Presenter
IDisposableand unhook all events inDispose(); call it from the bootstrap inOnDestroy. - 04Render from the Model's current state on every change instead of patching individual labels — it keeps the View consistent.
- 05MVVM with UI Toolkit data binding is a close cousin; pick MVP when you want explicit control over each refresh.