lstwoMODS Core • Overlay Plugins

Custom Elements

The Three Classes

A custom element is three classes:

ClassProjectBase type
MyWidgetDataSharedObjectsBaseUIElementData
MyWidgetmodBaseUIElement<MyWidget>
MyWidgetRendererOverlayExtensionUIRenderer

The data class is the wire format, the element is what you write in ConstructUI, and the renderer is the ImGui code.

The Data Class

using lstwoMODS.ImGui.Shared.UI;

namespace MyMod.SharedObjects
{
    public class StepperData : BaseUIElementData
    {
        public int Value { get; set; }
        public int Step  { get; set; } = 1;
    }
}

Use public properties, the serializer ignores fields. Keep it a plain data object, it gets serialized every time it changes.

BaseUIElementData already gives you Id, Name, Enabled, RequireInput, PushCommands, Tooltip and TooltipHoveredFlags, so don’t redeclare those.

Why it has to live in SharedObjects

The element tree is polymorphic, so the IPC payload carries a $type for each element. Type resolution goes through an allow list binder that only accepts types assignable to BaseUIElementData or PushCommand, and only from assemblies that are already loaded.

In practice this means your data type is accepted automatically because it derives from BaseUIElementData, but the assembly declaring it has to be loaded in both processes under the same full type name.

Nesting depth is capped at 64 and a single IPC frame is capped at 32 MB.

The Mod Side Element

using System;
using System.Collections.Generic;
using lstwoMODS.ImGui.Shared.UI;
using lstwoMODS_Core.UI.Elements;
using MyMod.SharedObjects;

namespace MyMod.UI.Elements;

public class Stepper : BaseUIElement<Stepper>
{
    public Action<int>? OnChanged;

    public int Value
    {
        get => ((StepperData)Data).Value;
        set { ((StepperData)Data).Value = value; MarkChanged(); }
    }

    public Stepper(string name, int value = 0, int step = 1) : base(name)
    {
        Data = new StepperData { Name = name, Value = value, Step = step };
    }

    public override IEnumerable<BaseUIElement> GetChildren() => [];

    public override void ApplyReceivedData(BaseUIElementData data)
    {
        var old = Value;
        base.ApplyReceivedData(data);

        if (old != Value)
        {
            var v = Value;
            InvokeCallback(() => OnChanged?.Invoke(v));
        }
    }
}

Now you can use it like any other element:

public override Group ConstructUI() =>
    new(Name, new Stepper("Count", 0, 5) { OnChanged = v => Plugin.Logger.LogInfo(v) });

Two things matter here.

MarkChanged() is what queues the element into the next frame state. Change the data without it and the overlay never hears about it.

ApplyReceivedData is the inbound half. It runs on the IPC reader thread, so route user code through InvokeCallback(...), which respects RunCallbacksOnMainThread (true by default, meaning the callback gets marshalled onto the Unity main thread). Touching Unity objects directly in ApplyReceivedData will crash or misbehave.

Because BaseUIElement<TSelf> is self typed, all the With* helpers (WithTooltip, WithItemWidth, WithDisabled, WithVisible, WithRequireInput, WithStyleVar, …) already work on your element and return Stepper.

The Renderer

using Hexa.NET.ImGui;
using lstwoMODS.ImGui.Shared.UI;
using lstwoMODS_Overlay.UiRenderers;
using MyMod.SharedObjects;

namespace MyMod.OverlayExtension;

public class StepperRenderer : UIRenderer
{
    private int _value;
    private int _step;

    public StepperRenderer(BaseUIElementData data) : base(data)
    {
        var d = (StepperData)data;
        _value = d.Value;
        _step  = d.Step;
    }

    public override void ApplyState(BaseUIElementData data)
    {
        var d = (StepperData)data;
        Data = d; Name = d.Name;
        _value = d.Value;
        _step  = d.Step;
    }

    public override void Render()
    {
        if (ImGui.Button($"-##{Data.Id}")) _value -= _step;
        ImGui.SameLine();
        ImGui.Text($"{Data.Name}: {_value}");
        ImGui.SameLine();
        if (ImGui.Button($"+##{Data.Id}")) _value += _step;
    }

    public override BaseUIElementData? GetNewState()
    {
        var d = (StepperData)Data;
        if (_value == d.Value) return null;

        d.Value = _value;
        return new StepperData
        {
            Id = Data.Id, Name = Data.Name, Enabled = Data.Enabled,
            Value = _value, Step = _step
        };
    }
}

And register it in Initialize:

ctx.RegisterRenderer<StepperData, StepperRenderer>();

The constructor has to take a single BaseUIElementData. Renderers are created with Activator.CreateInstance(rendererType, data) lazily, the first time the element is rendered, and cached per element Id. Window is set for you right after construction and points at the RemoteImGuiWindow that owns the element.

