lstwoMODS Core • Overlay Plugins

Panels and Windows

Where a Plugin can draw

There are three places, in increasing distance from the element tree:

LevelWhere it drawsUse for
Element rendererinside the mod’s element treeanything that belongs to a panel or window the mod already builds
Frame callbackonce per frame, after all elementsHUDs, debug overlays, floating windows the mod doesn’t own
Separate OS windowits own render thread and ImGui contexta second monitor, a tool window

Frame Callbacks

OnWindowCreated hands you the RemoteImGuiWindow, so you can register a callback on it:

public override void OnWindowCreated(RemoteImGuiWindow window)
{
    window.AddFrameCallback("mymod.hud", () =>
    {
        ImGui.SetNextWindowBgAlpha(0.35f);
        if (ImGui.Begin("MyMod HUD", ImGuiWindowFlags.NoDecoration | ImGuiWindowFlags.AlwaysAutoResize))
            ImGui.Text($"FPS {ImGui.GetIO().Framerate:0}");
        ImGui.End();
    });
}

The callback runs on the window’s render thread, every frame, after the element tree has been rendered and before the frame gets sent back to the mod.

The id is a key, so adding the same id twice replaces the previous callback and RemoveFrameCallback(id) removes it. Exceptions are caught and logged as [FrameCallback] Exception: ..., and the callback keeps running. AddFrameCallback itself is thread safe, which matters because OnWindowCreated gets invoked from the IPC reader thread.

Input Passthrough

On an overlay type window, mouse and keyboard are passed through to the game unless the element tree asks for input. Frame callbacks are not part of that walk, so a panel drawn purely from a callback is visible but not clickable, and hotkeys watched by the window don’t get polled while nothing else wants input.

If your callback UI needs interaction, make sure something in the element tree requires input while it’s up. From the mod side that can be as simple as:

new Container("mymod-input-anchor").WithRequireInput(true)

The nicer option is exposing the panel as a custom element instead, so it takes part in the walk normally.

Identifying Windows

RemoteImGuiWindow.WindowId is the only public identity and it’s the GUID of the mod side OSWindow. Title and window type aren’t exposed.

If a callback should only attach to one specific window, have the mod send its Id in a custom message and match on that:

private static readonly HashSet<string> _myWindows = new();

ctx.RegisterMessageHandler(MyWindowIdMessage.MessageType, msg =>
    _myWindows.Add(MyWindowIdMessage.Deserialize(msg).WindowId));

Per Window State

Every RemoteImGuiWindow runs on its own thread with its own ImGui context, including its own ImPlot, ImPlot3D, ImNodes and ImGuizmo contexts, all created by the window before the first frame.

So never share ImGui pointers, texture ids or draw lists between windows. Keep plugin view state per window (keyed by WindowId) unless it’s genuinely process global.

Static mutable state in a renderer is shared across every instance of that element in every window. Instance fields are per element and are what you usually want.

Textures and Images

public override unsafe void Render()
{
    // Lazily load on first call, the render thread owns the graphics context.
    if (_texId == -1)
    {
        _texId = Window.LoadTexture(_filePath, out _w, out _h);
        if (_texId == -1) return;
    }

    // ImGui.ImTextureRef returns an ImTextureRefPtr, which implicitly converts to
    // ImTextureRef*. Dereference it for Image/ImageButton.
    ImTextureRef* texRef = ImGui.ImTextureRef(new ImTextureID((ulong)_texId));
    if (texRef == null) return;

    ImGui.Image(*texRef, new Vector2(_w, _h));
}

LoadTexture has to be called from the render thread, so inside a renderer’s Render() or a frame callback. Results are cached per path, so repeat calls are free, and it returns -1 on failure (which gets cached too).

The mod side can warm the cache ahead of time with OSWindow.PreloadImage(path), and the built in UIImage element covers the simple “just show this file” case without a plugin.

Fonts

Fonts are registered by the mod side with OSWindow.AddFont(name, path, size, merge, glyphOffsetY) and the atlas gets rebuilt in the overlay. The overlay’s font map is private, so a plugin can’t push a font by name directly. Apply it from the mod side instead, on the element that hosts your renderer:

new MyWidget("chart").WithFont("inter");

Lucide icons are merged into the default font, so icon glyphs work inside plugin rendering with no extra setup.

Floating ImGui Windows

Prefer the mod side GuiWindow element. It’s a real element, so it docks, saves its layout in imgui.ini, reports open and focus state back to the mod, and takes part in input passthrough:

public override Group ConstructUI() =>
{    
    new(Name,
        new GuiWindow("my-window", "My Window",
            new MyWidget("content")
        ).WithSize(400, 300)
    );
}

Its renderer already handles size, position, dock id, pivot, pinning to the main viewport, the close button, focus requests and the “just opened” pulse.

Calling ImGui.Begin and ImGui.End yourself inside a renderer or frame callback is fine for transient things like a popup or a HUD, but then you own visibility, focus and layout persistence.

Separate OS Windows

Window creation is driven by the mod, a plugin has no API to spawn a standalone OS window. Subclass OSWindow on the mod side and initialize it:

public class MyToolWindow(IntPtr gameWindowHandle) : OSWindow("My Tool", 900, 600, WindowType.Normal, gameWindowHandle)
{
    public override void ConstructUI()
    {
        Config = new ImGuiConfig
        {
            ConfigFlags = ImGuiConfigFlags.NavEnableKeyboard | ImGuiConfigFlags.DockingEnable
        };

        AddFont("inter", "Assets/InterVariable.ttf", 16f);
        AddElement(new Container("root", new MyWidget("content")));
    }
}
// somewhere after UIManager is initialized
await new MyToolWindow(gameWindowHandle).Initialize();

Initialize() sends a window init message, the overlay creates a RemoteImGuiWindow with its own thread, and your plugin’s OnWindowCreated fires for it.

WindowType.Normal is an ordinary resizable window, which is what you want for a second monitor. WindowType.Overlay is transparent, click through when nothing needs input, and tracks the game window handle you pass in.

Checklist for a New Plugin

  • Data types and message types live in SharedObjects, deployed to both the mod folder and Overlay/plugins/.
  • RegisterRenderer and RegisterMessageHandler calls happen in Initialize.
  • Every IPC handler queues, and the render thread consumes.
  • Every mod side handler that touches Unity goes through MainThread.Enqueue.
  • State messages are full snapshots, sent only when dirty and bounded in size.
  • There is a resync path so a late or restarted overlay catches up.
  • Interactive callback UI has something in the element tree requiring input.