Server UI Builder System
Why this system exists
Servers were previously limited to Call.menu, which only allowed a plain grid of buttons, and nothing else.
UI builders add a way to create a dynamic, serializable UI tree on the server, with support for multiple return values per dialog.
Note: This system is only available in build 160+.
An example dialog
static int voteId; //ID of vote menu
static{
//register handler once
voteId = Menus.registerMenuBuilder((player, result) -> {
Log.info(result); //for debugging
if(result.is("vote1")){ //player pressed vote1 button
MenuBuilder.of(
"""
id: table1
background: button
margin: 10
image{
region: "ok"
size: 300
}
row
label: "you voted!!!!"
""").id(voteId).update(player, "table1");
}else if(result.is("vote2")){ //player pressed vote2 button
MenuBuilder.of(
"""
id: table2
background: button
margin: 10
image{
region: "ok"
size: 300
}
row
label: "you voted!!!!"
""").id(voteId).update(player, "table2");
}else if(result.is("ok")){ //player pressed 'ok' button (it has clicked: ok)
//hide menu
Call.hideMenuBuilder(player.con, voteId);
//search label
Call.infoMessage(player.con, "You typed: " + result.getString("searchLabel"));
}
});
}
public static void show(){
//if you don't want to parse the DSL each time (might be a little slow), you can cache it with UiBuilder.parse("yourDsl") as a static field and pass that to MenuBuilder.
MenuBuilder.of("""
defaults{
pad: 2
}
table{
id: table1
background: button
margin: 10
image{
region: "ranai"
size: 300
}
row
//note: you can add a bundle to assets/bundles, and use "@somebundlekey" for text instead, which will automatically be localized!
label: "Map 1"
}
table{
id: table2
background: button
margin: 10
image{
//regions can be data patch regions too, e.g. dp-mysprite
region: "cat"
size: 300
}
row
label: "Map 2"
}
row
button{
fillX: true
height: 60
text: "Vote for map 1"
clicked: vote1
}
button{
fillX: true
height: 60
text: "Vote for map 2"
clicked: vote2
}
row
table{
colspan: 2
height: 50
growX: true
image{
region: zoom
size: 32
padRight: 8
}
label: "Input text here..."
field{
//by setting an ID, the value will be returned in the result, so you can call result.getString("searchLabel")
id: searchLabel
growX: true
}
}
row
button{
icon: ok
text: "Do something"
//result will be 'ok' when this is clicked
clicked: ok
colspan: 2
width: 200
height: 60
}
""")
.id(voteId) //menu ID, important, unless you're reusing IDs
.title("Select Map") //can be null for no title table at all
//.token(someLong) //you can pass an optional long token if you want to reuse menu IDs; this is returned in menu results, and can be used for tracking which specific menu the player had opened
.hideOnClick(false) //do not hide automatically when a button is clicked
.show(Groups.player.find(p -> !p.isLocal())); //show to first online player (for testing), substitute for proper person
}
Builder API vs DSL
Both produce the same NodeBuilder<?> tree; pick whichever is easier for the situation. DSL is more compact for static layouts, while the Java builder is easier when the layout depends on data (loops, conditionals in Java itself).
Builder API:
import static mindustry.ui.builder.UiBuilder.*;
TableBuilder ui = table()
.add(defaults().pad(2))
.add(
table().background("button").margin(10)
.add(image().region("ranai").size(300))
.row()
.add(label("Map 1"))
)
.row()
.add(button("Vote for map 1").fillX().height(60).clicked("vote1"));
MenuBuilder.of(ui).id(voteId).title("Select Map").show(player);
DSL:
MenuBuilder.of("""
defaults{
pad: 2
}
table{
background: button
margin: 10
image{
region: "ranai"
size: 300
}
row
label: "Map 1"
}
row
button{
fillX: true
height: 60
text: "Vote for map 1"
clicked: vote1
}
""").id(voteId).title("Select Map").show(player);
Conditional layout (portrait vs landscape)
Nodes accept a condition string. If it evaluates false, that node is skipped entirely (not just hidden). This is checked on the client at build time, so it naturally reacts to whatever screen shape that specific player has.
Supported conditions: "portrait", "landscape", or "<width|height> <op> <number>" where op is one of >=, >, <=, <.
table{
condition: "landscape"
row
label: "Wide layout: map previews side by side"
}
table{
condition: "portrait"
row
label: "Narrow layout: map previews stacked"
}
table{
condition: "width >= 900"
label: "Extra info panel, only on large screens"
}
Since this is evaluated per-client during tree build, each player sees the layout appropriate to their own window, from the same single DSL string sent by the server.
More examples
Player list with working search; builder API
Shows a list of players, filtered by a search box, with the filtered list rebuilt in place when you hit search. The menu handler is registered once in a static block.
update(player, "list") replaces a single element by id, not the whole dialog. The update builds the pane{ id: list } node with its rows and sends that, while the initial show call builds the full dialog (search bar included). That's why buildList is split out into its own method instead of being in buildRoot.
//replace with real player names
static String[] players = {"Alice", "Bob", "Charlie", "Dave", "Eve", "Frank", "Grace", "Heidi", "Ivan", "Judy"};
static int listId;
static{
listId = Menus.registerMenuBuilder((player, result) -> {
if(result.is("search")){
String query = result.getString("query", "");
//rebuild inner contents of search pane
MenuBuilder.of(buildList(query)).id(listId).update(player, "list");
}else if(result.result != null && result.result.startsWith("kick:")){
String target = result.result.substring("kick:".length());
Call.infoMessage(player.con, "Kicked: " + target);
}
});
}
public static void showPlayerList(Player viewer){
MenuBuilder.of(buildRoot("")).id(listId).hideOnClick(false).title("Players").show(viewer);
}
private static TableBuilder buildRoot(String query){
return table()
.add(defaults().pad(4))
.add(
table().growX()
//note: enter("search") makes the text field fire 'search' when enter is pressed, for convenience
.add(field(query).id("query").enter("search").hint("Search players...").growX())
.add(button("Go").clicked("search"))
)
.row()
.add(buildList(query));
}
private static PaneBuilder buildList(String query){
TableBuilder rows = table();
for(String name : players){
if(!query.isEmpty() && !name.toLowerCase().contains(query.toLowerCase())) continue;
rows.add(image("players").size(32f).padRight(5f))
.add(label(name))
.add(button("Kick").padRight(10f).width(150f).clicked("kick:" + name))
.row();
}
return pane().id("list").add(rows);
}
The target player's name is baked directly into the clicked result string ("kick:" + name) since a plain button has nothing else to attach an id-backed value to. hideOnClick(false) is set on the initial show so the dialog stays open after hitting search or kicking someone, since both are meant to update the list in place rather than close the menu.
Vote-kick confirmation with a slider threshold
defaults{
pad: 6
width: 300
}
label{
text: "Reason: tomfoolery."
labelAlign: center
}
row
slider{
id: threshold
min: 1
max: 8
step: 1
defaultValue: 3
text: "Votes needed"
}
row
button{
text: "Start Vote"
icon: ok
clicked: startVote
fillX: true
height: 50
}
The server reads result.getFloat("threshold") when startVote is returned.
Server settings panel with checkboxes and grouped buttons
defaults{
pad: 8
}
check{
id: Bingus
text: "Enable bingus"
checked: true
}
check{
id: frogs
text: "Enable frogs"
checked: false
}
row
label: "Difficulty"{ //placing it here is shorthand for text
colspan: 2
labelAlign: center
fillX: true
}
row
table{
colspan: 2
defaults{
width: 200
height: 50
}
button: "Easy"{ //also shorthand for text
group: difficulty
id: diffEasy //no clicked: here because it shouldn't close the dialog
style: togglet
}
button: "Normal"{
group: difficulty
id: diffNormal
style: togglet
}
button: "Insufferable"{
group: difficulty
id: diffInsufferable
style: togglet
}
}
row
button: "Save"{
clicked: save
colspan: 2
fillX: true
height: 50
}
group: difficulty makes the three difficulty buttons mutually exclusive (a ButtonGroup), and their checked state comes back in values for any id'd checkable element, so the server can read which one is currently selected when 'save' is pressed.
Cell properties
These apply to the cell a node occupies in its parent table, mirroring scene2d table layout. Boolean properties take no value on the builder API (just call the method); in the DSL they're written as key: true or key: false.
| Property | Type | Description |
|---|---|---|
grow |
bool | Expand and fill in both directions. |
growX |
bool | Expand and fill horizontally. |
growY |
bool | Expand and fill vertically. |
fill |
bool | Fill the cell in both directions (without expanding). |
fillX |
bool | Fill the cell horizontally. |
fillY |
bool | Fill the cell vertically. |
expand |
bool | Take up extra available space in both directions. |
expandX |
bool | Take up extra available horizontal space. |
expandY |
bool | Take up extra available vertical space. |
uniform |
bool | Force this cell's size to match other uniform cells in both directions. |
uniformX |
bool | Force uniform width with other uniform cells. |
uniformY |
bool | Force uniform height with other uniform cells. |
width |
float | Fixed cell width. |
height |
float | Fixed cell height. |
size |
float | Fixed width and height at once. |
minWidth |
float | Minimum width. |
maxWidth |
float | Maximum width. |
minHeight |
float | Minimum height. |
maxHeight |
float | Maximum height. |
pad |
float | Padding on all sides. |
padTop |
float | Top padding. |
padLeft |
float | Left padding. |
padBottom |
float | Bottom padding. |
padRight |
float | Right padding. |
align |
string | Alignment within the cell (top, bottom, left, right, center, topLeft, botLeft, topRight, botRight). |
colspan |
int | Number of columns this cell spans. |
Note: defaults{} blocks apply cell properties to every sibling node added after them within the same table body, but don't reach into nested table{}/pane{} blocks.
Elements
| Node | Purpose | Example (DSL) |
|---|---|---|
table |
A nested table/container. Supports background, margin, wrap (switches to a WrapTable instead of Table, which ignores rows/columns). |
table{ background: button margin: 10 label: "hi" } |
pane |
Scrollable container wrapping an inner table. Supports style. |
pane{ label: "scrollable content" } |
label |
Text label. Supports text, wrap, style, labelAlign. Resolves @bundleKey text itself, which can be sourced from bundles in the server assets/bundles folder. |
label: "Hello" |
image |
An image from the texture atlas or an icon. Supports region/icon, scaling, size. |
image{ region: "ok" size: 300 } |
button |
Text button, optional icon. Supports text, icon, style, clicked, group, checked. |
button{ text: "Vote" clicked: vote1 } |
imageButton |
Icon-only button. Supports icon, style, clicked, group, checked. |
imageButton{ icon: ok clicked: confirm } |
field |
Text input. Supports text, hint, maxLength, style, and id to read back the value. |
field{ id: search hint: "Search..." growX: true } |
check |
Checkbox. Supports text, checked, style, group, id. |
check{ id: ranked text: "Ranked only" checked: true } |
slider |
Slider. Supports min, max, step, defaultValue, style, id, text. Text can be a format string that contains {0} from a bundle. |
slider{ id: kickVotes min: 1 max: 10 step: 1 defaultValue: 3 text: "Votes: " } |
space |
Empty cell, useful as a spacer. | space |
buttonTable |
A Button that also acts as a container for other nodes (a whole clickable table). Supports style, clicked, group. |
buttonTable{ clicked: pick1 label: "Map 1" } |
defaults |
Not a real element; sets cell property defaults for later siblings in this block. | defaults{ pad: 4 } |
row |
Not a node; ends the current row and starts a new one. | row |
Any node with an id set has its element registered by that id. For interactive elements (field, slider, check, checkable button/buttonTable), that id is what shows up as a key in MenuResult.values when any button with a clicked result fires.
Button Styles
Reference for the style names available on Styles, usable via style: "name" on button/buttonTable (TextButtonStyle) and imageButton (ImageButtonStyle) nodes.
Text button styles (button element)
| Name | Description |
|---|---|
defaultt |
Default text button style, gray corners at 45 degrees. |
flatt |
Flat, square, opaque. |
grayt |
Flat, square, opaque, gray. |
flatTogglet |
Flat, square, toggleable. |
flatBordert |
Flat, square, gray border. |
nonet |
No background whatsoever, only text. |
logicTogglet |
Similar to flatToggle, but slightly tweaked for logic. |
flatToggleMenut |
Similar to flatToggle, but with a transparent base background. |
togglet |
Toggle variant of the default style. |
cleart |
Partially transparent square button. |
clearTogglet |
Clear, square, orange border, toggleable. |
fullTogglet |
Similar to flatToggle, but without a darker border. |
squareTogglet |
Toggle-able version of flatBorder. |
logict |
Special square button for logic dialogs. |
Image button styles (imageButton element)
| Name | Description |
|---|---|
defaulti |
Default image button style, gray corners at 45 degrees. |
nodei |
Used for research nodes in the tech tree. |
emptyi |
No background, tints the image itself when hovered. |
emptyTogglei |
Toggleable variant of emptyi. |
selecti |
Displays a border around the image when selected, used in the placement fragment. |
logici |
Pure black version of emptyi, used for the logic toolbar. |
geni |
Used for the toolbar in map generation filters. |
grayi |
Gray, toggleable, no background. |
graySquarei |
Gray square background, standard behavior. Equivalent to grayt. |
flati |
Flat, square, black background. |
squarei |
Square border. |
squareTogglei |
Square border, toggleable. |
grayTogglei |
Square border, toggleable. |
clearNonei |
No background unless focused, no border. |
cleari |
Partially transparent black background. |
clearTogglei |
Toggleable variant of cleari. |
clearNoneTogglei |
clearNone, but toggleable. |
Note: buttonTable elements can use any of these button styles.