Edit

Introduction to the Data-Driven UI (DDUI) Framework

The Data-Driven UI (DDUI) framework is a modern approach to building user interfaces in Minecraft Bedrock Edition scripts. It enables dynamic, real-time UI updates without rebuilding forms. New modal forms is the first step in our UI journey for creators. Stay tuned for more features in future updates!

This guide introduces the DDUI framework and highlights the key differences from the existing @minecraft/server-ui module.

Summary

The DDUI framework provides a modern, reactive approach to building user interfaces in Minecraft Bedrock scripts. Key advantages include:

  • Reactive data binding: UI automatically updates when Observable values change
  • Inline callbacks: Cleaner code with callbacks directly on buttons
  • Dynamic controls: Show, hide, enable, and disable controls at runtime
  • Unified form type: CustomForm handles most UI scenarios

For API reference documentation, see the @minecraft/server-ui module reference.

Prerequisites

  • Basic knowledge of TypeScript and Minecraft scripting
  • A behavior pack with scripting enabled

Overview of the frameworks

Existing server-ui module

The existing APIs provide three main form types:

Class Purpose
ActionFormData Displays a list of buttons for player selection
MessageFormData Shows a simple two-button confirmation dialog
ModalFormData Presents a questionnaire with various input controls

These forms are static—once displayed, their content cannot change until the player closes and reopens them.

DDUI framework

The DDUI framework introduces:

Class Purpose
CustomForm A unified, flexible form with dynamic content and reactive bindings
MessageBox A streamlined two-button dialog with enhanced options
Observable* A reactive wrapper for values that automatically updates the UI where * is String, Boolean, Number, and UIRawMessage

DDUI forms are dynamic—content can update in real-time while the form is open.

Form types

CustomForm

CustomForm is the primary form type in DDUI. It combines the functionality of all three server-ui form types into a single, flexible class.

import { CustomForm, Observable } from "@minecraft/server-ui";

const playerName = new ObservableString("Player", { clientWritable: true });
const difficulty = new ObservableNumber(1, { clientWritable: true });
const musicEnabled = new ObservableBoolean(true, { clientWritable: true });
const volumeLevel = new ObservableNumber(75, { clientWritable: true });

new CustomForm(player, "Game Settings")
    .closeButton()
    .spacer()
    .label("General Settings")
    .divider()
    .textField("Player Name", playerName, {
        description: "Your display name in-game"
    })
    .spacer()
    .label("Difficulty")
    .dropdown("", difficulty, [
        { label: "Peaceful", value: 0 },
        { label: "Easy", value: 1 },
        { label: "Normal", value: 2 },
        { label: "Hard", value: 3 }
    ])
    .spacer()
    .divider()
    .label("Audio Settings")
    .toggle("Music Enabled", musicEnabled)
    .slider("Volume", volumeLevel, 0, 100, {
        description: "Master volume level",
        step: 5
    })
    .spacer()
    .button("Reset to Defaults", () => resetSettings())
    .show()
    .then(() => {
        console.log(`Settings saved: ${playerName.getData()}`);
    })
    .catch(e => {
        console.error(e);
    });

MessageBox

MessageBox provides a streamlined way to create simple two-button dialogs:

import { MessageBox } from "@minecraft/server-ui";

new MessageBox(player, "Confirm Action")
    .body("Are you sure you want to delete this item? This action cannot be undone.")
    .button1("Delete")
    .button2("Cancel", "Keep the item and close this dialog")
    .show()
    .then((response) => {
        // The selection will be undefined if they user did not make a selection 
        // and the UI was closed, 1 for button 1 and 2 for button 2.
        if (response.selection === 1) {
            deleteItem();
        }
    })
    .catch(e => {
        console.error(e);
    });

Working with Observables

Creating Observables

// Read-only Observable (server controls the value)
const status = new ObservableString("Loading...");

// Client-writable Observable (UI controls can update it)
const userInput = new ObservableString("", { clientWritable: true });

Updating Observable values

const entityCount = new ObservableNumber(0);

// Update the value - UI automatically reflects the change
system.runInterval(() => {
    const entities = dimension.getEntities();
    entityCount.setData(entities.length);
}, 20);

Subscribing to changes

const selectedOption = new ObservableNumber(0, { clientWritable: true });

selectedOption.subscribe((newValue) => {
    console.log(`Selection changed to: ${newValue}`);
    updatePreview(newValue);
});

Using Observables for dynamic labels

const statusLabel = new ObservableString("Ready");
const itemCount = new ObservableNumber(0);

