lstwoMODS Core • Custom UI

Editing the Element Tree

Mods and windows cover most UI. Sometimes you need something that is neither: a HUD readout that sits on top of the game, a loading bar during a long scan, a chat box, or a list whose contents change while the game runs. For those you add elements to the overlay window’s tree yourself, instead of returning them from BuildPanel or ConstructUI.

The window

Everything lives in one OSWindow, reachable as lstwoMODS_Core.Plugin.Window. It holds a flat map of every element in the tree and syncs changes to the overlay process each frame. It does not exist until core has started the overlay, so there are two places to add to it:

private void Awake()
{
    // Build time: runs while the window is being constructed, ships in the initial state.
    LstwoModsOverlay.OnConstructUI += OnConstructUI;
}

private void OnConstructUI()
{
    lstwoMODS_Core.Plugin.Window.AddElement(myHudWindow);
}
// Runtime: any time after that, from anywhere.
lstwoMODS_Core.Plugin.Window.AddElement(row, myContainer);
lstwoMODS_Core.Plugin.Window.RemoveElement(oldRow);

Both use the same API. Build time is for UI that always exists, runtime is for UI that appears and disappears. Subscribe to OnConstructUI in Awake, since core creates the window in its own Start.

MemberEffect
AddElement(element)Add at the window’s top level.
AddElement(element, parent, index)Add inside a container that is already in the tree. index -1 appends.
RemoveElement(element)Remove the element and its whole subtree.
AddFont(name, path, size, merge, glyphOffsetY)Register a font for WithFont(name).
PreloadImage(path)Load a texture before the first render that needs it.
FocusOverlayWindow() / FocusGameWindow()Hand keyboard focus between the overlay and the game.
LoadIniSettings(ini)Apply a saved ImGui layout.

Both AddElement overloads register the whole subtree, so you pass a finished tree and never walk it yourself. AddElement throws if the parent is not part of the window or cannot hold children; check with parent.CanHoldChildren if you are not sure.

What can go at the top level

Top level elements are rendered without an ImGui window around them, so they have to open their own: GuiWindow, Modal, Popup, MainMenuBar, DockSpace, or a Container holding those. A loose Button at the top level has nothing to draw into.

Anything else goes inside a parent.

A HUD window

This is the asset database scan bar from the Wobbly Life mod. The whole element tree is built once at construct time, and everything that changes afterwards is a Ref<T>:

internal static readonly Ref<bool>   ScanWindowVisible = new();
internal static readonly Ref<float>  ScanProgressValue = new();
internal static readonly Ref<string> ScanProgressText  = new("");

private void OnConstructUI()
{
    lstwoMODS_Core.Plugin.Window.AddElement(
        new GuiWindow("asset-db-scan", "##asset-db-scan",
            new UIText("scan-title", "Scanning Assets"),
            new ProgressBar("scan-bar", sizeY: 22f)
                .WithValue(ScanProgressValue)
                .WithOverlay(ScanProgressText)
                .WithRequireInput(false)
        )
        .WithOpen(ScanWindowVisible)
        .WithRequireInput(false)
        .WithNoClose()
        .WithFlags(
            ImGuiWindowFlags.NoDecoration          |
            ImGuiWindowFlags.NoMove                |
            ImGuiWindowFlags.NoInputs              |
            ImGuiWindowFlags.NoSavedSettings       |
            ImGuiWindowFlags.NoBringToFrontOnFocus |
            ImGuiWindowFlags.NoDocking
        )
        .WithSize(540f, 62f, ImGuiCond.Always)
        .WithPosition(-1f, 20f, ImGuiCond.Always, 0.5f, 0f)
    );
}

Driving it is then just setting values:

private static IEnumerator RunScan()
{
    ScanWindowVisible.Value = true;
    ScanProgressValue.Value = 0f;
    ScanProgressText.Value  = $"Scanning 0 / {total}";

    yield return AssetDatabase.ScanAllComponentsAsync((done, total) =>
    {
        ScanProgressValue.Value = total > 0 ? (float)done / total : 1f;
        ScanProgressText.Value  = $"Scanning {done} / {total}";
    });

    ScanProgressValue.Value = 1f;
    ScanProgressText.Value  = "Done";
    yield return new WaitForSecondsRealtime(3f);
    ScanWindowVisible.Value = false;
}

