Skip to content
RustPluginGenerator

Rust CUI Tutorial: Build Custom UI Panels in Oxide Plugins

CUI is Oxide's server-side API for drawing UI on a player's screen: you build a CuiElementContainer of panels, labels and buttons, send it with CuiHelper.AddUi, and remove it with CuiHelper.DestroyUi. This guide covers anchors, offsets, colors, layers, parent names, cursor control, button commands, updating and cleanup, with a complete plugin that shows a closeable panel.

9 min readUpdated Oct 26, 2026

Rust CUI is the server-side UI system in Oxide: your plugin builds a CuiElementContainer holding panels, labels and buttons, sends it to a player with CuiHelper.AddUi, and removes it by name with CuiHelper.DestroyUi. Everything is described as data on the server and drawn by the normal Rust client, so players need no mods. Carbon loads the same plugin files and supports the same API.

This guide explains each building block, then ends with a complete plugin you can drop into oxide/plugins (or carbon/plugins).

How CUI works

A CUI is a tree of named elements. Each element has a name, a parent, and components (a rectangle, an image, text, a button). The flow is always the same:

  1. Create a CuiElementContainer.
  2. Add elements to it. Each Add call returns the element's name.
  3. Send the container to one player with CuiHelper.AddUi(player, container).
  4. Later, remove an element with CuiHelper.DestroyUi(player, name). Destroying a parent also removes its children.

The server does not keep track of what the client currently displays. If you send something, it is your job to remember it exists and to remove it. Most CUI bugs come from forgetting this.

You need these namespaces:

  • using Oxide.Game.Rust.Cui; for the container, elements and CuiHelper
  • using UnityEngine; for TextAnchor

The core classes

ClassPurpose
CuiElementContainerA list of elements you send together. Use Add(...) to append.
CuiPanelA rectangle with an optional background color and cursor control. Used as a window or a layout box.
CuiLabelText with font size, alignment and color.
CuiButtonA clickable rectangle with text that runs a console command.

Each element has a RectTransform for position, plus a type-specific part: Image for panels, Text for labels, and both Button and Text for buttons.

Adding elements

container.Add(element, parent, name) takes the element, the name of the parent, and the name of the new element. If you omit the name, a random one is generated. You can only destroy an element later if you know its name, so name everything you plan to update or remove.

C#
var container = new CuiElementContainer();

string panel = container.Add(new CuiPanel
{
    Image = { Color = "0.1 0.1 0.1 0.9" },
    RectTransform = { AnchorMin = "0.35 0.3", AnchorMax = "0.65 0.7" },
    CursorEnabled = true
}, "Overlay", "MyPlugin.Panel");

container.Add(new CuiLabel
{
    Text = { Text = "Hello", FontSize = 18, Align = TextAnchor.MiddleCenter, Color = "1 1 1 1" },
    RectTransform = { AnchorMin = "0 0.7", AnchorMax = "1 1" }
}, panel);

The label is added to panel, which is the name returned by the first call, so it is positioned relative to the panel and disappears with it.

Anchors and offsets

Every element is positioned inside its parent's rectangle using two corners:

  • AnchorMin is the bottom-left corner.
  • AnchorMax is the top-right corner.

Both are written as a string "x y" with values from 0 to 1, where "0 0" is the parent's bottom-left and "1 1" is its top-right. Note that the origin is bottom-left, so a larger y value is higher on screen.

Examples, relative to the parent:

  • Full size: "0 0" to "1 1"
  • Top quarter: "0 0.75" to "1 1"
  • Centered box 30% wide, 40% tall: "0.35 0.3" to "0.65 0.7"

Anchors are fractions, so they scale with resolution and aspect ratio of the parent.

Offsets

OffsetMin and OffsetMax are pixel values added to the anchored corners. They are useful for padding:

C#
RectTransform =
{
    AnchorMin = "0 0", AnchorMax = "1 1",
    OffsetMin = "10 10", OffsetMax = "-10 -10"
}

This fills the parent with a 10 pixel margin on every side. Positive values move a corner right/up, negative values left/down. You can also anchor a corner pair to a single point (for example "0.5 0.5" for both) and use offsets alone to get a fixed pixel size, such as a 200 by 40 button centered on the screen. Mixing the two approaches heavily leads to layouts that look different across resolutions, so use anchors for structure and offsets for small adjustments.

Colors

Colors are strings of four numbers, "r g b a", each from 0 to 1 and separated by spaces:

  • "1 1 1 1" is opaque white
  • "0 0 0 0.8" is mostly opaque black
  • "0.7 0.2 0.2 1" is a dark red

Values are not 0-255 and there is no hex support in the API itself. If you want hex colors in a config file, write a small helper that converts the hex string into four floats and formats them as a string. Alpha controls transparency, so use something like 0.85 for panel backgrounds so the game stays visible behind a window.