// Update label based on data changes
itemCount.subscribe((count) => {
    statusLabel.setData(`Found ${count} items`);
});

new CustomForm(player, "Inventory")
    .label(statusLabel) // Label updates automatically
    .button("Refresh", () => refreshInventory())
    .show()
    .catch(e => {
        console.error(e);
    });

Localization support

DDUI supports localized text using the RawMessage format:

new CustomForm(player, { translate: "ui.settings.title" })
    .label({ translate: "ui.settings.description", with: ["value1", "value2"] })
    .button({ translate: "ui.button.save" }, () => save())
    .show()
    .catch(e => {
        console.error(e);
    });

Examples

Example 1: Simple action menu

import { CustomForm } from "@minecraft/server-ui";

function showMenu(player: Player) {
    new CustomForm(player, "Main Menu")
        .label("What would you like to do?")
        .spacer()
        .button("Play Game", () => startGame(player))
        .button("Settings", () => openSettings(player))
        .button("Exit", () => exitGame(player))
        .show()
        .catch(e => {
            console.error(e);
        });
}

Example 2: Settings form with validation

import { CustomForm, Observable } from "@minecraft/server-ui";

function showSettings(player: Player) {
    const username = new ObservableString("", { clientWritable: true });
    const renderDistance = new ObservableNumber(12, { clientWritable: true });
    const showParticles = new ObservableBoolean(true, { clientWritable: true });
    const language = new ObservableNumber(0, { clientWritable: true });

    new CustomForm(player, "Settings")
        .textField("Username", username, {
            description: "Your display name"
        })
        .slider("Render Distance", renderDistance, 2, 32, {
            step: 1,
            description: "Higher values may impact performance"
        })
        .toggle("Show Particles", showParticles, {
            description: "Display particle effects"
        })
        .dropdown("Language", language, [
            { label: "English", value: 0 },
            { label: "Spanish", value: 1 },
            { label: "French", value: 2 }
        ])
        .closeButton()
        .show()
        .then(() => {
            applySettings(
                username.getData(),
                renderDistance.getData(),
                showParticles.getData(),
                language.getData()
            );
        })
        .catch(e => {
            console.error(e);
        });
}

Example 3: Real-time updating form

This example demonstrates a capability unique to DDUI—updating form content while it's open:

import { CustomForm, Observable } from "@minecraft/server-ui";
import { system, TicksPerSecond } from "@minecraft/server";

function showEntityMonitor(player: Player) {
    const entityStatus = new ObservableString("Scanning...");
    const killButtonDisabled = new ObservableBoolean(true);
    const statusMessage = new ObservableString("");

    // Update entity count every 4 seconds while form is open
    const intervalId = system.runInterval(() => {
        const nearbyEntities = player.dimension.getEntities({
            location: player.location,
            maxDistance: 20
        });

        entityStatus.setData(`Found ${nearbyEntities.length} entities within 20 blocks`);
        killButtonDisabled.setData(nearbyEntities.length === 0);
    }, TicksPerSecond * 4);

    new CustomForm(player, "Entity Monitor")
        .spacer()
        .label(entityStatus)
        .spacer()
        .button("Remove All Entities", () => {
            const entities = player.dimension.getEntities({
                location: player.location,
                maxDistance: 20,
                excludeTypes: ["minecraft:player"]
            });

            let count = 0;
            for (const entity of entities) {
                entity.kill();
                count++;
            }

            statusMessage.setData(`Removed ${count} entities`);
        }, {
            tooltip: "Removes all non-player entities within range",
            disabled: killButtonDisabled
        })
        .spacer()
        .divider()
        .label(statusMessage)
        .closeButton()
        .show()
        .then(() => {
            // Clean up interval when form closes
            system.clearRun(intervalId);
        })
        .catch(e => {
            console.error(e);
        });
}

Migration guide

When migrating from @minecraft/server-ui to DDUI:

Server-ui DDUI Notes
new ActionFormData() new CustomForm(player, title) Player passed at creation
new ModalFormData() new CustomForm(player, title) Unified form type
new MessageFormData() new MessageBox(player) Simplified message dialogs
.show(player) .show() Player already provided
.button("text") .button("text", callback) Inline callbacks
response.formValues[0] observable.getData() Direct value access
Static content only Dynamic with Observable Real-time updates

Key differences

1. Reactive data binding with Observable

The most significant difference is the introduction of the Observable class. Instead of passing static values to form controls, you pass reactive Observable wrappers that can update the UI automatically.

Server-ui (static)