What makes it behave like a HUD element rather than a panel:

  • WithOpen(Ref<bool>) is the show and hide switch. The element stays in the tree the whole session, the ref decides whether it renders.
  • ImGuiCond.Always on size and position re-applies them every frame, so the user cannot drag the bar somewhere else and it stays put through resolution changes.
  • WithPosition(-1f, 20f, Always, 0.5f, 0f) uses the negative X sentinel, which the renderer resolves to the middle of the display, with a pivot of 0.5 centering the window on it. Centered() and CenteredX(y) are shorthands for the common cases.
  • NoSavedSettings keeps it out of the overlay’s imgui.ini, NoDecoration and NoInputs make it a plain rectangle nobody can interact with.
  • WithRequireInput(false) keeps the mouse in the game. See below.

The overlay runs in its own process, so this keeps rendering and animating while the game’s main thread is busy loading. That is the whole reason a progress bar works here at all.

Input passthrough

Each frame the overlay asks the enabled tree whether anything needs input. If nothing does, it turns on click-through and the game keeps the mouse and keyboard. RequireInput is what feeds that answer, and it has three states:

ModeMeaning
InheritTake the parent’s answer. The default, and true at the root.
TrueThis subtree needs input.
FalseThis subtree does not.

So a HUD element that only displays something needs WithRequireInput(false), or it steals the mouse from the game the whole time it is visible. For mixed UI, set the container to Inherit and let the children decide. The chat window does exactly this: the window inherits, the log rows are false, and only the input box is true, so the overlay grabs input while you are typing and releases it again the moment the box hides.

Closed windows, modals and popups are skipped entirely, so a dialog that is in the tree but not open never holds input.

Adding into an existing panel

A mod’s BuildPanel returns a Container, and any container in it can be a host for elements you add later. Keep the reference:

private Container _rows;

public override Container BuildPanel(string id)
{
    new Container(id,
        new UIText(id + "-hdr", "Nearby players"),
        _rows = new Container(id + "-rows")
    );
}

private void AddRow(string playerName)
{
    var row = new UIText($"row-{playerName}", playerName);
    lstwoMODS_Core.Plugin.Window.AddElement(row, _rows);
    _built.Add(row);
}

Core’s Settings window has a hook for the same idea at build time. The list is handed to you before the Group is constructed, so anything you append is registered normally:

private void Awake()
{
    SettingsWindow.OnBuildUI += OnBuildSettingsUI;
}

private void OnBuildSettingsUI(List<BaseUIElement> elements)
{
    var confirm = new ConfirmDialog("rescan-confirm",
        title:        "Rescan Asset Database",
        message:      "This deletes the cached database and scans every asset again.",
        confirmLabel: "Rescan",
        onConfirm:    () => StartCoroutine(RunScan(forceRefresh: true)));

    elements.Add(new SeparatorText("my-sep", "My Mod"));
    elements.Add(confirm);
    elements.Add(new Button("Rescan Asset Database", confirm.Show).WithContentWidth());
}

Dialogs are elements too, so the ConfirmDialog has to be in the tree as well as the button that opens it.

To reorder children you already have, use Group.SetChildOrder(orderedChildren) instead of removing and re-adding them. It is the only structural edit that syncs without a remove and a create, and it exists on Group only.

Lists that change while the game runs

Build a static skeleton with an empty container as the host, then fill it at runtime:

public override Group ConstructUI()
{
    MainThread.Enqueue(Refresh);   // first fill, once the skeleton is registered

    return new Group("MyWindow",
        new Button("Add Item", AddItem),
        new Separator("MyWindow-sep"),
        _host = new Container("MyWindow-host"));
}

Then reconcile the rendered rows against your own data, keeping a list of what you built:

private void Refresh()
{
    if (_host == null || Plugin.Window == null) return;

    // Remove rows whose item is gone.
    for (var i = _rows.Count - 1; i >= 0; i--)
    {
        if (Items.Any(it => it.Id == _rows[i].ItemId)) continue;
        Plugin.Window.RemoveElement(_rows[i].Element);
        _rows.RemoveAt(i);
    }

    // Insert rows for new items, at the right position.
    for (var i = 0; i < Items.Count; i++)
    {
        if (i < _rows.Count && _rows[i].ItemId == Items[i].Id) continue;

        var existing = _rows.FindIndex(r => r.ItemId == Items[i].Id);
        if (existing >= 0)
        {
            Plugin.Window.RemoveElement(_rows[existing].Element);
            _rows.RemoveAt(existing);
        }

        var row = BuildRow(Items[i]);
        Plugin.Window.AddElement(row.Element, _host, i);
        _rows.Insert(i, row);
    }

    // Everything that is only a value change: push it into the existing rows.
    for (var i = 0; i < _rows.Count; i++)
        RefreshRow(_rows[i], Items[i]);
}

Four rules make this work. They are not obvious and each one produces a bug that looks like something else:

Do not edit Children lists directly. Updated elements are sent without their children, so a Children.Add(...) reaches the overlay only if something else happens to re-send the parent whole. AddElement and RemoveElement send proper create and remove instructions. SetChildOrder sends the new order. Nothing else does.

Your elements are not registered until ConstructUI returns. The panel tree is handed to the window as one piece, so calling AddElement with one of your own containers as the parent from inside ConstructUI throws. Defer the first fill with MainThread.Enqueue(...), as above.

Never remove and re-add the same element instance in the same frame. Creates are applied before removes, and a create for an ID that is already registered is skipped, so the remove wins and the element disappears. To move a row, build a fresh instance for the new position and drop the old one. A new instance gets a new ID and behaves.

When items move between parents, remove everything stale first, then add. Otherwise a row that moves from one host to another hits the case above, added under the new parent in the same frame the old parent drops it.

Changing versus rebuilding

Adding and removing is for structure that actually changes. For anything that toggles often, keep the element and change its state instead:

WantUse
Show or hideSetVisible(bool), or WithVisible(Ref<bool>) at build time
Gray outSetDisabled(bool)
New value or labelThe element’s own property or its Ref<T> binding
Reorder childrenGroup.SetChildOrder(...)
Different number of childrenAddElement / RemoveElement

Hiding is cheap, keeps IDs stable, and cannot hit any of the ordering traps. The chat log builds its rows once and toggles them, because rows come and go every few seconds and fade while they do.

If you poke Data directly rather than going through the element’s own accessors, call MarkChanged() afterwards or the overlay never hears about it.

Threading

AddElement and RemoveElement are safe to call from any thread. Building the elements is only safe where the code you run is safe: if a constructor reads Unity state, it belongs on the main thread.

Callbacks and Ref<T> changes arrive from the IPC thread and are marshalled to Unity’s main thread by default. Pass mainThread: false when a handler touches no Unity API and has to keep working while the game is frozen or loading, which is when the main thread is not ticking at all. MainThread.Enqueue(...) marshals manually.

Overlay restarts

If the overlay process dies, core restarts it and replays the live tree, including everything you added at runtime. You do not re-add anything.

What does not survive is state you pushed across yourself with custom IPC messages, since that went down with the old process. Re-send it from UIManager.OnReconnected, which runs on the IPC connect thread:

UIManager.OnReconnected += () => MainThread.Enqueue(() =>
{
    SendMyCatalogue();
});

Debugging

Turn on Developer Mode in the config to get the UI inspector window, which shows the live element tree with IDs and lets you confirm that a create actually landed where you meant it to. The overlay also logs when it cannot place an element, for example when a create names a parent it does not know or one that has no child list, in which case the element ends up at the top level.

For elements that need their own ImGui code rather than a new arrangement of existing ones, see Custom Elements.