ApplyState(data) gets called when the mod pushes an update for this element. Render() draws, you can use any ImGui methods here.

GetNewState() gets called once per frame for every live renderer. Return null when nothing changed, otherwise return a fresh data object carrying the new values. Keep it cheap, because it runs for every element every frame.

Returning a value from GetNewState also mutates the cached Data (see d.Value = _value above). That is on purpose, because it’s what stops the same change from being reported again next frame.

What the Host does for you

Before Render() gets called, the window already handled:

  • Enabled == false, in which case the element and its whole subtree are skipped
  • every PushCommand on the element (fonts, style vars, style colors, ID stack, item width, item flags, BeginDisabled, text wrap, clip rect), including the matching pops afterwards
  • the tooltip, applied immediately after the widget
  • exception isolation with a render stack breadcrumb

So don’t push and pop those yourself unless it’s genuinely local to your drawing.

Containers

If your element should hold children, expose a property named exactly Children on both sides:

public class PanelData : BaseUIElementData
{
    public List<BaseUIElementData> Children { get; set; } = new List<BaseUIElementData>();
}
public class Panel : BaseUIElement<Panel>
{
    public List<BaseUIElement> Children { get; }

    public Panel(string name, params BaseUIElement[] children) : base(name)
    {
        Children = new List<BaseUIElement>(children);
        Data = new PanelData { Name = name, Children = Children.Select(c => c.Data).ToList() };
    }

    public override IEnumerable<BaseUIElement> GetChildren() => Children;
}

The name isn’t cosmetic. Children (and LineChildren, used by the line style containers) are looked up by reflection for runtime AddElement and RemoveElement splicing, for the Enabled subtree walk, and for the input passthrough calculation.

The renderer draws children through the window:

public class PanelRenderer : UIRenderer
{
    private List<BaseUIElementData> _children;

    public PanelRenderer(BaseUIElementData data) : base(data)
        => _children = ((PanelData)data).Children;

    public override void ApplyState(BaseUIElementData data)
    {
        var d = (PanelData)data;
        Data = d; Name = d.Name;
        // Updates arrive child-stripped, only replace when the incoming list is populated.
        if (d.Children?.Count > 0) _children = d.Children;
    }

    public override void Render()
    {
        foreach (var child in _children)
            Window.RenderSingleElement(child);
    }

    public override BaseUIElementData? GetNewState() => null;
}

There are two container specific rules.

Updates arrive child stripped. Updated elements are serialized without their Children lists so a single changed label doesn’t retransmit the whole tree. Hold the child list by reference and only swap it when a non empty list arrives, exactly like above.

Runtime creates splice into your list. OSWindow.AddElement(child, parent) sends a create entry that gets inserted into the same list your renderer is holding. That is also why frame state gets applied on the render thread and not on the IPC thread.

Wrapping Children

If children have to be drawn between a Begin and End pair, or conditionally, override RenderWidget and RenderChildren instead of Render:

public override bool RenderWidget()
{
    _open = ImGui.CollapsingHeader(Data.Name);
    return _open; // false = skip children entirely
}

public override void RenderChildren()
{
    foreach (var child in _children)
        Window.RenderSingleElement(child);
}

The split exists so the tooltip lands on the header itself and not on the last child. The default RenderWidget just calls Render() and returns true, which is correct for every leaf element.

Input Passthrough

On an overlay type window the overlay only steals mouse and keyboard from the game when something in the element tree asks for it. Each frame the window walks the tree and resolves each element’s RequireInput (True, False or Inherit).

Two hooks on your renderer take part in that.

ParticipatesInInput (default true) should return false when your element rendered nothing this frame, for example a closed window or popup. Without it a mounted but invisible container keeps the overlay pinned in the foreground.

IsOnMainViewport (default true) should be set in Render() when your element can be torn off into a separate OS window through multi viewport:

IsOnMainViewport = ImGui.GetWindowViewport().ID == ImGui.GetMainViewport().ID;

From the mod side, WithRequireInput(true) on the element forces focus while it’s visible.

The Opaque Element Pattern

For a big self contained screen, don’t model it as elements at all. Ship a single empty data type and let the renderer own everything:

public class PropSpawnerData : BaseUIElementData;
public class PropSpawnerElement : BaseUIElement<PropSpawnerElement>
{
    public PropSpawnerElement(string name) : base(name)
        => Data = new PropSpawnerData { Name = name };

    public override IEnumerable<BaseUIElement> GetChildren() => [];
}
public override Group ConstructUI() =>
    new("PropSpawnerRoot", new PropSpawnerElement("prop-spawner-native"));

The renderer returns null from GetNewState() and talks to the mod exclusively through custom messages.

This is how the Wobbly Life prop spawner works. Thousands of rows, search, multi select, drag and drop and modals, all rendered natively in the overlay, with only a state snapshot and small command messages crossing the process boundary.