Menus
SwiftlyS2 menus provide an interactive, per-player UI layer for settings, selections, and action flows.
In plugin code, the menu entry point is Core.MenusAPI (IMenuManagerAPI).
Quick Start
var menu = Core.MenusAPI.CreateBuilder()
.Design.SetMenuTitle("Match Settings")
.Design.SetMaxVisibleItems(5)
.AddOption(new TextMenuOption("Configure the round before it starts"))
.AddOption(new ButtonMenuOption("Start match"))
.Build();
Core.MenusAPI.OpenMenuForPlayer(player, menu);Interactive Visual Builder
Use the dedicated block-based page to visually script menus and generate code:
The builder page provides a full canvas workflow and exports C# with TODO placeholders where you should wire project-specific behavior.
Recommended Workflow
- Create a builder with
Core.MenusAPI.CreateBuilder(). - Configure behavior (sound, freeze, auto-close, keybind overrides).
- Configure appearance through
builder.Design. - Add options.
- Call
Build(). - Open and close through
Core.MenusAPI.
Builder Configuration
var menu = Core.MenusAPI.CreateBuilder()
.EnableSound()
.SetPlayerFrozen(false)
.SetAutoCloseDelay(0f)
.SetSelectButton(KeyBind.E | KeyBind.Mouse1)
.SetMoveForwardButton(KeyBind.W)
.SetMoveBackwardButton(KeyBind.S)
.SetExitButton(KeyBind.Esc)
.AddExtraButton(KeyBind.R, "Reset", (p, m) =>
{
p.PrintToChat("Reset action executed.");
})
.Design.SetMenuTitle("Gameplay Settings")
.Design.SetMenuTitleItemCountVisible(true)
.Design.SetMenuFooterVisible(true)
.Design.SetCommentVisible(true)
.Design.SetDefaultComment("Use W/S to move and E to select")
.Design.SetMaxVisibleItems(5)
.Design.SetGlobalScrollStyle(MenuOptionScrollStyle.WaitingCenter)
.Build();Option Types
Common built-in options from SwiftlyS2.Core.Menus.OptionsBase:
TextMenuOption: non-interactive informational line.ButtonMenuOption: clickable action.ToggleMenuOption: per-player on/off value.SliderMenuOption: numeric range with step.ChoiceMenuOption: per-player value from a string list.InputMenuOption: chat input with validation.ProgressBarMenuOption: dynamic progress display.SubmenuMenuOption: opens another menu.SelectorMenuOption<T>: typed selector with previous/next behavior.
var toggle = new ToggleMenuOption("Friendly Fire", defaultToggleState: false);
toggle.ValueChanged += (sender, args) =>
{
args.Player.PrintToChat($"Friendly Fire: {args.NewValue}");
};
var slider = new SliderMenuOption(
text: "Round Time",
min: 60f,
max: 300f,
defaultValue: 120f,
step: 30f,
totalBars: 8
);
slider.ValueChanged += (sender, args) =>
{
args.Player.PrintToChat($"Round time: {args.NewValue:0}s");
};
var input = new InputMenuOption(
text: "Clan Tag",
maxLength: 8,
validator: value => !string.IsNullOrWhiteSpace(value),
defaultValue: "",
hintMessage: "Type your clan tag in chat"
);
input.ValueChanged += (sender, args) =>
{
args.Player.PrintToChat($"Saved tag: {args.NewValue}");
};
var menu = Core.MenusAPI.CreateBuilder()
.Design.SetMenuTitle("Player Preferences")
.AddOption(toggle)
.AddOption(slider)
.AddOption(input)
.Build();Submenus
You can provide a pre-built submenu or lazily build it when selected.
var advancedMenu = Core.MenusAPI.CreateBuilder()
.Design.SetMenuTitle("Advanced")
.AddOption(new ButtonMenuOption("Do advanced action"))
.Build();
var openAdvanced = new SubmenuMenuOption("Advanced", advancedMenu);
var lazyAdvanced = new SubmenuMenuOption("Lazy Advanced", () =>
{
return Core.MenusAPI.CreateBuilder()
.Design.SetMenuTitle("Loaded On Demand")
.AddOption(new TextMenuOption("This submenu was built on click"))
.Build();
});Open and Close Menus
Use manager methods for lifecycle correctness.
Core.MenusAPI.OpenMenuForPlayer(player, menu);
Core.MenusAPI.CloseActiveMenu(player);
Core.MenusAPI.OpenMenu(menu);
Core.MenusAPI.CloseMenu(menu);
Core.MenusAPI.CloseAllMenus();
var current = Core.MenusAPI.GetCurrentMenu(player);IMenuAPI.ShowForPlayer(...) and IMenuAPI.HideForPlayer(...) only affect visual display. Prefer manager-level open/close methods for full state handling and events.
Events
Global manager events
public override void Load(bool hotReload)
{
Core.MenusAPI.MenuOpened += OnMenuOpened;
Core.MenusAPI.MenuClosed += OnMenuClosed;
}
public override void Unload()
{
Core.MenusAPI.MenuOpened -= OnMenuOpened;
Core.MenusAPI.MenuClosed -= OnMenuClosed;
}
private void OnMenuOpened(object? sender, MenuManagerEventArgs args)
{
if (args.Player != null)
{
Core.Logger.LogInformation("Menu opened for {SteamId}", args.Player.SteamID);
}
}
private void OnMenuClosed(object? sender, MenuManagerEventArgs args)
{
if (args.Player != null)
{
Core.Logger.LogInformation("Menu closed for {SteamId}", args.Player.SteamID);
}
}Per-menu and per-option events
var adminOnly = new ButtonMenuOption("Admin Action");
adminOnly.Validating += (sender, args) =>
{
var hasAccess = Core.Permission.PlayerHasPermission(args.Player.SteamID, "admin");
if (!hasAccess)
{
args.Cancel = true;
args.CancelReason = "Missing admin permission";
}
};
adminOnly.Click += async (sender, args) =>
{
args.Player.PrintToChat("Admin action executed.");
return ValueTask.CompletedTask;
};
adminOnly.BeforeFormat += (sender, args) =>
{
args.CustomText = $"[SECURE] {args.Option.Text}";
};
adminOnly.AfterFormat += (sender, args) =>
{
args.CustomText = $"<font color='#FFD700'>{args.CustomText}</font>";
};
var menu = Core.MenusAPI.CreateBuilder()
.Design.SetMenuTitle("Admin")
.AddOption(adminOnly)
.Build();
menu.OptionHovering += (sender, args) =>
{
// Fired every render frame while hovering.
};
menu.OptionHovered += (sender, args) =>
{
// Fired only when hovered option changes.
};
menu.OptionSelected += (sender, args) =>
{
// Fired when selection is activated.
};Runtime Updates
Menus and options can be updated dynamically after creation.
var option = new ButtonMenuOption("Dynamic Option");
var menu = Core.MenusAPI.CreateBuilder().AddOption(option).Build();
menu.AddOption(new TextMenuOption("Added later"));
menu.MoveToOptionIndex(player, 0);
option.SetVisible(player, false);
option.SetEnabled(player, false);
option.SetVisible(player, true);
option.SetEnabled(player, true);IMenuOption.Visible and IMenuOption.Enabled are global states. SetVisible(player, ...) and SetEnabled(player, ...) are per-player overrides.
Direct CreateMenu Usage
Use direct creation when you need explicit configuration objects.
var configuration = new MenuConfiguration
{
Title = "Raw Menu",
HideTitle = false,
HideFooter = false,
PlaySound = true,
MaxVisibleItems = 5
};
var overrides = new MenuKeybindOverrides
{
Select = KeyBind.E,
Move = KeyBind.W,
MoveBack = KeyBind.S,
Exit = KeyBind.Esc
};
var menu = Core.MenusAPI.CreateMenu(
configuration,
overrides,
parent: null,
optionScrollStyle: MenuOptionScrollStyle.CenterFixed,
optionTextStyle: MenuOptionTextStyle.TruncateEnd
);
menu.AddOption(new ButtonMenuOption("Continue"));
Core.MenusAPI.OpenMenuForPlayer(player, menu);Common Pitfalls
- Use
Core.MenusAPI, not legacyCore.Menusreferences. - Open and close menus through manager methods, not only
ShowForPlayer/HideForPlayer. - Keep heavy logic out of
OptionHoveringbecause it runs frequently. - Unsubscribe events and dispose menu instances during plugin unload.
Reference
See IMenuManagerAPI for manager-level methods and events.
See IMenuBuilderAPI and IMenuDesignAPI for fluent setup.
See IMenuAPI and IMenuOption for runtime control and per-option behavior.
See optionsbase for built-in option classes.