Layers and parent names

The parent string decides where in the screen's draw order your element sits. The two names you will use most:

ParentBehavior
OverlayDraws on top of the HUD and most menus. Use it for windows and anything that should be impossible to miss.
HudPart of the in-game HUD layer. Use it for persistent, unobtrusive elements such as counters and status bars.

Rust has a few other built-in layers (for example Hud.Menu, Under and Overall), but Overlay and Hud cover nearly every plugin.

A parent can be either one of those built-in names or the name of an element you created earlier. The parent must already exist on the client when the child arrives. Within a single container this works as long as the parent is added before its children. If you send a child to a parent that was destroyed, never sent, or misspelled, the client rejects it and the server logs AddUI Unknown Parent.

Pick names that are unlikely to collide with other plugins. Prefixing with the plugin name, like MyPlugin.Panel, is the usual convention.

CursorEnabled

Setting CursorEnabled = true on a panel releases the mouse cursor so the player can click buttons. While any such panel is on screen the player cannot aim or move the camera normally. Rules to follow:

  • Set it on the top-level window panel, not on every child.
  • Always provide a way to close the UI, with a close button and ideally a chat or console command as backup.
  • Do not enable it on permanent HUD elements, which would lock the player's view permanently.

If a cursor-enabled panel is left behind (for example after a plugin reload that did not clean up), players get stuck with a visible cursor until they reconnect.

Buttons and console commands

A CuiButton runs the string in Button.Command as a console command on behalf of the player who clicked it. Your plugin receives that through a [ConsoleCommand] method:

C#
container.Add(new CuiButton
{
    Button = { Command = "myplugin.close", Color = "0.7 0.2 0.2 1" },
    RectTransform = { AnchorMin = "0.4 0.05", AnchorMax = "0.6 0.2" },
    Text = { Text = "Close", FontSize = 14, Align = TextAnchor.MiddleCenter }
}, panel);
C#
[ConsoleCommand("myplugin.close")]
private void CcmdClose(ConsoleSystem.Arg arg)
{
    var player = arg.Player();
    if (player == null) return;
    CuiHelper.DestroyUi(player, PanelName);
}

Things to keep in mind:

  • arg.Player() is null when the command comes from the server console or RCON, so always check it.
  • Players can type your console command by hand, so never assume that only your button triggers it. Re-check permissions and state inside the command.
  • You can pass parameters in the command string, for example "myplugin.page 2", and read them with arg.GetInt(0, 1).
  • Console command names should be unique, so use a pluginname.action format. For more on the difference to chat commands, see chat commands vs console commands.

Buttons also have a Close property that takes the name of a panel. The client then removes that panel immediately without a server round trip. This is fast, but the server never learns that the UI is gone, so any state you track will be out of sync. Running a console command and destroying on the server is the safer default.

Destroying UI

CuiHelper.DestroyUi(player, name) removes the named element and everything under it. It is safe to call even when the element does not exist, which makes it the standard first step before showing a UI:

C#
CuiHelper.DestroyUi(player, PanelName);
CuiHelper.AddUi(player, container);

You should destroy UI when:

  • The player clicks close.
  • The player should no longer see it (for example a timed notification expired). See timers in Rust plugins.
  • The plugin unloads, for every online player.
  • Optionally on player events such as OnPlayerDisconnected, mainly to clear your own tracking data. The client UI itself disappears on disconnect.

Updating UI

CUI has no "edit this element" call. To change something, destroy it and send a new version with the same name. There are two common patterns:

  • Whole redraw: destroy the root panel and rebuild everything. Simple, but it can flicker and sends more data.
  • Partial redraw: put the changing part (a counter, a timer, a list) in its own named element, destroy only that element, and re-add it to the existing parent. This is cheaper and avoids flicker.

The example below uses the partial approach for a click counter. For frequently changing displays, avoid redrawing every tick. Update once per second or only on change; see Rust plugin performance.

Complete example: a closeable panel

This plugin adds the chat command /cuipanel. It opens a centered panel with a title, a click counter, a button that increments the counter, and a close button. It uses a permission, localized text, and a config option for the panel color, and cleans up on unload.

C#
using System;
using System.Collections.Generic;
using Newtonsoft.Json;
using Oxide.Game.Rust.Cui;
using UnityEngine;

namespace Oxide.Plugins
{
    [Info("Cui Basics", "RustPluginGenerator", "1.0.0")]
    [Description("Shows a closeable example panel with a click counter to demonstrate Rust CUI")]
    public class CuiBasics : RustPlugin
    {
        private const string PermUse = "cuibasics.use";
        private const string PanelName = "CuiBasics.Panel";
        private const string CounterName = "CuiBasics.Counter";

