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.
How it works: You create a menu handle, add items to it, and display it to a player. Your handler callback is invoked as the player interacts with it (opens it, selects an item, cancels out, or the display closes), so all of your menu's behavior lives in one function.
import { Plugin } from 'plugify';
import * as s2 from ':s2sdk';
function onColorMenu(id, action, playerSlot, param) {
if (action === s2.MenuAction.Select) {
const info = s2.GetMenuItemInfoText(id, param);
s2.PrintToChat(playerSlot, `You picked: ${info}`);
} else if (action === s2.MenuAction.End) {
s2.DestroyMenu(id);
}
}
export class Sample extends Plugin {
pluginStart() {
const flags = s2.ConVarFlag.LinkedConcommand | s2.ConVarFlag.Release | s2.ConVarFlag.ClientCanExecute;
s2.AddConsoleCommand("sm_colors", "Opens a color picker menu", flags,
(caller, context, arguments) => {
if (caller === -1) return s2.ResultType.Handled;
const menu = s2.CreateMenu("Pick a Color", onColorMenu, "chat");
s2.AddMenuItem(menu, "red", "Red", s2.MenuItemStyle.Default);
s2.AddMenuItem(menu, "green", "Green", s2.MenuItemStyle.Default);
s2.AddMenuItem(menu, "blue", "Blue", s2.MenuItemStyle.Default);
s2.DisplayMenu(menu, caller, 0);
return s2.ResultType.Handled;
}, s2.HookMode.Post);
}
}
local plugify = require 'plugify'
local Plugin = plugify.Plugin
local s2 = require 's2sdk'
local function on_color_menu(id, action, player_slot, param)
if action == s2.MenuAction.Select then
local info = s2:GetMenuItemInfoText(id, param)
s2:PrintToChat(player_slot, "You picked: " .. info)
elseif action == s2.MenuAction.End then
s2:DestroyMenu(id)
end
end
local Sample = {}
setmetatable(Sample, { __index = Plugin })
function Sample:plugin_start()
local flags = bit.bor(s2.ConVarFlag.LinkedConcommand, s2.ConVarFlag.Release, s2.ConVarFlag.ClientCanExecute)
local function command_colors(caller, context, arguments)
if caller == -1 then return s2.ResultType.Handled end
local menu = s2:CreateMenu("Pick a Color", on_color_menu, "chat")
s2:AddMenuItem(menu, "red", "Red", s2.MenuItemStyle.Default)
s2:AddMenuItem(menu, "green", "Green", s2.MenuItemStyle.Default)
s2:AddMenuItem(menu, "blue", "Blue", s2.MenuItemStyle.Default)
s2:DisplayMenu(menu, caller, 0)
return s2.ResultType.Handled
end
s2:AddConsoleCommand("sm_colors", "Opens a color picker menu", flags, command_colors, s2.HookMode.Post)
end
local M = {}
M.Sample = Sample
return M
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.
A menu outlives the function that creates it. It needs to stay alive until the player selects something, cancels, or the display times out — which happens later, in your handler callback. If you let the Menu object go out of scope (a using block in C#, a stack variable in C++) right after calling Display, its destructor fires immediately and destroys the menu you just showed. Use the class for the convenience of its methods during setup, but keep destroying the menu from your handler's End case with plain DestroyMenu(id) — the handler only ever receives the raw handle, never a Menu instance, so that's the only place cleanup can happen anyway.
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;
}
}
}
#include <plg/plugin.hpp>
#include "s2sdk.hpp"
using namespace s2sdk;
void OnColorMenu(MenuId id, MenuAction action, int playerSlot, int param) {
switch (action) {
case MenuAction::Select: {
plg::string info = GetMenuItemInfoText(id, param);
PrintToChat(playerSlot, "You picked: " + info);
break;
}
case MenuAction::End:
DestroyMenu(id); // cleanup happens here, not via Menu's destructor
break;
default:
break;
}
}
class Sample : public plg::Plugin {
public:
void OnPluginStart() override {
ConVarFlag flags = ConVarFlag::LinkedConcommand | ConVarFlag::Release | ConVarFlag::ClientCanExecute;
AddConsoleCommand("sm_colors", "Opens a color picker menu", flags,
[](int caller, CommandCallingContext context, const plg::vector<plg::string>& arguments) -> ResultType {
if (caller == -1) return ResultType::Handled;
Menu 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;
}, HookMode::Post);
}
};
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 display session is fully closed — always sent last, after Select or Cancel
unused
Always destroy your menus: CreateMenu allocates a handle that isn't cleaned up automatically. Call DestroyMenu(id) from your handler's End case (as in the example above) — otherwise every menu you display leaks for the lifetime of the plugin.
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).
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.
MenuCancelReason
Meaning
Exit
The client used the normal exit option
Timeout
The display's timer ran out
Disconnect
The client disconnected while the menu was open
Interrupted
Another DisplayMenu call replaced this display for the client
Destroyed
The menu handle was destroyed while it was being displayed
ExitBack
The 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;
}
}
}
#include <plg/plugin.hpp>
#include "s2sdk.hpp"
using namespace s2sdk;
void OnMainMenu(MenuId id, MenuAction action, int playerSlot, int param);
MenuId CreateMainMenu() {
MenuId menu = CreateMenu("Settings", &OnMainMenu, "chat");
AddMenuItem(menu, "hello", "Say Hello", MenuItemStyle::Default);
AddMenuItem(menu, "colors", "Color Options", MenuItemStyle::Default);
return menu;
}
void OnColorSubMenu(MenuId id, MenuAction action, int playerSlot, int param) {
switch (action) {
case MenuAction::Select: {
plg::string info = GetMenuItemInfoText(id, param);
PrintToChat(playerSlot, "Color set to: " + info);
break;
}
case MenuAction::Cancel:
if (static_cast<MenuCancelReason>(param) == MenuCancelReason::ExitBack) {
DisplayMenu(CreateMainMenu(), playerSlot, 0);
}
break;
case MenuAction::End:
DestroyMenu(id);
break;
default:
break;
}
}
void OnMainMenu(MenuId id, MenuAction action, int playerSlot, int param) {
switch (action) {
case MenuAction::Select: {
plg::string info = GetMenuItemInfoText(id, param);
if (info == "hello") {
PrintToChat(playerSlot, "Hello!");
} else if (info == "colors") {
MenuId 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;
default:
break;
}
}
class Sample : public plg::Plugin {
public:
void OnPluginStart() override {
ConVarFlag flags = ConVarFlag::LinkedConcommand | ConVarFlag::Release | ConVarFlag::ClientCanExecute;
AddConsoleCommand("sm_settings", "Opens a settings menu", flags,
[](int caller, CommandCallingContext context, const plg::vector<plg::string>& arguments) -> ResultType {
if (caller == -1) return ResultType::Handled;
DisplayMenu(CreateMainMenu(), caller, 0);
return ResultType::Handled;
}, HookMode::Post);
}
};
from plugify.plugin import Plugin
from plugify.pps import s2sdk as s2
def create_main_menu():
menu = s2.CreateMenu("Settings", on_main_menu, "chat")
s2.AddMenuItem(menu, "hello", "Say Hello", s2.MenuItemStyle.Default)
s2.AddMenuItem(menu, "colors", "Color Options", s2.MenuItemStyle.Default)
return menu
def on_color_submenu(id, action, player_slot, param):
if action == s2.MenuAction.Select:
info = s2.GetMenuItemInfoText(id, param)
s2.PrintToChat(player_slot, f"Color set to: {info}")
elif action == s2.MenuAction.Cancel:
if param == s2.MenuCancelReason.ExitBack:
s2.DisplayMenu(create_main_menu(), player_slot, 0)
elif action == s2.MenuAction.End:
s2.DestroyMenu(id)
def on_main_menu(id, action, player_slot, param):
if action == s2.MenuAction.Select:
info = s2.GetMenuItemInfoText(id, param)
if info == "hello":
s2.PrintToChat(player_slot, "Hello!")
elif info == "colors":
sub = s2.CreateMenu("Color Options", on_color_submenu, "chat")
s2.AddMenuItem(sub, "red", "Red", s2.MenuItemStyle.Default)
s2.AddMenuItem(sub, "blue", "Blue", s2.MenuItemStyle.Default)
s2.SetMenuExitBackButton(sub, True) # shows "Back" instead of "Exit"
s2.DisplayMenu(sub, player_slot, 0)
elif action == s2.MenuAction.End:
s2.DestroyMenu(id)
class Sample(Plugin):
def plugin_start(self):
flags = s2.ConVarFlag.LinkedConcommand | s2.ConVarFlag.Release | s2.ConVarFlag.ClientCanExecute
def command_settings(caller, context, arguments):
if caller == -1:
return s2.ResultType.Handled
s2.DisplayMenu(create_main_menu(), caller, 0)
return s2.ResultType.Handled
s2.AddConsoleCommand("sm_settings", "Opens a settings menu", flags, command_settings, s2.HookMode.Post)
package main
import (
"s2sdk"
"github.com/untrustedmodders/go-plugify"
)
func createMainMenu() uint32 {
menu := s2sdk.CreateMenu("Settings", onMainMenu, "chat")
s2sdk.AddMenuItem(menu, "hello", "Say Hello", s2sdk.MenuItemStyleDefault)
s2sdk.AddMenuItem(menu, "colors", "Color Options", s2sdk.MenuItemStyleDefault)
return menu
}
func onColorSubMenu(id uint32, action s2sdk.MenuAction, playerSlot int32, param int32) {
switch action {
case s2sdk.MenuActionSelect:
info := s2sdk.GetMenuItemInfoText(id, param)
s2sdk.PrintToChat(playerSlot, "Color set to: "+info)
case s2sdk.MenuActionCancel:
if s2sdk.MenuCancelReason(param) == s2sdk.MenuCancelReasonExitBack {
s2sdk.DisplayMenu(createMainMenu(), playerSlot, 0)
}
case s2sdk.MenuActionEnd:
s2sdk.DestroyMenu(id)
}
}
func onMainMenu(id uint32, action s2sdk.MenuAction, playerSlot int32, param int32) {
switch action {
case s2sdk.MenuActionSelect:
info := s2sdk.GetMenuItemInfoText(id, param)
if info == "hello" {
s2sdk.PrintToChat(playerSlot, "Hello!")
} else if info == "colors" {
sub := s2sdk.CreateMenu("Color Options", onColorSubMenu, "chat")
s2sdk.AddMenuItem(sub, "red", "Red", s2sdk.MenuItemStyleDefault)
s2sdk.AddMenuItem(sub, "blue", "Blue", s2sdk.MenuItemStyleDefault)
s2sdk.SetMenuExitBackButton(sub, true) // shows "Back" instead of "Exit"
s2sdk.DisplayMenu(sub, playerSlot, 0)
}
case s2sdk.MenuActionEnd:
s2sdk.DestroyMenu(id)
}
}
func onPluginStart() error {
flags := s2sdk.ConVarFlag.LinkedConcommand | s2sdk.ConVarFlag.Release | s2sdk.ConVarFlag.ClientCanExecute
s2sdk.AddConsoleCommand("sm_settings", "Opens a settings menu", flags,
func(caller int32, context s2sdk.CommandCallingContext, arguments []string) s2sdk.ResultType {
if caller == -1 {
return s2sdk.ResultType.Handled
}
s2sdk.DisplayMenu(createMainMenu(), caller, 0)
return s2sdk.ResultType.Handled
}, s2sdk.HookMode.Post)
return nil
}
func init() {
...
}
import { Plugin } from 'plugify';
import * as s2 from ':s2sdk';
function createMainMenu() {
const menu = s2.CreateMenu("Settings", onMainMenu, "chat");
s2.AddMenuItem(menu, "hello", "Say Hello", s2.MenuItemStyle.Default);
s2.AddMenuItem(menu, "colors", "Color Options", s2.MenuItemStyle.Default);
return menu;
}
function onColorSubMenu(id, action, playerSlot, param) {
if (action === s2.MenuAction.Select) {
const info = s2.GetMenuItemInfoText(id, param);
s2.PrintToChat(playerSlot, `Color set to: ${info}`);
} else if (action === s2.MenuAction.Cancel) {
if (param === s2.MenuCancelReason.ExitBack) {
s2.DisplayMenu(createMainMenu(), playerSlot, 0);
}
} else if (action === s2.MenuAction.End) {
s2.DestroyMenu(id);
}
}
function onMainMenu(id, action, playerSlot, param) {
if (action === s2.MenuAction.Select) {
const info = s2.GetMenuItemInfoText(id, param);
if (info === "hello") {
s2.PrintToChat(playerSlot, "Hello!");
} else if (info === "colors") {
const sub = s2.CreateMenu("Color Options", onColorSubMenu, "chat");
s2.AddMenuItem(sub, "red", "Red", s2.MenuItemStyle.Default);
s2.AddMenuItem(sub, "blue", "Blue", s2.MenuItemStyle.Default);
s2.SetMenuExitBackButton(sub, true); // shows "Back" instead of "Exit"
s2.DisplayMenu(sub, playerSlot, 0);
}
} else if (action === s2.MenuAction.End) {
s2.DestroyMenu(id);
}
}
export class Sample extends Plugin {
pluginStart() {
const flags = s2.ConVarFlag.LinkedConcommand | s2.ConVarFlag.Release | s2.ConVarFlag.ClientCanExecute;
s2.AddConsoleCommand("sm_settings", "Opens a settings menu", flags,
(caller, context, arguments) => {
if (caller === -1) return s2.ResultType.Handled;
s2.DisplayMenu(createMainMenu(), caller, 0);
return s2.ResultType.Handled;
}, s2.HookMode.Post);
}
}
local plugify = require 'plugify'
local Plugin = plugify.Plugin
local s2 = require 's2sdk'
local on_main_menu -- forward declaration
local function create_main_menu()
local menu = s2:CreateMenu("Settings", on_main_menu, "chat")
s2:AddMenuItem(menu, "hello", "Say Hello", s2.MenuItemStyle.Default)
s2:AddMenuItem(menu, "colors", "Color Options", s2.MenuItemStyle.Default)
return menu
end
local function on_color_submenu(id, action, player_slot, param)
if action == s2.MenuAction.Select then
local info = s2:GetMenuItemInfoText(id, param)
s2:PrintToChat(player_slot, "Color set to: " .. info)
elseif action == s2.MenuAction.Cancel then
if param == s2.MenuCancelReason.ExitBack then
s2:DisplayMenu(create_main_menu(), player_slot, 0)
end
elseif action == s2.MenuAction.End then
s2:DestroyMenu(id)
end
end
on_main_menu = function(id, action, player_slot, param)
if action == s2.MenuAction.Select then
local info = s2:GetMenuItemInfoText(id, param)
if info == "hello" then
s2:PrintToChat(player_slot, "Hello!")
elseif info == "colors" then
local sub = s2:CreateMenu("Color Options", on_color_submenu, "chat")
s2:AddMenuItem(sub, "red", "Red", s2.MenuItemStyle.Default)
s2:AddMenuItem(sub, "blue", "Blue", s2.MenuItemStyle.Default)
s2:SetMenuExitBackButton(sub, true) -- shows "Back" instead of "Exit"
s2:DisplayMenu(sub, player_slot, 0)
end
elseif action == s2.MenuAction.End then
s2:DestroyMenu(id)
end
end
local Sample = {}
setmetatable(Sample, { __index = Plugin })
function Sample:plugin_start()
local flags = bit.bor(s2.ConVarFlag.LinkedConcommand, s2.ConVarFlag.Release, s2.ConVarFlag.ClientCanExecute)
local function command_settings(caller, context, arguments)
if caller == -1 then return s2.ResultType.Handled end
s2:DisplayMenu(create_main_menu(), caller, 0)
return s2.ResultType.Handled
end
s2:AddConsoleCommand("sm_settings", "Opens a settings menu", flags, command_settings, s2.HookMode.Post)
end
local M = {}
M.Sample = Sample
return M
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):
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).
How players select on chat/console/centerhtml: these three types are digit-driven — items are numbered 1-7 per page, with 8/9/0 reserved for previous page/next page/exit. The actual command a player needs to run is <prefix><number> (from the server's MenuCommandPrefixes setting, e.g. css_1) typed in console, or menuselect <number> — both are configurable server-side. To select from chat, the player also needs to prefix it with the server's chat trigger (e.g. !css_1), since chat messages only route to commands when they start with one. The built-in menus print a hint reflecting whatever's actually configured, so you don't need to hardcode this yourself.
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.
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": ""
Controls are always rendered as images, sourced from a mounted workshop addon (e.g. workshop id 3763619947) — there's no plain-text fallback. If that addon isn't mounted on your server, the Up/Down/Select/Exit control row and the inline select hint will show as broken images instead of text. MenuButtonImagePath/MenuButtonImageExtension locate the image files (resource/menus/*.vsvg by default), and each MenuButtonImage* (Up/Down/Left/Right/Select/Exit) is that control's image name — every one gets a -p suffix appended automatically while its key is held down. If you don't want this dependency, use the chat, console, or centerhtml type instead (or SetDefaultMenuType to one of them).
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.
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.
Always destroy menus you create — call DestroyMenu from your handler's End case, including for submenus you create dynamically.
Key selection logic off info, not display — display text can change (translations, dynamic labels); info strings are yours to keep stable.
Use SetMenuExitBackButton for parent navigation, not a manual "Back" item — it gives you a distinct MenuCancelReason and works consistently across all menu types.
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.
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.