swiftlys2/docs/api/ai/

Menus

SwiftlyS2 menus provide an interactive, per-player UI layer for settings, selections, and action flows, through Core.MenusAPI (APIIMenuManagerAPI).

  1. Create a builder with Core.MenusAPI.CreateBuilder().
  2. Configure behavior (sound, freeze, auto-close, keybind overrides) and appearance (builder.Design).
  3. Add options and call Build().
  4. Open and close through Core.MenusAPI.

Builder Configuration

Every call under .Design returns back to the builder, so chain .Design. again for each design call:

var menu = Core.MenusAPI.CreateBuilder()
    .EnableSound()
    .SetPlayerFrozen(false)
    .SetAutoCloseDelay(0f)
    .SetSelectButton(KeyBind.E | KeyBind.Mouse1) // KeyBind is a [Flags] enum
    .SetMoveForwardButton(KeyBind.W)
    .SetMoveBackwardButton(KeyBind.S)
    .SetExitButton(KeyBind.Esc)
    .AddExtraButton(KeyBind.R, "Reset", (p, m) =>
    {
        p.SendChat("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();
 
Core.MenusAPI.OpenMenuForPlayer(player, menu);

MaxVisibleItems only accepts [1, 5] or -1 (falls back to ItemsPerPage in configs/core.jsonc, see Core Configuration) - out-of-range values log an error and reset to -1.

Option Types

Built-in options live under SwiftlyS2.Core.Menus.OptionsBase:

var slider = new SliderMenuOption(
    text: "Round Time",
    min: 60f,
    max: 300f,
    defaultValue: 120f,
    step: 30f,
    totalBars: 8
);
 
slider.ValueChanged += (sender, args) =>
{
    args.Player.SendChat($"Round time: {args.NewValue:0}s");
};
 
var menu = Core.MenusAPI.CreateBuilder()
    .Design.SetMenuTitle("Player Preferences")
    .AddOption(slider)
    .Build();

The other value-holding options (APIToggleMenuOption, APIInputMenuOption, APIChoiceMenuOption, APISelectorMenuOption`1) follow the same constructor-plus-ValueChanged-handler pattern, just with their own value type (bool, string, etc. - see APIMenuOptionValueChangedEventArgs`1.OldValue/APIMenuOptionValueChangedEventArgs`1.NewValue).

Provide a pre-built submenu, or build it lazily 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

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);

APIIMenuAPI.ShowForPlayer/ APIIMenuAPI.HideForPlayer only affect visual display - prefer the manager methods above 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) { /* same shape */ }

Both handlers receive APIMenuManagerEventArgs.

Per-menu and per-option events

var adminOnly = new ButtonMenuOption("Admin Action");
 
adminOnly.Validating += (sender, args) =>
{
    // args.Player, args.Option are also available here
    if (!Core.Permission.PlayerHasPermission(args.Player.SteamID, "admin"))
        args.Cancel = true;
};
 
adminOnly.Click += (sender, args) =>
{
    args.Player.SendChat("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: fired every render frame while hovering - keep this cheap.
// menu.OptionHovered: fired only when the hovered option changes.
// menu.OptionSelected: fired when a selection is activated.

APIMenuOptionValidatingEventArgs only exposes Player, Option, and a settable Cancel bool - there is no CancelReason. Send a chat message from the Validating handler yourself before setting Cancel = true if the player needs to know why.

Runtime Updates

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);

APIIMenuOption.Visible/ APIIMenuOption.Enabled are the global states; SetVisible/SetEnabled(player, ...) are per-player overrides on top of them.

Direct CreateMenu Usage

APIIMenuManagerAPI.CreateMenu is a lower-level alternative to the builder, taking explicit APIMenuConfiguration/ APIMenuKeybindOverrides objects (the same settings the builder's .Design/Set*Button calls configure) instead of a fluent chain:

var menu = Core.MenusAPI.CreateMenu(
    new MenuConfiguration { Title = "Raw Menu", MaxVisibleItems = 5 },
    new MenuKeybindOverrides { Select = KeyBind.E, Move = KeyBind.W, MoveBack = KeyBind.S, Exit = KeyBind.Esc },
    parent: null,
    optionScrollStyle: MenuOptionScrollStyle.CenterFixed,
    optionTextStyle: MenuOptionTextStyle.TruncateEnd
);

Common Pitfalls

  • Open and close menus through Core.MenusAPI manager methods, not only ShowForPlayer/HideForPlayer.
  • Keep heavy logic out of OptionHovering - it runs every render frame while hovering.
  • Unsubscribe manager-level events (MenuOpened/MenuClosed) during plugin Unload().