State¶
Tick UI splits state between the framework and your application. This page explains the split, what Tick UI owns for you, and how to inject your own application state into the per-frame draw.
Core principle: data becomes UI through a function call¶
Your application holds the state, and DoUI is a function from
that state to a frame of pixels. Tick UI holds only the UI
mechanics that need to persist between frames.
protected override void DoUI(UI ui)
{
ui.BeginFrame(...);
if (ui.Button("save"))
m_isDirty = false;
ui.Text(m_isDirty ? "Unsaved changes" : "Saved", ...);
ui.EndFrame();
}
The UI re-reads m_isDirty every frame. Mutate the field, and the
next frame reflects the change.
For the per-frame mechanics this depends on, refer to Frame lifecycle.
What Tick UI owns versus what you own¶
Tick UI owns UI mechanics state. Your application owns the meaning: the data the UI shows, and the conditions that drive state transitions.
| Owned by Tick UI | Owned by your application |
|---|---|
Panel visibility (Visible, Hiding, Hidden) |
Whether the user opened the modal in the first place |
Scroll positions inside ScrollView |
The list of items being scrolled through |
| Focused textbox; which widget has the caret | The string the textbox is editing |
Animation context state (Init, Enter, Leave) |
The condition that drove the state change |
| Hover and press tracking per widget ID | The bool isHovered you might want to read (Tick UI exposes it via ui.IsHovered()) |
LastUsedFrame per panel |
First-show initialisation, read via ui.IsPanelFirstShown() |
| Cached text meshes, keyed by a content hash | The current text being rendered |
For the panel state machine, refer to Panels and modals. For the animation context lifecycle, refer to Animation.
How widget identity ties state to your code¶
State that Tick UI owns is keyed by a widget ID derived from
the strings you pass to Button("save"),
Textbox("username", ...), BeginPanel("settings", ...). Two
widgets with the same ID share state. That's why duplicate IDs
are a bug.
For repeated widgets (a row per item in a list), wrap each
iteration in PushID(item.Id) and PopID(). Each row gets a
unique ID even though the inner widgets all use the same string
literals.
For the full identity model and the editor-only assertion that catches collisions, refer to IDs.
Inject your application state¶
Three patterns, in order of complexity, let you feed application state into the draw:
Pattern A: a field on the BaseGameUI subclass¶
The simplest path. Your MyGameUI : BaseGameUI subclass holds the
data directly:
public class SettingsUI : BaseGameUI
{
[SerializeField] private Settings m_settings;
protected override void DoUI(UI ui)
{
ui.BeginFrame(...);
ui.Textbox("playerName", ref m_settings.playerName, ...);
ui.EndFrame();
}
}
Use this when the UI owns the data. It is Inspector-friendly. It does not scale beyond a screen or two before the subclass becomes overburdened.
Pattern B: a model object passed in¶
Decouple the UI from the data:
public class SettingsUI : BaseGameUI
{
public Settings Model { get; set; }
protected override void DoUI(UI ui)
{
if (Model == null)
return;
ui.BeginFrame(...);
ui.Textbox("playerName", ref Model.playerName, ...);
ui.EndFrame();
}
}
The settings live on the application; the UI is a view of them.
Set Model from elsewhere (a game manager, a save system).
Pattern C: a static accessor or service locator¶
For UI fragments that reuse across screens (a HUD that needs the
same player stats wherever it appears), pull from a static or
DI-managed service inside DoUI. Keep the access read-only; write
mutations through your normal application APIs, not directly via
the UI.
What does not belong in DoUI¶
DoUI runs every frame, so anything you put in it runs every
frame. Avoid:
- Allocations in the hot path. A per-frame
new List<>()is per-frame GC pressure. - Side-effecting calls that aren't tied to a click. Wrap them
in
if (ui.Button("load"))so they fire only on user intent. - Long-running work. Asset loads, network calls. Start these from a click handler and read the result back from a field on the next frame.
Cache within a frame¶
If you compute something expensive that you would render multiple
times in a single frame (a measured layout that drives both a
scroll bar and a content rect), compute it once in a local at the
top of DoUI and pass it down. Don't recompute per call.
For state that both survives between frames and depends on UI
identity (for example, the rect at which you last drew a hover
indicator), use a Dictionary<int, T> keyed by widget ID, with
the same PushID discipline as Tick UI's own state lookup.
Additional resources¶
- Frame lifecycle: when state is read versus when it is flushed to the GPU.
- Stacks: IDs, animation context, and panel state all live on stacks.
- IDs: the identity rules behind state-keying.
- Panels and modals:
IsPanelFirstShownand the visibility state machine. - Resource providers: for state that is content (fonts, icons, theme assets) rather than user data.