Skip to content

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: IsPanelFirstShown and the visibility state machine.
  • Resource providers: for state that is content (fonts, icons, theme assets) rather than user data.