Skip to content

Custom Pages

Custom pages are the primary system for creating menus, dialogs, forms, and other interactive interfaces.

ClassPurpose
CustomUIPageAbstract base class
InteractiveCustomUIPage<T>Adds typed event handling (most common)
BasicCustomUIPageSimplified version without typed events

Extend InteractiveCustomUIPage<T> where T is your event data class:

public class MyPage extends InteractiveCustomUIPage<MyPage.EventData> {
public MyPage(PlayerRef playerRef) {
super(playerRef, CustomPageLifetime.CanDismiss, EventData.CODEC);
}
@Override
public void build(Ref<EntityStore> ref, UICommandBuilder cmd,
UIEventBuilder events, Store<EntityStore> store) {
// load .ui file
cmd.append("Pages/MyPage.ui");
// set initial values
cmd.set("#Title.Text", "Welcome!");
// bind events
events.addEventBinding(
CustomUIEventBindingType.Activating,
"#CloseButton",
EventData.of("Action", "close")
);
}
@Override
public void handleDataEvent(Ref<EntityStore> ref, Store<EntityStore> store,
EventData data) {
if ("close".equals(data.action)) {
this.close();
}
}
// event data class with codec
public static class EventData {
public static final BuilderCodec<EventData> CODEC = BuilderCodec.builder(
EventData.class, EventData::new)
.append(new KeyedCodec<>("Action", Codec.STRING),
(e, s) -> e.action = s, e -> e.action)
.add()
.build();
private String action;
}
}
  1. Constructor - Store references, initialize state
  2. build() - Called once when page opens; load UI, set values, bind events
  3. handleDataEvent() - Called for each client event
  4. sendUpdate() - Push changes to client
  5. onDismiss() - Called when page closes (cleanup)

Control how the page can be closed:

LifetimeBehavior
CantClosePlayer cannot dismiss; must be closed by server
CanDismissESC key dismisses the page
CanDismissOrCloseThroughInteractionESC or interaction closes
super(playerRef, CustomPageLifetime.CantClose, EventData.CODEC);

See Player API for how to obtain a PlayerRef.

Player player = store.getComponent(ref, Player.getComponentType());
PlayerRef playerRef = store.getComponent(ref, PlayerRef.getComponentType());
MyPage page = new MyPage(playerRef);
player.getPageManager().openCustomPage(ref, store, page);
// from outside the page
player.getPageManager().setPage(ref, store, Page.None);
// from inside the page
this.close();

State changes require explicit updates:

private int score = 0;
public void addScore(int points) {
this.score += points;
// must explicitly push the change
UICommandBuilder cmd = new UICommandBuilder();
cmd.set("#Score.Text", String.valueOf(this.score));
this.sendUpdate(cmd, new UIEventBuilder(), false);
}

The sendUpdate() parameters:

  • cmd - UI commands to execute
  • events - New event bindings (or empty builder to keep existing)
  • clear - If true, clears all existing content first

For major state changes, use rebuild() to re-run build():

// triggers build() again with fresh state
this.rebuild();

When to use which:

  • sendUpdate() - Small changes (update a label, toggle visibility). Faster, only sends diff.
  • rebuild() - Major changes (different layout, add/remove many elements). Re-runs build() entirely.

The event data class needs a codec to deserialize JSON from the client. See Codecs for more on the codec system.

public static class EventData {
public static final BuilderCodec<EventData> CODEC = BuilderCodec.builder(
EventData.class, EventData::new)
// static field
.append(new KeyedCodec<>("Action", Codec.STRING),
(e, s) -> e.action = s, e -> e.action)
// dynamic field (@ prefix in binding)
.append(new KeyedCodec<>("@SearchQuery", Codec.STRING),
(e, s) -> e.searchQuery = s, e -> e.searchQuery)
.add()
.build();
private String action;
private String searchQuery;
}

Register a page to open when players interact with blocks. See Adding Interactions for more on the interaction system.

// create an interaction that opens a page
OpenCustomUIInteraction interaction = new OpenCustomUIInteraction(
(playerRef, ref, store) -> new MyPage(playerRef)
);
// register with your custom block/entity interaction type

Complete example with increment/decrement buttons:

public class CounterPage extends InteractiveCustomUIPage<CounterPage.EventData> {
private int count = 0;
public CounterPage(PlayerRef playerRef) {
super(playerRef, CustomPageLifetime.CanDismiss, EventData.CODEC);
}
@Override
public void build(Ref<EntityStore> ref, UICommandBuilder cmd,
UIEventBuilder events, Store<EntityStore> store) {
cmd.append("Pages/Counter.ui");
cmd.set("#Count.Text", String.valueOf(this.count));
events.addEventBinding(
CustomUIEventBindingType.Activating,
"#IncrementBtn",
EventData.of("Action", "inc")
);
events.addEventBinding(
CustomUIEventBindingType.Activating,
"#DecrementBtn",
EventData.of("Action", "dec")
);
}
@Override
public void handleDataEvent(Ref<EntityStore> ref, Store<EntityStore> store,
EventData data) {
if ("inc".equals(data.action)) {
this.count++;
} else if ("dec".equals(data.action)) {
this.count--;
}
UICommandBuilder cmd = new UICommandBuilder();
cmd.set("#Count.Text", String.valueOf(this.count));
this.sendUpdate(cmd, new UIEventBuilder(), false);
}
public static class EventData {
public static final BuilderCodec<EventData> CODEC = BuilderCodec.builder(
EventData.class, EventData::new)
.append(new KeyedCodec<>("Action", Codec.STRING),
(e, s) -> e.action = s, e -> e.action)
.add()
.build();
private String action;
public static EventData of(String key, String value) {
EventData data = new EventData();
data.action = value;
return data;
}
}
}