Guides

Menus

Menus with numbered options, paging, per-player instances and cleanup on unload — in the center panel (CenterHtmlMenu) or on the Panorama HUD (HudMenu).

ISource2ModMenus shows a menu to a player and turns their !1–!9 presses into option callbacks. You build the menu, Source2Mod keeps one active instance per player and handles paging and the close button. There are two kinds of screen: a CenterHtmlMenu, drawn in the center panel every frame, and a HudMenu, drawn by Panorama with clickable rows — same IBaseMenu, everything on this page applies to both; the HUD one needs an addon on the client, see HUD menus.

plugin.cpp
#include "source2mod/ISource2ModMenus.h"

Building and opening a menu

A menu is a CenterHtmlMenu (an IBaseMenu) with a title and a list of ChatMenuOptions. AddMenuOption() takes the display text, the callback and whether the option starts disabled.

plugin.cpp
static CenterHtmlMenu g_TeamMenu("Pick a team");

void BuildTeamMenu()
{
    g_TeamMenu.ClearOptions();

    g_TeamMenu.AddMenuOption("Terrorists", [](CCSPlayerController* player, ChatMenuOption& option)
    {
        player->PrintToChat("You picked T.");
    });

    g_TeamMenu.AddMenuOption("Counter-Terrorists", [](CCSPlayerController* player, ChatMenuOption& option)
    {
        player->PrintToChat("You picked CT.");
    });

    // Shown in the disabled colour, cannot be selected.
    g_TeamMenu.AddMenuOption("Spectators (closed)", nullptr, true);
}

REGISTER_CON_COMMAND("team_menu", [](const Source2ModCommandContext& context, const Source2ModCommandArgs& args, bool post)
{
    CCSPlayerController* player = CCSPlayerController::FromSlot(context.GetPlayerSlot());
    if (!player)
        return;

    OPEN_CENTER_HTML_MENU(player, &g_TeamMenu);
});

OPEN_CENTER_HTML_MENU(player, menu) expands to g_pSource2ModMenus->OpenCenterHtmlMenu(g_PluginID, player, menu); a script calls Source2ModMenus.OpenCenterHtmlMenu(player, menu). Opening a menu closes whatever the player had open before. A script's option handlers are named functions; they get the player, the menu, the option's index and the data passed to AddMenuOption().

How players choose

Options are numbered from !1 on each page. A page holds five options (four once both the previous and next buttons are shown, one more without the exit button); !7 goes to the previous page, !8 to the next, and !9 closes the menu when the exit button is on (SetExitButton(true), the default for CenterHtmlMenu). Source2Mod registers the 1–9 commands itself, so the keys work in chat (!3) and from the console (3, bindable to a key).

On the HUD

A HudMenu is opened with g_pCustomhudManager->OpenMenu(g_PluginID, player, menu) instead — the CustomhudManager_s2t plugin's interface, fetched in OnAllSource2ModPluginsLoaded(). Its rows are clickable, 1–6 pick the options of the page (7, 8, 9 as above), and the navigation texts are fields of the menu so they can be in the player's language:

static HudMenu g_TeamMenu("Pick a team");
g_TeamMenu.PrevText = "Zpět";
g_TeamMenu.NextText = "Další";
g_TeamMenu.CloseText = "Zavřít";
g_pCustomhudManager->OpenMenu(g_PluginID, player, &g_TeamMenu);

CaptureInput (cursor and clicks, or keys with free movement), CaptureWhenDead (the cursor for a dead player, whose binds do nothing) and Position (left, center, right) are fields of the menu too — see HUD menus. It is not redrawn every frame, only when what would be drawn changed. A player without the layout sees nothing, so a public server ships the addon first. HudMenu is C++ only; a script uses CenterHtmlMenu.

A menu of your own

The core's menu system takes instances other libraries build (ISource2ModMenus::OpenMenu, ISource2ModMenus005): derive from IMenuInstance, draw in Display(), react in OnKeyPress(), and the core does the rest — it holds one instance per player, calls OnFrame() every frame (redraw, or refresh only when something changed), feeds it the 1–9 keys unless AcceptsKeys() says no, and frees it through Destroy() when it is closed, replaced, the player leaves or your plugin unloads. That is how the s2t_hud plugin serves HudMenu; a plugin with a screen of its own does the same (C++ only — a script cannot implement IMenuInstance):

class MyScreenMenu final : public IMenuInstance
{
public:
    MyScreenMenu(CCSPlayerController* player, IBaseMenu* menu) : IMenuInstance(player, menu) {}
    void Display() override { /* draw the current page somewhere */ }
    void OnKeyPress(CCSPlayerController* player, int key) override { /* pick, page, close */ }
    void Close() override { CLOSE_ACTIVE_MENU(player_); }
};

g_pSource2ModMenus->OpenMenu(g_PluginID, player, new MyScreenMenu(player, &g_Menu));

Appearance

CenterHtmlMenu has public colour fields used when the HTML is drawn: TitleColor, EnabledColor, DisabledColor, PrevPageColor, NextPageColor, CloseColor. A script sets them with SetColor() and MenuColor_Title, MenuColor_Enabled, MenuColor_Disabled, MenuColor_PrevPage, MenuColor_NextPage, MenuColor_Close.