        // Players who currently have the panel open, with their click count
        private readonly Dictionary<ulong, int> clicks = new Dictionary<ulong, int>();

        private PluginConfig config;

        private class PluginConfig
        {
            [JsonProperty("Panel color (r g b a)")]
            public string PanelColor = "0.1 0.1 0.1 0.92";

            [JsonProperty("Close button color (r g b a)")]
            public string CloseColor = "0.7 0.2 0.2 1";

            [JsonProperty("Action button color (r g b a)")]
            public string ActionColor = "0.2 0.5 0.3 1";
        }

        protected override void LoadDefaultConfig() => config = new PluginConfig();

        protected override void LoadConfig()
        {
            base.LoadConfig();
            try
            {
                config = Config.ReadObject<PluginConfig>();
                if (config == null) throw new Exception();
            }
            catch
            {
                PrintWarning("Configuration file is invalid, using defaults");
                LoadDefaultConfig();
            }
            SaveConfig();
        }

        protected override void SaveConfig() => Config.WriteObject(config);

        protected override void LoadDefaultMessages()
        {
            lang.RegisterMessages(new Dictionary<string, string>
            {
                ["NoPermission"] = "You do not have permission to use this command.",
                ["Title"] = "CUI Example",
                ["Clicks"] = "Button clicks: {0}",
                ["Click"] = "Click me",
                ["Close"] = "Close"
            }, this);
        }

        private string Msg(string key, string userId = null, params object[] args) =>
            string.Format(lang.GetMessage(key, this, userId), args);

        private void Init()
        {
            permission.RegisterPermission(PermUse, this);
        }

        private void Unload()
        {
            foreach (var player in BasePlayer.activePlayerList)
            {
                if (player == null) continue;
                CuiHelper.DestroyUi(player, PanelName);
            }
            clicks.Clear();
        }

        private void OnPlayerDisconnected(BasePlayer player)
        {
            if (player == null) return;
            clicks.Remove(player.userID.Get());
        }

        [ChatCommand("cuipanel")]
        private void CmdPanel(BasePlayer player, string command, string[] args)
        {
            if (player == null) return;

            if (!permission.UserHasPermission(player.UserIDString, PermUse))
            {
                SendReply(player, Msg("NoPermission", player.UserIDString));
                return;
            }

            clicks[player.userID.Get()] = 0;
            ShowPanel(player);
        }

        [ConsoleCommand("cuibasics.close")]
        private void CcmdClose(ConsoleSystem.Arg arg)
        {
            var player = arg.Player();
            if (player == null) return;

            CuiHelper.DestroyUi(player, PanelName);
            clicks.Remove(player.userID.Get());
        }

        [ConsoleCommand("cuibasics.click")]
        private void CcmdClick(ConsoleSystem.Arg arg)
        {
            var player = arg.Player();
            if (player == null) return;

            // Ignore the command if the panel is not open for this player
            int count;
            ulong id = player.userID.Get();
            if (!clicks.TryGetValue(id, out count)) return;

            clicks[id] = count + 1;
            UpdateCounter(player);
        }

        private void ShowPanel(BasePlayer player)
        {
            // Remove an existing copy first to avoid duplicate element names
            CuiHelper.DestroyUi(player, PanelName);

            var container = new CuiElementContainer();

            string panel = container.Add(new CuiPanel
            {
                Image = { Color = config.PanelColor },
                RectTransform = { AnchorMin = "0.35 0.3", AnchorMax = "0.65 0.7" },
                CursorEnabled = true
            }, "Overlay", PanelName);

            container.Add(new CuiLabel
            {
                Text = { Text = Msg("Title", player.UserIDString), FontSize = 22, Align = TextAnchor.MiddleCenter, Color = "1 1 1 1" },
                RectTransform = { AnchorMin = "0 0.8", AnchorMax = "1 1" }
            }, panel);

            container.Add(new CuiButton
            {
                Button = { Command = "cuibasics.click", Color = config.ActionColor },
                RectTransform = { AnchorMin = "0.1 0.3", AnchorMax = "0.9 0.45" },
                Text = { Text = Msg("Click", player.UserIDString), FontSize = 16, Align = TextAnchor.MiddleCenter, Color = "1 1 1 1" }
            }, panel);

            container.Add(new CuiButton
            {
                Button = { Command = "cuibasics.close", Color = config.CloseColor },
                RectTransform = { AnchorMin = "0.3 0.08", AnchorMax = "0.7 0.2" },
                Text = { Text = Msg("Close", player.UserIDString), FontSize = 14, Align = TextAnchor.MiddleCenter, Color = "1 1 1 1" }
            }, panel);

            CuiHelper.AddUi(player, container);
            UpdateCounter(player);
        }

