Menus

How to create interactive WASD button, chat, console, and center-HTML menus for players.

The menu system lets you build interactive, paginated lists of options that players can navigate and select from. The same creation/item API works identically across four built-in presentation styles — a WASD-navigated on-screen panel, or classic digit-driven chat, console, and center-HTML menus — so you write your menu logic once and pick (or let players pick) how it's displayed.

Overview

There are four built-in menu types, selected by name when you create or display a menu:

TypeHow the player interacts
button (default)WASD-style: move a cursor with keys, select with another key. Rendered as an on-screen HUD panel.
chatDigit-driven: item lines are printed to chat, selected by typing a command (see Menu Types).
consoleSame digit-driven input, printed to the player's console instead of chat.
centerhtmlSame digit-driven input, rendered as a center-screen HTML panel instead of plain text.

You can also register your own presentation style with RegisterMenuType — see Custom Menu Types.

Quick Example: A Chat Menu

Here's a complete, minimal menu bound to a console command: it creates a menu, adds three items, and displays it to whoever ran the command.

c#
c++
python
go
js
lua
using Plugify;
using static s2sdk.s2sdk;

public unsafe class Sample : Plugin
{
    public void OnPluginStart()
    {
        var flags = ConVarFlag.LinkedConcommand | ConVarFlag.Release | ConVarFlag.ClientCanExecute;
        AddConsoleCommand("sm_colors", "Opens a color picker menu", flags, Command_Colors, HookMode.Post);
    }

    public ResultType Command_Colors(int caller, CommandCallingContext context, string[] arguments)
    {
        if (caller == -1) return ResultType.Handled;

        uint menu = CreateMenu("Pick a Color", OnColorMenu, "chat");
        AddMenuItem(menu, "red", "Red", MenuItemStyle.Default);
        AddMenuItem(menu, "green", "Green", MenuItemStyle.Default);
        AddMenuItem(menu, "blue", "Blue", MenuItemStyle.Default);
        DisplayMenu(menu, caller, 0);

        return ResultType.Handled;
    }

    public static void OnColorMenu(uint id, MenuAction action, int playerSlot, int param)
    {
        switch (action)
        {
            case MenuAction.Select:
                string info = GetMenuItemInfoText(id, param);
                PrintToChat(playerSlot, $"You picked: {info}");
                break;
            case MenuAction.End:
                DestroyMenu(id);
                break;
        }
    }
}

Using the Menu Class

For languages that support it, a Menu class wraps the handle so you can call menu.AddItem(...) instead of AddMenuItem(menu, ...). Its constructor forwards straight to CreateMenu, and it has a destructor that calls DestroyMenu for you — but that destructor runs on normal scope exit, same as any other RAII object, so it's not a fit-and-forget replacement for the DestroyMenu call in your handler's End case.

c#
c++
using Plugify;
using static s2sdk.s2sdk;

public unsafe class Sample : Plugin
{
    public void OnPluginStart()
    {
        var flags = ConVarFlag.LinkedConcommand | ConVarFlag.Release | ConVarFlag.ClientCanExecute;
        AddConsoleCommand("sm_colors", "Opens a color picker menu", flags, Command_Colors, HookMode.Post);
    }

    public ResultType Command_Colors(int caller, CommandCallingContext context, string[] arguments)
    {
        if (caller == -1) return ResultType.Handled;

        var menu = new Menu("Pick a Color", OnColorMenu, "chat");
        menu.AddItem("red", "Red", MenuItemStyle.Default);
        menu.AddItem("green", "Green", MenuItemStyle.Default);
        menu.AddItem("blue", "Blue", MenuItemStyle.Default);
        menu.Display(caller, 0);

        return ResultType.Handled;
    }

    public static void OnColorMenu(uint id, MenuAction action, int playerSlot, int param)
    {
        switch (action)
        {
            case MenuAction.Select:
                string info = GetMenuItemInfoText(id, param);
                PrintToChat(playerSlot, $"You picked: {info}");
                break;
            case MenuAction.End:
                DestroyMenu(id); // cleanup happens here, not via Menu's Dispose/finalizer
                break;
        }
    }
}