CenterHtmlMenu menu("Admin");
menu.TitleColor = "red";
menu.DisabledColor = "#555555";

What happens after a selection

SetPostSelectAction() decides what the menu does once an option's callback returned:

PostSelectActionEffect
Reset (default)Stays open, jumps back to the first page
CloseCloses the menu
NothingStays open on the current page

In a script it is the PostSelect property (PostSelect_Reset, PostSelect_Close, PostSelect_Nothing); a single option can override it with the MenuOption_Close or MenuOption_Stay flag (SetOptionFlags()).

If the callback opened another menu or closed this one, the post-select action is skipped — the instance is no longer the active one.

Options that change state

ChatMenuOption::Disabled can be flipped at any time, and DisabledEvaluator (a std::function<bool()>) is evaluated every time the menu is drawn, so an option can grey itself out while the menu is on screen. A script uses SetOptionDisabled(option, disabled) and SetOptionDisabledEvaluator(option, callback), the callback asked for each player the menu is shown to.

ChatMenuOption& buy = menu.AddMenuOption("Buy AWP", OnBuyAwp);
buy.DisabledEvaluator = [] { return !g_bBuyTimeOpen; };

Global cooldown

AddMenuOptionWithCooldown(text, action, disabled = false, close = true, disabledEvaluator = nullptr) wraps the callback in two global hooks you install once with IBaseMenu::SetCooldownHandlers(canSelect, onSelect): canSelect(player) is asked before the action runs (return false to swallow the press), onSelect runs after it. With close = true the menu is closed after the action and the option's text is shown in its place. This is how FUNPLAY rate-limits every menu at once:

plugin.cpp — Load()
IBaseMenu::SetCooldownHandlers(
    [](CCSPlayerController* player) { return !IsOnMenuCooldown(player); },
    [](CCSPlayerController* player) { StartMenuCooldown(player); });

The handlers are static members of IBaseMenu — global to your plugin's binary, not per menu. SetCooldownHandlers() is C++ only: a script's AddMenuOptionWithCooldown() uses the default handlers (with close, the menu closes and the option's text is shown centered for 5 seconds).

The active menu

CallMacroWhat it does
GetActiveMenu(player)GET_ACTIVE_MENU(player)The player's open IMenuInstance*, or nullptr
CloseActiveMenu(player)CLOSE_ACTIVE_MENU(player)Closes it
OnKeyPress(player, key)MENU_ON_KEY_PRESS(player, key)Feeds a key (1–9) to the open menu, as if the player had typed it

IMenuInstance::Menu() returns the IMenu* behind the instance, so you can tell whether the open menu is yours. A script calls Source2ModMenus.GetActiveMenu(player), which returns only the script's own menus (null otherwise), CloseActiveMenu(player) and OnKeyPress(player, key); HasActiveMenu(player) tells whether any menu is open:

IMenuInstance* active = GET_ACTIVE_MENU(player);
if (active && active->Menu() == &g_TeamMenu)
    CLOSE_ACTIVE_MENU(player);

Close menus whose options no longer make sense — FUNPLAY closes the menu of a player who died or disconnected:

events.cpp
HOOK_GAME_EVENT("player_death", [](IGameEvent* event, bool post, bool& dontBroadcast) -> Action
{
    auto* victim = static_cast<CCSPlayerController*>(event->GetPlayerController("userid"));
    if (victim)
        CLOSE_ACTIVE_MENU(victim);
    return Action::Ignore;
}, true);

Ownership and lifetime

  • You own the menu object. Source2Mod stores a raw CenterHtmlMenu* / HudMenu* in the player's instance and never deletes it. It has to stay alive for as long as it is open for anyone: a static or member menu, or a per-player std::unique_ptr you replace only after the new menu is open. A script's CenterHtmlMenu handle is its own until it is deleted, and deleting it closes it for everyone who has it open.
  • Do not free or rebuild a menu from inside one of its own option callbacks. The callback being executed is a std::function stored in that menu's option list. Opening a different, still-alive menu from a callback is fine; freeing the current one or calling ClearOptions() on it is not. Defer that with NEXT_FRAME (Source2ModScheduler.NextFrame in a script), and carry a handle rather than the raw player pointer (a script's player is a handle already):
menu.AddMenuOption("Refresh", [](CCSPlayerController* player, ChatMenuOption&)
{
    CHandle<CCSPlayerController> hPlayer = player->GetHandle();
    NEXT_FRAME([hPlayer]()
    {
        if (CCSPlayerController* p = hPlayer.Get())
            OpenFreshMenu(p); // rebuilds / replaces the old menu safely
    });
});
  • One instance per player slot. Opening a menu for a player replaces whatever was open, including another plugin's menu.
  • Unload. Every menu your plugin still has open is closed for you when it unloads — the option handlers live in your library, and the next key press would jump into unmapped code. Nothing to do in Unload().
  • Main thread only. Open and close menus from game callbacks, commands, timers or NEXT_FRAME, never from a worker thread.

Reference

On this page