import { ModalFormData } from "@minecraft/server-ui";

const form = new ModalFormData()
        .title('Settings')
        .slider('Volume', 0, 100, { valueStep: 5, defaultValue: 50 }); // Static default of 50

form.show(player).then((response) => {
        if (!response.canceled) {
            const volume = response.formValues[0]; // Access by index
        }
    })
    .catch(e => {
        console.error(e);
    });

DDUI (reactive)

import { CustomForm, Observable } from "@minecraft/server-ui";

const volume = new ObservableNumber(50, { clientWritable: true });

new CustomForm(player, 'Settings')
    .slider('Volume', volume, 0, 100, { step: 5 })
    .show()
    .then(() => {
        const currentVolume = volume.getData(); // Access via Observable
    })
    .catch(e => {
        console.error(e);
    });

Tip

The clientWritable: true option allows the client to update the Observable value when the player interacts with the control.

2. Form creation pattern

Server-ui

Forms are created with a constructor and the player is passed to show():

const form = new ActionFormData()
    .title("My Form")
    .body("Select an option")
    .button("Option 1");

form.show(player)
    .catch(e => {
        console.error(e);
    });

DDUI

Forms are created with a static factory method that accepts both the player and title:

const form = new CustomForm(player, "My Form")
    .label("Select an option")
    .button("Option 1", () => handleOption1());

form.show()
    .catch(e => {
        console.error(e);
    });

3. Button callbacks vs. selection indices

Server-ui

Button selection is determined by checking an index in the response:

import { ActionFormData } from "@minecraft/server-ui";

new ActionFormData()
    .title("Actions")
    .button("Save")
    .button("Load")
    .button("Exit")
    .show(player)
    .then((response) => {
        if (response.canceled) return;

        switch (response.selection) {
            case 0: save(); break;
            case 1: load(); break;
            case 2: exit(); break;
        }
    })
    .catch(e => {
        console.error(e);
    });

DDUI

Buttons accept inline callback functions that execute when pressed:

import { CustomForm } from "@minecraft/server-ui";

new CustomForm(player, "Actions")
    .button("Save", () => save())
    .button("Load", () => load())
    .button("Exit", () => exit())
    .show()
    .catch(e => {
        console.error(e);
    });

Note

DDUI callbacks execute immediately when the button is pressed, while the form remains open. This enables multi-action forms where users can interact multiple times before closing.

4. Dynamic form controls

DDUI forms can dynamically enable, disable, show, or hide controls based on Observable values.

const isAdvancedMode = new ObservableBoolean(false, { clientWritable: true });
const isNotAdvancedMode = new ObservableBoolean(true, { clientWritable: true });
const showAdvancedOptions = new ObservableBoolean(false, { clientWritable: true });

isAdvancedMode.subscribe(newVal => {
    isNotAdvancedMode.setData(!newVal);
});

new CustomForm(player, "Settings")
    .toggle("Enable Advanced Mode", isAdvancedMode)
    .toggle("Show Advanced Options", showAdvancedOptions)
    .button("Advanced Settings", () => openAdvancedSettings(), {
        visible: showAdvancedOptions,
        disabled: isNotAdvancedMode // Disabled when advanced mode is off
    })
    .show()
    .catch(e => {
        console.error(e);
    });

5. Enhanced control options

DDUI controls support additional options like descriptions and tooltips:

new CustomForm(player, "Player Settings")
    .textField("Display Name", playerName, {
        description: "Enter the name shown to other players",
        disabled: isLocked
    })
    .button("Apply Changes", () => applyChanges(), {
        tooltip: "Save your current settings"
    })
    .dropdown("Difficulty", selectedDifficulty, [
        { label: "Easy", value: 0, description: "Reduced enemy damage" },
        { label: "Normal", value: 1, description: "Standard gameplay" },
        { label: "Hard", value: 2, description: "Increased challenge" }
    ])
    .show()
    .catch(e => {
        console.error(e);
    });

6. Dealing with User Busy

DDUI forms will not show if another UI is open. You can check for this by looking at the result of the show methods:

new CustomForm(player, "Player Settings")
    .textField("Display Name", playerName, {
        description: "Enter the name shown to other players",
        disabled: isLocked
    })
    .show()
    .then(showResult => {
        if (showResult === DataDrivenScreenClosedReason.UserBusy) {
            player.sendMessage("Player was busy, try again.");
            return;
        }

        player.sendMessage(`Display name is ${playerName.getData()}`)
    })
    .catch(e => {
        console.error(e);
    });

See also