SwiftlyS2
Development

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:

Open Menu Block Builder

The builder page provides a full canvas workflow and exports C# with TODO placeholders where you should wire project-specific behavior.

  1. Create a builder with Core.MenusAPI.CreateBuilder().
  2. Configure behavior (sound, freeze, auto-close, keybind overrides).
  3. Configure appearance through builder.Design.
  4. Add options.
  5. Call Build().
  6. 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();

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 legacy Core.Menus references.
  • Open and close menus through manager methods, not only ShowForPlayer/HideForPlayer.
  • Keep heavy logic out of OptionHovering because 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.

On this page