The rest of this guide uses the plain function-call style throughout, since it's identical across every language — everything shown also works as a menu.MethodName(...) call through the class if you prefer it.

The Menu Handler Callback

Every menu is driven by a single handler function you pass to CreateMenu. It's called with a MenuAction describing what just happened:

ActionWhenparam
StartThe menu was just displayed to a clientunused
SelectThe client selected an itemThe absolute index of the selected item — pass it to GetMenuItemInfoText/GetMenuItemDisplay
CancelThe display session ended without a final selectionA MenuCancelReason (see Submenus and Going Back)
EndThe display session is fully closed — always sent last, after Select or Cancelunused

Each item you add has two separate strings:

  • info — an internal identifier, never shown to the client. Use it to know which item was picked without depending on display text (which might change, or be localized).
  • display — the text actually shown to the player.

There's no dedicated "submenu" item type — you build multi-level menus by creating and displaying a new menu from inside your handler's Select case. To let the player go back to the parent menu, call SetMenuExitBackButton on the child menu: this replaces its normal exit option with a "back" option that reports MenuCancelReason.ExitBack to your Cancel case instead of MenuCancelReason.Exit, so you can tell the two apart and redisplay the parent.

MenuCancelReasonMeaning
ExitThe client used the normal exit option
TimeoutThe display's timer ran out
DisconnectThe client disconnected while the menu was open
InterruptedAnother DisplayMenu call replaced this display for the client
DestroyedThe menu handle was destroyed while it was being displayed
ExitBackThe client used the "back" option set up by SetMenuExitBackButton
c#
c++
python
go
js
lua
using Plugify;
using static s2sdk.s2sdk;

public unsafe class Sample : Plugin
{
    public void OnPluginStart()
    {
        var flags = ConVarFlag.LinkedConcommand | ConVarFlag.Release | ConVarFlag.ClientCanExecute;
        AddConsoleCommand("sm_settings", "Opens a settings menu", flags, Command_Settings, HookMode.Post);
    }

    uint CreateMainMenu()
    {
        uint menu = CreateMenu("Settings", OnMainMenu, "chat");
        AddMenuItem(menu, "hello", "Say Hello", MenuItemStyle.Default);
        AddMenuItem(menu, "colors", "Color Options", MenuItemStyle.Default);
        return menu;
    }

    public ResultType Command_Settings(int caller, CommandCallingContext context, string[] arguments)
    {
        if (caller == -1) return ResultType.Handled;
        DisplayMenu(CreateMainMenu(), caller, 0);
        return ResultType.Handled;
    }

    public void OnMainMenu(uint id, MenuAction action, int playerSlot, int param)
    {
        switch (action)
        {
            case MenuAction.Select:
                string info = GetMenuItemInfoText(id, param);
                if (info == "hello")
                {
                    PrintToChat(playerSlot, "Hello!");
                }
                else if (info == "colors")
                {
                    uint sub = CreateMenu("Color Options", OnColorSubMenu, "chat");
                    AddMenuItem(sub, "red", "Red", MenuItemStyle.Default);
                    AddMenuItem(sub, "blue", "Blue", MenuItemStyle.Default);
                    SetMenuExitBackButton(sub, true); // shows "Back" instead of "Exit"
                    DisplayMenu(sub, playerSlot, 0);
                }
                break;
            case MenuAction.End:
                DestroyMenu(id);
                break;
        }
    }

    public void OnColorSubMenu(uint id, MenuAction action, int playerSlot, int param)
    {
        switch (action)
        {
            case MenuAction.Select:
                string info = GetMenuItemInfoText(id, param);
                PrintToChat(playerSlot, $"Color set to: {info}");
                break;
            case MenuAction.Cancel:
                if ((MenuCancelReason)param == MenuCancelReason.ExitBack)
                {
                    DisplayMenu(CreateMainMenu(), playerSlot, 0);
                }
                break;
            case MenuAction.End:
                DestroyMenu(id);
                break;
        }
    }
}

Besides AddMenuItem (appends to the end), you can insert at a specific position and manage existing items:

Item styleMeaning
DefaultShown, numbered, selectable
DisabledShown, numbered, not selectable — cursor-driven types skip over it
SpacerBlank line, not numbered, not selectable
int index = InsertMenuItemAt(menu, 0, "vip_only", "VIP Perks", MenuItemStyle.Disabled);
RemoveMenuItem(menu, index);
RemoveAllMenuItems(menu);

int count = GetMenuItemsCount(menu);
string info = GetMenuItemInfoText(menu, 0);
string display = GetMenuItemDisplay(menu, 0);
MenuItemStyle style = GetMenuItemStyle(menu, 0);
bool selectable = IsMenuItemSelectable(menu, 0);

SetMenuItemDisplay(menu, 0, "Updated Text");
SetMenuItemStyle(menu, 0, MenuItemStyle.Disabled);

Displaying and Navigating

DisplayMenu(id, playerSlot, time) shows a menu starting at its first item; DisplayMenuAtItem(id, playerSlot, firstItem, time) starts at a specific item instead. time is a timeout in seconds — 0 or negative means no timeout, and the display session ends with MenuCancelReason.Timeout if it expires.

If a menu has more items than fit on one page, use SetMenuPagination to control how many items are shown per page (0 disables pagination — all items render at once):

SetMenuPagination(menu, 5); // 5 items per page
int perPage = GetMenuPagination(menu);

bool hasPrev = ClientMenuHasPrevPage(playerSlot);
bool hasNext = ClientMenuHasNextPage(playerSlot);
MenuNextPage(playerSlot);
MenuPrevPage(playerSlot);

By default, selecting an item automatically closes the menu display (MenuAction.Select fires, then the display closes, then MenuAction.End fires). Turn this off with SetMenuCloseOnSelect(menu, false) if you want the menu to stay open after a selection — your handler then becomes responsible for closing or redisplaying it (e.g. a toggle-style options menu that redraws itself after each pick).

Set a menu's presentation style at creation time (CreateMenu's third argument) or later with SetMenuType/GetMenuType. An empty string uses the server's default type — SetDefaultMenuType/GetDefaultMenuType control which one that is (built-in default is button).

button is the odd one out: it's WASD-navigated (move a cursor, select with a key) and rendered as an on-screen HUD panel instead of numbered text — see the next section for how to configure it.

Customizing the WASD Button Menu

The button type's controls are configured server-side, in settings.jsonc, not through the plugin API — this lets server owners tune it without every plugin needing to expose its own settings for the same thing.

// Freeze the player's movement while a WASD button menu is open
"MenuButtonFreezePlayer": true,

// Max items shown per page; keeps the item area + footer at a fixed, always-visible height
"MenuButtonMaxItems": 6,

// Buttons driving the cursor (InputBitMask_t names, e.g. IN_FORWARD, IN_BACK, IN_USE, IN_MOVELEFT, IN_MOVERIGHT, IN_RELOAD, ...)
"MenuButtonKeyUp": "IN_FORWARD",
"MenuButtonKeyDown": "IN_BACK",
"MenuButtonKeyLeft": "IN_MOVELEFT",   // jumps a full page back, faster than Up/Down
"MenuButtonKeyRight": "IN_MOVERIGHT", // jumps a full page forward
"MenuButtonKeySelect": "IN_USE",
"MenuButtonKeyExit": "IN_RELOAD",

// Panorama CSS classes (e.g. "fontSize-sm") to shrink/grow the title, item list, and footer independently
"MenuButtonBodyFontClass": "",
"MenuButtonTitleFontClass": "",
"MenuButtonFooterFontStyle": "",

// Sound events played on interaction; leave empty to play nothing
"MenuSoundScroll": "",
"MenuSoundClick": "",
"MenuSoundBack": "",
"MenuSoundExit": "",
"MenuSoundDisabled": ""

Custom Menu Types (Advanced)

If none of the built-in presentation styles fit — say, you want a Panorama-based UI — you can register your own backend with RegisterMenuType(name, display, close, frame). display/close are called to render/hide your UI for a client; frame (optional) runs every server frame while a client has your type open, for polling custom input.

