Skip to content
BasaltSoftware
Bedrock 1.26.60

Site colour

Discord
Esc

    ↑↓ moveEnter openEsc close

    Keyboard shortcuts
    Ctrl K
    Search the whole site, from any page
    /
    Jump to the sidebar search, or search the whole site where there isn't one
    Enter
    Open the top search result
    Esc
    Clear the search, or close this panel
    ?
    Show these shortcuts
    Plugin API

    Container menus

    Open chest-style menus with clickable item slots using Basalt's @basalt/menus module.

    @basalt/menus opens container windows, such as a chest, that have no real block behind them. Each slot holds an item you choose, and your plugin hears every click. They are the usual way to build shops, kit selectors, navigators and any menu where item icons work better than a list of buttons.

    This module is Basalt’s own. It does not exist on BDS, so a plugin that imports it only runs on Basalt.

    import { Menu, ChestMenu, MenuType, MenuClickAction } from "@basalt/menus";

    You do not need to list @basalt/menus in your manifest’s dependencies.

    import { world, ItemStack } from "@minecraft/server";
    import { Menu, MenuType } from "@basalt/menus";
    const menu = new Menu(MenuType.Chest, "Navigator");
    const spawn = new ItemStack("minecraft:compass", 1);
    spawn.nameTag = "§aSpawn";
    spawn.setLore(["§7Click to teleport"]);
    menu.setItem(13, spawn, (event) => {
    event.cancel = true;
    event.player.teleport({ x: 0, y: 80, z: 0 });
    menu.close(event.player);
    });
    // Refuse every other click, so nothing can be taken or put in.
    menu.onClick((event) => {
    event.cancel = true;
    });
    world.afterEvents.itemUse.subscribe(({ source, itemStack }) => {
    if (itemStack.typeId === "minecraft:clock") void menu.show(source);
    });

    Three things are happening:

    1. setItem(slot, itemStack, onClick) puts an item in a slot and, optionally, gives that slot its own click handler.
    2. onClick handles clicks on every slot that has no handler of its own.
    3. show(player) opens the window.

    new Menu(type, title) takes a MenuType. new ChestMenu(title, { large: true }) is a shortcut for a chest or a double chest.

    MenuType Slots
    Chest, EnderChest, Barrel, ShulkerBox 27
    DoubleChest 54
    Hopper 5
    Dispenser, Dropper 9
    Furnace, BlastFurnace, Smoker 3: input, fuel, output
    BrewingStand 5
    CraftingTable 9
    Anvil, EnchantingTable, Grindstone, CartographyTable 2
    Loom, SmithingTable 3
    Stonecutter, Beacon 1

    Slots are numbered from 0, left to right and top to bottom. In a chest, slot 13 is the middle of the middle row.

    The workstation types (crafting table, anvil and the rest) show their slots but do not do anything: nothing crafts, repairs or enchants. They are there for their look. Some of them show their own title instead of yours.

    A click handler receives a MenuClickEvent:

    Field Meaning
    menu The menu that was clicked
    player Who clicked
    slot The slot number
    action What the player did: a MenuClickAction
    itemStack What the slot holds now
    result What the slot will hold if the click goes ahead
    cancel Set to true to refuse the click
    MenuClickAction The player
    Take Picked the item up
    Place Put an item down
    Swap Swapped it with the item on their cursor
    Drop Threw it out of the window
    Shift Shift-clicked it to move it across

    A refused click changes nothing; the player’s client puts everything back.

    Basalt waits for the handler’s answer, for at most 50 ms. If the plugin does not answer in time, the click is refused. The rule about reads inside before events applies: the event’s own fields are always there, and changes such as teleport or addItem are fine, but reading the player’s inventory or location belongs in system.run.

    This shop sells items for emeralds. The click handler only decides which offer was clicked; counting and taking the emeralds reads the inventory, so it is deferred to system.run.

    import { system, world, ItemStack, Player } from "@minecraft/server";
    import { Menu, MenuType, MenuClickAction } from "@basalt/menus";
    const offers = new Map([
    [11, { typeId: "minecraft:bread", cost: 1 }],
    [13, { typeId: "minecraft:diamond", cost: 10 }],
    [15, { typeId: "minecraft:iron_sword", cost: 5 }],
    ]);
    const shop = new Menu(MenuType.Chest, "Shop");
    for (const [slot, { typeId, cost }] of offers) {
    const icon = new ItemStack(typeId, 1);
    icon.setLore([`§a${cost} emeralds`]);
    shop.setItem(slot, icon);
    }
    /** Takes `cost` emeralds from the player. False, taking nothing, if they have too few. */
    function pay(player: Player, cost: number): boolean {
    const container = player.getComponent("minecraft:inventory")?.container;
    if (!container) return false;
    const slots: number[] = [];
    let found = 0;
    for (let slot = 0; slot < container.size; slot++) {
    const item = container.getItem(slot);
    if (item?.typeId !== "minecraft:emerald") continue;
    slots.push(slot);
    found += item.amount;
    }
    if (found < cost) return false;
    let left = cost;
    for (const slot of slots) {
    const item = container.getItem(slot);
    if (!item || left === 0) continue;
    if (item.amount <= left) {
    left -= item.amount;
    container.setItem(slot, undefined);
    } else {
    item.amount -= left;
    left = 0;
    container.setItem(slot, item);
    }
    }
    return true;
    }
    shop.onClick((event) => {
    event.cancel = true; // nothing leaves the shop by hand
    const offer = offers.get(event.slot);
    if (!offer || event.action !== MenuClickAction.Take) return;
    const { player } = event;
    system.run(() => {
    if (!player.isValid) return;
    if (!pay(player, offer.cost)) {
    player.sendMessage("§cYou cannot afford that.");
    return;
    }
    player.getComponent("minecraft:inventory")?.container.addItem(new ItemStack(offer.typeId, 1));
    player.playSound("random.orb");
    });
    });
    world.afterEvents.chatSend.subscribe(async ({ sender, message }) => {
    if (message !== "!shop") return;
    await shop.show(sender);
    sender.sendMessage("Come again!");
    });

    show(player) opens the menu, closing any window the player already has open. It returns a promise that resolves when that player’s window closes, whatever closed it:

    • the player closed it,
    • the plugin called menu.close(player), or menu.close() for everyone,
    • the player died, teleported or left.
    const closed = await menu.show(player);
    console.log(`${closed.player.name} closed ${closed.menu.title}`);

    onClose(callback) runs for every close, which is handy when a menu is shown from several places.

    As with forms, do not call show directly inside a before event or command callback. Start it from system.run.

    One Menu object can be open for many players at once. They all see the same slots: when you call setItem while it is open, the slot changes in front of everyone, and a click that goes ahead changes it for the others too. That suits a shared shop or a live display.

    For a menu whose contents depend on the player, build a new Menu each time:

    import { ItemStack, Player } from "@minecraft/server";
    import { ChestMenu } from "@basalt/menus";
    function openProfile(player: Player): void {
    const menu = new ChestMenu(`${player.name}'s profile`);
    const head = new ItemStack("minecraft:paper", 1);
    head.nameTag = `§e${player.name}`;
    head.setLore([`§7Level ${player.level}`]);
    menu.setItem(13, head);
    menu.onClick((event) => {
    event.cancel = true;
    });
    void menu.show(player);
    }

    The slots belong to the menu, not to any player or block:

    • Items stay in the menu when the window closes. Nothing drops into the world.
    • Nothing is lost if the player dies or leaves with the menu open.
    • A menu’s contents live in memory. They are gone when the plugin reloads or Basalt stops, so save anything that matters yourself, for example in dynamic properties.

    Other members:

    Member Does
    getItem(slot) Returns what a slot holds
    clear() Empties every slot
    size The number of slots
    type The MenuType
    title The window title. Changing it affects players who open the menu afterwards.

    setItem and getItem throw a RangeError for a slot number the menu does not have.

    To open a container window, the client needs a container block to attach it to. Basalt sends the player a block that only they can see, just above their head, opens the window on it, and restores the real block when the window closes. Other players never see it, and nothing in the world changes.

    The window opens a tick or two after show is called.

    The full list of classes is in @basalt/menus.