Available on: Bukkit ✅ only - the other platforms have no inventory to draw into
Three related facades:
| Facade | What it draws | Needs |
|---|---|---|
BlueFoundation.Menus |
One definition; a chest inventory for Java players, a native Bedrock form for Bedrock ones | nothing |
BlueFoundation.Forms |
A Bedrock form directly, built by hand | nothing |
BlueFoundation.Dialogs |
A Java Edition dialog screen | Minecraft 1.21.6+ |
None of them requires Floodgate, Geyser or Cumulus as a dependency, on the compile classpath or in your jar. See Talking to Bedrock without Floodgate for how.
A menu is described once. Which surface a player gets is decided per player at open time, so a
server with both editions on it needs one config rather than a java/ and a bedrock/ folder that
have to be kept in step.
MenuDefinition warps = BlueFoundation.Menus.load("warps", config.section("menu"));
BlueFoundation.Menus.register(warps);
BlueFoundation.Menus.open(plugin, player, "warps");Or without a config file:
MenuDefinition menu = MenuDefinition.builder("warps")
.title("<gradient:#EC4899:#F43F5E>Warps</gradient>")
.rows(3)
.button(MenuButton.builder("spawn")
.name("<green>Spawn")
.lore("<gray>Go back to the hub")
.material("PLAYER_HEAD", "SKULL_ITEM")
.slot(11)
.image("https://example.com/spawn.png")
.onClick(p -> p.performCommand("spawn"))
.build())
.closeButton(MenuButton.builder("close")
.name("<red>Close").material("BARRIER").slot(22).action("close").build())
.build();
BlueFoundation.Menus.open(plugin, new MenuContext(player, menu));A button with no name, no lore and nothing to do is wallpaper, and its tooltip
is hidden without being asked: hovering the gap between two groups of buttons
should not open an empty window. hide-tooltip: true says so explicitly for
anything else. It needs Minecraft 1.20.5 or later; on older servers a blank
name still carries most of the effect.
material takes several names and uses the first that exists on the running server, which is how
one definition covers 1.8 through modern without a version branch. image is the icon beside the
button on Bedrock and is ignored on Java; material is the item on Java and is ignored on Bedrock.
A button's lore is folded into the Bedrock button's label - Bedrock buttons wrap on newlines - so
the explanation a Java player reads in the tooltip is not lost.
Set layout: paginated and hand the entries in at open time:
List<MenuButton> entries = new ArrayList<>();
for (Warp warp : warps) {
entries.add(MenuButton.builder(warp.id())
.name("<aqua>" + warp.name())
.material("ENDER_PEARL")
.action("command: warp " + warp.id())
.build());
}
BlueFoundation.Menus.open(plugin, new MenuContext(player, menu)
.entries(entries)
.placeholder("{world}", player.getWorld().getName()));Entries fill the menu's dynamic-slots, or every slot no declared button occupies. On Bedrock they
become buttons after the declared ones, with previous/next at the end where a phone user scrolls to
find them. {page}, {page_total}, {page_next}, {page_previous}, {page_has_next},
{page_has_previous} and {entries_total} are substituted in every string.
actions:
- "command: spawn" # as the player
- "console: say hello" # as the console
- "message: <green>Done"
- "menu: warps_admin" # open another registered menu
- "back" # return to the menu this one was opened from
- "page: next" # next | previous | first | last | 3
- "close"
- "sound: UI_BUTTON_CLICK"
- "custom: shop buy diamond 3"custom routes to a handler you register, which is the extension point that keeps the shared
vocabulary from growing a case for every plugin that uses it:
BlueFoundation.Menus.handler("shop", (player, payload, context) -> {
shop.buy(player, payload);
return true;
});Actions may also be written as mappings, which is the only form that can restrict an action to one kind of click:
actions:
- type: command
value: warp spawn
click: RIGHT # LEFT | RIGHT | SHIFT | MIDDLE | any ClickType nameA click filter never matches on Bedrock, where a form button has one kind of press.
BlueFoundation ships no placeholder engine. Install a resolver once and every menu string - titles, names, lore, form bodies, action values, visibility conditions - goes through it:
BlueFoundation.Menus.resolver((player, text) -> PlaceholderAPI.setPlaceholders(player, text));visible-if is resolved the same way and read as a boolean. Anything that is not a recognised false
counts as true, so a placeholder that failed to resolve leaves the button visible rather than
silently emptying a menu.
title: "<gradient:#EC4899:#F43F5E>Warps</gradient>"
mode: auto # auto | chest | form
layout: paginated # static | paginated
size: 54 # or: rows: 6
page-size: 28
dynamic-slots: [10,11,12,13,14,15,16]
content: # the form body; ignored by chest menus
- "<gray>Pick somewhere to go"
empty: "<red>No warps yet"
form:
style: simple # simple | modal
icon: "https://example.com/icon.png"
sounds:
open: BLOCK_CHEST_OPEN
click: UI_BUTTON_CLICK
decoration:
material: GRAY_STAINED_GLASS_PANE
name: " "
slots: [0,1,2,3,4,5,6,7,8]
items:
spawn:
name: "<green>Spawn"
lore: ["<gray>Go home"]
material: "PLAYER_HEAD, SKULL_ITEM"
amount: 1
slot: 20 # or: slots: [20, 21]
skull: "{player}" # a name, a UUID, a base64 texture, or {player}
image: "https://example.com/spawn.png"
glow: false
hide-flags: true
hide-tooltip: false # removes the box, not just the text
enchantments: ["DURABILITY:1"]
visible-if: "%some_placeholder%"
actions:
- "command: spawn"
template: # the shape entries take in a paginated menu
name: "<aqua>{name}"
material: ENDER_PEARL
actions: ["command: warp {name}"]
navigation:
previous: { material: ARROW, name: "<yellow>Back", slot: 45 }
next: { material: ARROW, name: "<yellow>Next", slot: 53 }
close: { material: BARRIER, name: "<red>Close", slot: 49 }Every key is optional. A menu: wrapper key is unwrapped, so a menu can live in a file alongside a
plugin's own settings. Keys BlueFoundation does not recognise are kept and readable through
definition.property(key).
Navigation buttons get page: previous, page: next and close for free; an explicit actions
list overrides that.
mode: form draws only for Bedrock players and is skipped, with a console warning, for Java ones.
mode: chest draws a chest for everybody - Geyser renders Java containers on Bedrock natively, it
just looks like a chest rather than a native screen.
For when a Bedrock form is the point, rather than the Bedrock half of a menu:
BlueFoundation.Forms.send(plugin, player, SimpleForm.builder()
.title("Warps")
.content("Pick somewhere to go")
.button("Spawn", FormImage.url("https://example.com/spawn.png"), p -> p.performCommand("spawn"))
.button("Shop", p -> p.performCommand("shop"))
.onClosed(p -> p.sendMessage("Maybe later."))
.build());ModalForm is the two-button version. CustomForm is the one with fields, which has no chest
equivalent - it is the Bedrock answer to an anvil prompt:
BlueFoundation.Forms.send(plugin, player, CustomForm.builder()
.title("House settings")
.label("<gray>These apply to everyone who visits.")
.input("name", "House name", "My house", house.name())
.toggle("pvp", "Allow PvP", house.pvp())
.slider("radius", "Border radius", 16, 256, 16, house.radius())
.dropdown("weather", "Weather", "clear", "rain", "thunder")
.onSubmit((p, response) -> {
house.name(response.getString("name"));
house.pvp(response.getBoolean("pvp"));
house.radius((int) response.getFloat("radius"));
house.weather(response.getOption("weather"));
})
.build());Responses are read by key rather than by position, so inserting a label above a field later does not silently shift every value.
BlueFoundation.Bedrock.isBedrockPlayer(player) answers the same question the menus use to pick a
surface.
Three transports, tried in order, none of them a dependency:
- Floodgate's API, by reflection. Preferred when it is reachable - not because the others do not work, but because Floodgate then owns the form ids and the pending-form table.
- Geyser's API, by reflection. Covers Geyser running as a plugin without Floodgate.
- The raw
floodgate:formplugin message. Geyser handles this channel itself: a form type byte, a two-byte id, a JSON body, and an answer on the same channel. BlueFoundation builds and parses that JSON with its own writer and reader, so this path needs no GeyserMC class at all. The cost, and the reason it is last: Floodgate also listens on that channel and logsCouldn't find stored form for playerfor a form it did not send. Harmless, but noisy.
BlueFoundation.Forms.transportFor(plugin, player) returns which one would carry a form right now,
which is worth a line in a startup log or a diagnostics command.
Detecting a Bedrock player follows the same idea: Floodgate's API, then Geyser's, then the UUID -
Floodgate mints Bedrock UUIDs as new UUID(0, xuid), so a zero high half is a Bedrock player, which
is the test Floodgate's own isFloodgateId makes. That last check cannot see a Bedrock player who
logged in through a linked Java account; if neither API is reachable and your server uses linking,
tell BlueFoundation the username prefix from Floodgate's config:
BlueFoundation.Bedrock.usernamePrefix(".");Dialogs are a Java Edition feature, added in Minecraft 1.21.6. A Bedrock player connected
through Geyser does see one, because Geyser translates the packet into a native Bedrock form, but
that is Geyser's doing and it drops what a form cannot draw - item bodies in particular. For a menu
meant to look right on both editions, use Menus.
BlueFoundation.Dialogs.show(plugin, player, Dialog.confirmation("<red>Delete this house?")
.body("<gray>Everything in it goes with it. This cannot be undone.")
.yes(DialogButton.of("<red>Delete", DialogAction.callback((p, response) -> houses.delete(p))))
.no(DialogButton.of("<gray>Cancel"))
.build());Dialog.notice, Dialog.confirmation and Dialog.multiAction cover the three shapes. Buttons can
run a command, suggest one, open a URL, copy to the clipboard, or call back into your plugin - and a
callback button carries whatever the player typed into the dialog's inputs:
Dialog rename = Dialog.multiAction("<aqua>Rename house")
.input(DialogInput.text("name", "New name", house.name(), 32, null))
.input(DialogInput.bool("public", "Visible in discovery", house.isPublic()))
.input(DialogInput.numberRange("radius", "Border radius", 16, 256, 64f, 16f))
.input(DialogInput.singleOption("weather", "Weather", "clear", "rain", "thunder"))
.button(DialogButton.of("<green>Save", DialogAction.callback((p, response) -> {
house.name(response.getString("name"));
house.setPublic(response.getBoolean("public"));
house.radius(response.getInt("radius"));
house.weather(response.getString("weather"));
})))
.button(DialogButton.of("<gray>Cancel"))
.columns(2)
.build();Two server APIs can draw a dialog and no server has both - Spigot's net.md_5.bungee.api.dialog,
shipped from 1.21.6 in the separate net.md-5:bungeecord-dialog artifact, and Paper's
io.papermc.paper.registry.data.dialog. Whichever is present is used, both by reflection.
Neither exists before 1.21.6, and there is no way to fake one on an older client. So:
if (BlueFoundation.Dialogs.supported()) {
BlueFoundation.Dialogs.show(plugin, player, dialog); // throws if it cannot draw
} else {
BlueFoundation.Menus.open(plugin, player, "confirm_delete");
}show throws DialogUnsupportedException and writes one line to the console explaining what is
missing and what the server is running. trySend returns false instead, for callers that would
rather branch than catch. BlueFoundation.Dialogs.backendName() reports paper, spigot or
null.
Both facades hold a listener and, for the plugin-message transport, a channel registration. On a plugin that supports reloading:
@Override
public void onDisable() {
BlueFoundation.Menus.release(this);
BlueFoundation.Dialogs.release(this);
}