RegisterMenuType("my_ui", OnMyDisplay, OnMyClose, OnMyFrame);
UnregisterMenuType("my_ui");
bool registered = IsMenuTypeRegistered("my_ui");
string[] types = GetMenuTypes();

Your display callback reads menu/client state through the same getters plugin authors use (GetMenuTitle, GetMenuItemDisplay, GetClientMenuOffset, GetClientMenuCursor, ...), and your frame/input handling resolves raw input into an action by calling back into the shared plumbing:

  • HandleDigitInput(playerSlot, digit) — the shared 1-7/8/9/0 select-item/prev-page/next-page/exit path used by chat/console/centerhtml.
  • SelectMenuItem(playerSlot, itemIndex) — select a specific absolute item index directly.
  • MenuNextPage(playerSlot) / MenuPrevPage(playerSlot) — shift the display window by one page.

Method Reference

Lifecycle

MethodDescription
CreateMenu(title, handler, menuType)Creates a menu, returning its handle
DestroyMenu(id)Destroys a menu; any client viewing it is cancelled first with MenuCancelReason.Destroyed
IsValidMenu(id)Returns true if the handle refers to an existing menu

Properties

MethodDescription
SetMenuTitle(id, title) / GetMenuTitle(id)The menu's title
SetMenuType(id, typeName) / GetMenuType(id)Which registered menu type renders this menu (empty = default)
SetMenuPagination(id, itemsPerPage) / GetMenuPagination(id)Items per page; 0 disables pagination
SetMenuExitButton(id, enabled) / GetMenuExitButton(id)Whether an exit option is shown
SetMenuExitBackButton(id, enabled) / GetMenuExitBackButton(id)Replaces the exit option with a "back" option (MenuCancelReason.ExitBack)
SetMenuCloseOnSelect(id, enabled) / GetMenuCloseOnSelect(id)Whether selecting an item auto-closes the display

Items

MethodDescription
AddMenuItem(id, info, display, style)Appends an item, returning its index (or -1)
InsertMenuItemAt(id, index, info, display, style)Inserts an item at a specific index
RemoveMenuItem(id, index) / RemoveAllMenuItems(id)Removes one or all items
GetMenuItemsCount(id)Number of items
GetMenuItemInfoText(id, index) / GetMenuItemDisplay(id, index)An item's info/display string
GetMenuItemStyle(id, index) / SetMenuItemStyle(id, index, style)An item's draw style
IsMenuItemSelectable(id, index)true if the item's style is Default
SetMenuItemDisplay(id, index, display)Changes an item's display text

Display & Navigation

MethodDescription
DisplayMenu(id, playerSlot, time) / DisplayMenuAtItem(id, playerSlot, firstItem, time)Shows a menu to a client
CancelClientMenu(playerSlot, reason)Cancels whatever menu a client currently has open
GetClientMenu(playerSlot)The menu handle a client currently has open, or 0
GetClientMenuOffset(playerSlot) / GetClientMenuCursor(playerSlot) / SetClientMenuCursor(playerSlot, index)Current page offset / highlighted cursor index
ClientMenuHasPrevPage(playerSlot) / ClientMenuHasNextPage(playerSlot)Whether more pages exist in either direction
MenuNextPage(playerSlot) / MenuPrevPage(playerSlot)Advances the display by one page

Tips and Best Practices

  1. Always destroy menus you create — call DestroyMenu from your handler's End case, including for submenus you create dynamically.
  2. Key selection logic off info, not display — display text can change (translations, dynamic labels); info strings are yours to keep stable.
  3. Use SetMenuExitBackButton for parent navigation, not a manual "Back" item — it gives you a distinct MenuCancelReason and works consistently across all menu types.
  4. Test with a non-button type first if you're not sure the workshop addon is mounted — chat/console/centerhtml need no external assets and are the fastest way to verify your menu logic works before worrying about presentation.
  5. Don't assume a fixed page size — respect whatever GetMenuPagination reports rather than hardcoding item-per-page counts, since server owners can tune MenuButtonMaxItems and your own SetMenuPagination calls.