        private void UpdateCounter(BasePlayer player)
        {
            int count;
            if (!clicks.TryGetValue(player.userID.Get(), out count)) return;

            // Replace only the counter label; its parent panel already exists on the client
            CuiHelper.DestroyUi(player, CounterName);

            var container = new CuiElementContainer();
            container.Add(new CuiLabel
            {
                Text = { Text = Msg("Clicks", player.UserIDString, count), FontSize = 18, Align = TextAnchor.MiddleCenter, Color = "1 1 1 1" },
                RectTransform = { AnchorMin = "0 0.55", AnchorMax = "1 0.78" }
            }, PanelName, CounterName);

            CuiHelper.AddUi(player, container);
        }
    }
}

How the example works

  • ShowPanel destroys any existing panel with the same name, then builds the window on Overlay. The panel has CursorEnabled = true, so the player can click.
  • Both buttons run console commands registered with [ConsoleCommand]. The close command destroys the panel and forgets the player.
  • UpdateCounter only replaces the counter label. It uses the panel name as the parent, which is valid because the panel was sent earlier. If the player had closed the panel, the dictionary check stops the update and avoids an unknown parent error.
  • Unload loops through BasePlayer.activePlayerList and destroys the panel for everyone, so reloading the plugin never leaves orphaned UI.
  • Text goes through the lang API, so server owners can translate it. See Rust plugin localization and the config file guide.

To try it, grant the permission with oxide.grant user <name or id> cuibasics.use (details in Oxide permissions and groups), then type /cuipanel in chat.

Common mistakes

AddUI Unknown Parent

The client was told to attach an element to a parent it does not know. Typical causes:

  • A typo in the parent name, or a case mismatch.
  • The parent panel was destroyed (by you, by the Close property, or because the player disconnected) before the child was sent.
  • The parent was never sent to that player, for example because you built the container for one player and sent a child container to another.
  • A child is added to a container before its parent is added.

Fix it by tracking whether the parent is open before updating a child, as the example does. The full explanation is in AddUI Unknown Parent.

Duplicate element names

Sending a second element with the same name does not replace the first. You end up with two stacked copies, and later DestroyUi calls may behave in ways you did not intend. Always call DestroyUi for a name before adding it again, and give every element you will manage a unique, prefixed name.

UI not removed on unload

When a plugin unloads or reloads, the client keeps displaying what it was last sent. If you do not destroy it in Unload, players see dead buttons that do nothing and, if the panel had a cursor, they may be unable to look around. Always loop over BasePlayer.activePlayerList in Unload and destroy your root panels. Hook reference: Unload.

Other pitfalls

  • Null players. arg.Player() is null from the server console, and players can disconnect between your timer firing and your code running. Check for null before every AddUi or DestroyUi.
  • Wrong color scale. Using 0-255 values gives you solid, blown-out colors. Use 0 to 1.
  • Forgetting a close path. Any cursor-enabled panel needs a close button.
  • Trusting the button. Console commands can be typed manually. Re-check permissions and state inside the command.
  • Sending UI from hot hooks. Do not rebuild and send containers from hooks that fire constantly, such as OnPlayerInput or OnTick. Update on change or at a slow interval.
  • Oversized containers. Every element is sent as JSON. Long lists with hundreds of elements are heavy for both the server and the client, so paginate.

Where to go next

FAQ

Do I need a client mod or Unity project to use CUI?

No. CUI is built entirely in your C# plugin. The server serializes your element container to JSON and sends it to the player, and the stock Rust client draws it. Players install nothing. The trade-off is that you are limited to the component types the vanilla client supports, such as panels, text, buttons and images.

Why does my panel look different at other screen resolutions?

Anchors are fractions of the parent's size, so they scale with the screen. Offsets are fixed pixel values and do not scale the same way. If you mix large pixel offsets with anchors, layouts drift between resolutions. Prefer anchors for overall layout and use small offsets only for padding and borders.

Can a button run a chat command instead of a console command?

Yes. The button's Command is executed as if the player typed it in the F1 console. A chat command can be triggered with chat.say "/yourcommand". For plugin-internal buttons, a dedicated [ConsoleCommand] is cleaner because it receives a ConsoleSystem.Arg and needs no chat parsing.

How do I show a UI to everyone online?

Loop over BasePlayer.activePlayerList, null-check each player and call CuiHelper.AddUi(player, container) for each. The same container can be reused for all players if its content is identical. If the content is per-player, such as a name or balance, build a new container per player.

Does the UI survive a plugin reload?

No, not automatically. Reloading calls Unload, and the client keeps whatever was already drawn until something destroys it or the player disconnects. Destroy your elements in Unload, otherwise players can be left with orphaned panels and a stuck cursor after every reload.

Skip the C#: describe your plugin

Open generator