@bedrock-core/ui-runtime
v0.9.2
Published
@bedrock-core/ui runtime
Maintainers
Readme
@bedrock-core/ui-runtime
⚠️ Beta Status: Active development. Breaking changes may occur until 1.0.0. Pin exact versions for stability.
This is not ready for production use.
Core framework library for @bedrock-core/ui. This package contains the JSX runtime, serialization protocol, component system, and rendering logic that powers @bedrock-core/ui.
📦 Installation
This package is typically installed as a dependency when using @bedrock-core/ui:
yarn add @bedrock-core/uiOr directly:
yarn add @bedrock-core/ui-runtime🧱 Architecture Overview
| Layer | Responsibility | Key Files |
|-------|----------------|-----------|
| JSX Runtime | Transforms JSX to { type, props } elements | src/jsx/jsx-runtime.ts |
| Component Functions | Pure functions returning JSX.Element objects | src/core/components/*.ts |
| Serialization Protocol | UTF‑8 fixed-width, semicolon-padded segments | src/core/serializer.ts |
| Runtime (Entry) | Orchestrates build → snapshot → show → response; owns effect loop | src/core/runtime.ts |
| Renderer Adapter | Serializes UI tree to a form snapshot | src/core/rendererAdapter.ts |
| Fiber Registry | Manages component instances and hook state | src/core/fiber.ts |
| Context System | React-like context providers and consumers | src/core/context.ts |
| Hooks System | State management and side effects | src/core/hooks/*.ts |
🔄 Component Routing System
The system routes components through the native form factory's typed slots — the
engine itself dispatches each entry to the matching RP control, which is the cheapest
routing available (no per-entry #type gating for the slot decision):
For Client-Only Components (Panel, Text, Image, Fragment):
Panel/Textserialize viaform.label()→label_router;Imageserializes viaform.header()→ the slimheader_router(engine-level type routing; on the modal backend images fall back to the label slot)label_routerdecodes the cell's scroll-region field once, then mounts one mergedlabel_cell: a single control-block decode plus small self-gating leaves — a background image (the common[440]field, which also servespanelcells and foldedPanel+Textpairs) and per-type[1,1]label frames gated on#type- Serializer cell elision: a background-less
<Panel>emits nothing (its children are independent absolutely-positioned cells), and aPanel(background)wrapping a single<Text>folds into one text cell — both cut the engine's per-cell instantiation cost, the dominant cost on large screens - Decode bindings carry
binding_condition: gate chains areonce(the payload is constant per screen instance) and everything downstream isvisible, so a non-matching leaf costs only its ~7-binding gate and nothing re-evaluates per frame
For Native Form Components (Form and its Form.* fields — shipped in 0.9):
- A
<Form>on the tree switches the renderer from the all-buttons ActionForm backend to a nativeModalFormDatabuild (one atomic submit) - Field controls (
Form.Toggle,Form.Slider,Form.Dropdown,Form.InlineSelect,Form.Input) map to the native modal control factory (toggle/slider/dropdown/input) instead of the label router - They still use the same serialization protocol, so the RP decoders share the field-slicing machinery; values arrive once, on submit, keyed by each control's
name Form.Buttonis NOT a native control — it consumes noformValuesslot; its geometry + styling ride the form title payload (assembled post-layout) and wire to the engine's submit / close button ids
This "label-as-entry-point" system allows unlimited custom client components, while the modal backend leverages Minecraft's native form factory for interactive fields.
⚡ Performance Principles
JSON UI cost is dominated by how many controls the engine instantiates and how many bindings it evaluates at screen creation — not by payload string length (measured: +10KB per element made no difference) and not by re-evaluation once conditions are set. The architecture therefore follows these rules (established with in-game measurements):
- Fewer cells beats everything. Every emitted form entry instantiates a router
subtree in every mounted scroll-pool slot. The serializer skips no-op cells
(background-less panels) and folds
Panel+Textpairs before anything reaches the engine. - One decode per cell. The merged
label_cellruns the control-block decode once; type-specific visuals are cheap self-gating leaves, not sibling full-decode variants. binding_conditionon everything. Gate chains (#type, region) areonce— the payload is constant for the lifetime of a screen instance. All other decode chains arevisible, so gated-off leaves skip their work. Only genuinely live channels (toggle state, dropdown open state/selection, typed text) stay unconditioned. Never condition a binding that a hidden control needs to compute its own visibility.- Instantiation is parse-time.
visible: falsedoes NOT prevent instantiation and#collection_lengthcan NOT be view-computed (both probe-verified) — the only real levers are emitting fewer entries and mounting fewer pool slots. That is why the scroll pool is capped at 2 pooled scrolls (MAX_POOLED_SCROLLS); growing it means re-adding the RP slot + router variants and accepting the per-slot cost.
🧩 Component Pattern
Components are pure functions that return JSX.Element objects (using the custom JSX runtime):
import { FunctionComponent, JSX } from '@bedrock-core/ui';
import { ControlProps, withControl } from './control';
export interface PanelProps extends ControlProps {
// Component-specific props go here
}
export const Panel: FunctionComponent<PanelProps> = ({ children, ...rest }: PanelProps): JSX.Element => ({
type: 'panel',
props: {
...withControl(rest), // Must be first - applies control props in canonical order
// Component-specific props with defaults go here after withControl
children, // Children always last
},
});Conventions:
- Dimension and position props use flexbox style values (texels or percent strings like
"50%"); the layout engine resolves these to absolute Pocket-space texels before serialization - Props order is critical:
withControl(rest)must always be first in the props object, followed by component-specific props with default values, thenchildrenlast - Component prop names are camelCase; JSON UI bindings use snake_case
- Use the custom JSX runtime - no need to import React
- All "optional" props must have defined defaults - no undefined/null values in serialized output
- The
serialize()function incore/serializer.tshandles the encoding automatically - The
withControl()helper applies standard control properties in canonical order
🔐 Serialization Protocol
Defined in src/core/serializer.ts.
Payload always starts with a 9-character header: bcui + vXXXX (e.g., bcuiv0005). Decoders must skip these first 9 chars before field slicing.
Each field is composed of three conceptual parts concatenated in this order:
- Type prefix (2 bytes)
- Value (padded with semicolons
;until defined byte length) - Unique 1‑byte field marker (disambiguates otherwise identical full regions during JSON UI subtraction)
Field Widths (bytes)
| Type | Prefix | Prefix Width | Type Width | Marker Width | Full Width | Notes |
|----------|--------|--------------|------------|--------------|------------|-------|
| String | s: | 2 | 80 | 1 | 83 | Prefer to use translation keys when text larger than 32 characters is needed |
| Number | n: | 2 | 80 | 1 | 83 | Expanded to match string width in v0003; unified field sizing since v0004 |
| Boolean | b: | 2 | 5 | 1 | 8 | Serialized as 'true' or 'false' |
| Reserved | r: | 0 | variable | 0 | variable | No prefix/marker for easier JSON UI skipping |
Markers
Markers come from a stable ordered alphabet (0-9A-Za-z-_), limiting components to 64 props max.
Index position = field order. Never reorder existing markers (backward decode offsets rely on stable sequence).
Encoding Example
import { serializeProps } from '@bedrock-core/ui-runtime';
const [encoded, bytes] = serializeProps({
type: 'example', // string → 83 bytes
message: 'hello', // string → 83 bytes
count: 123, // number → 83 bytes
ratio: 45.67, // number → 83 bytes
ok: true, // bool → 8 bytes
});
// Total: 83 + 83 + 83 + 83 + 8 = 340 bytes (plus 9-byte header = 349 bytes)Field Binding Template Pattern (Decoding)
Decoding inside the resource pack uses a progressive "slice → subtract" strategy. Each field follows a 3‑step lifecycle:
extract_raw → update_remainder → extract_value
Generic template (JSON UI binding entries) — copy & replace placeholders:
{
"binding_type": "view", // full_width
"source_property_name": "('%.{FULL_WIDTH}s' * #rem_after_{PREV})",
"target_property_name": "#raw_{FIELD_NAME}"
},
{
"binding_type": "view",
"source_property_name": "(#rem_after_{PREV} - #raw_{FIELD_NAME})",
"target_property_name": "#rem_after_{FIELD_NAME}"
},
{
"binding_type": "view", // (full_width - marker_width) - prefix_width - padding_char (;)
"source_property_name": "(('%.{FM_WIDTH}s' * #raw_{FIELD_NAME}) - ('%.2s' * #raw_{FIELD_NAME}) - ';')",
"target_property_name": "#{FIELD_NAME}"
}Placeholder reference:
{FIELD_NAME}- unique identifier (e.g.type,visible){PREV}- previous remainder token (first field usesheader, others use previous field name){FULL_WIDTH}- from table full_width column{FM_WIDTH}- table (full_width - marker_width)
For reserved blocks: Simply skip by subtracting the fixed byte count from remainder, no extraction needed.
Base Control Properties Deserialization Order
All components inherit these base control properties, which are deserialized in this exact order after the 9-byte protocol header (bcuiv0005):
Field 0: type (string, 83 bytes) - component type identifier
Field 1: width (number, 83 bytes) - element width
Field 2: height (number, 83 bytes) - element height
Field 3: x (number, 83 bytes) - horizontal position
Field 4: y (number, 83 bytes) - vertical position
Field 5: visible (bool, 8 bytes) - visibility state (default: true)
Field 6: enabled (bool, 8 bytes) - interaction enabled (default: true)
Field 7: background (string, 83 bytes) - background texture path
Field 8: $reserved (501 bytes) - reserved for future expansion
Total control block: 1024 bytes (9 + 83 + (5×83) + (2×8) + 83 + 501)Component-specific properties are appended after the reserved block.
Title Metadata (Screen Selection)
Two distinct payloads share the protocol. Each form button label carries a component's control block (above), while the form title (#title_text) carries screen-level metadata. The title is produced by serializeTitleMetadata() and selects which Resource Pack screen layout to mount:
| Field | Type | Bytes | Purpose |
|--------|--------|-------|---------|
| header | — | 9 | bcuiv0005 protocol header |
| 0 | string | 83 | screen type: scroll (extensible — more types may be added) |
| 1 | number | 83 | root content height (used to size the scroll container) |
Total: 175 bytes. The screen type comes first because every screen reads it to route; the height comes second because only the scrolling layouts need it.
Deserialization (core-ui/common/screen_container.json):
- Bind
#title_text, strip the 9-byte header →#rem_after_header_sc. - Slice the 83-byte screen-type field and extract its value (drop the
s:prefix +;padding) →#screen_type. - Route by comparison on
#screen_type(currently onlyscroll; the container mountscore_ui_screens.scrollwhen#screen_type = 'scroll'). The routing stays so new screen types can be added without protocol changes.
Scrolling layouts (core_ui_screens.scroll) additionally skip the screen-type field to reach the height field, then bind it to #size_binding_y. No protocol guard is needed inside screen_container — server_form.json only mounts it for forms whose title carries the header (it collapses the container to 0px otherwise).
Decoding Rules & Tips
- Always slice FULL field (value + prefix + marker) first, then subtract to create the remainder
- Strip padding only after isolating the core full segment (second slice) so you don't accidentally remove semicolons in later fields
- Never assume a marker character appears only once globally—its uniqueness is only relative to its position
- Protocol extension rule: append new fields (new markers) at the end; never reorder or shrink earlier core lengths
- Reserved blocks are skipped entirely in deserialization—they create "gaps" in the payload that the JSON UI decoder jumps over
🪝 Hooks System
Hooks follow React-like patterns but adapted for Minecraft server environment:
useState(initial)– State hook with setter functionuseEffect(fn, deps)– Effect hook with dependency arrayuseRef(initial)– Mutable ref containeruseContext(context)– Access context value from ProvideruseReducer(reducer, initial)– Reducer hook for complex stateusePlayer()– Current player from render contextuseEvent(eventKey)– Listen to global eventsuseExit()– Cleanup callback when form closes
Rules:
- Hooks must be called in consistent order (no conditional hook calls)
- Hooks are stored per-component-instance in FiberRegistry
useEffectcleanup functions run on instance deletion
🧪 Testing
This package uses Vitest with mocked Minecraft APIs:
yarn test # Run tests
yarn coverage # Generate coverage reportMocks are located in src/__mocks__/@minecraft/.
⚠️ Known Caveats
- JSON UI string ops with numbers can behave unpredictably; prefix markers before numeric-derived substrings client-side.
- Texel values & JSON UI: Dimension and position values are serialized as raw integer texels (Pocket-space). JSON UI ignores numbers with decimal points, so the layout engine rounds all values to integers before serialization.
- Subtraction operator (
-) removes all occurrences; use distinct prefixes to avoid collisions.
⚠️ Breaking Change Guards
- Never modify
TYPE_WIDTH,PAD_CHAR, or canonical field order - Never change the 9-char header format (
bcui+ version) - Always append new fields to end; use
reserveBytes()for future space - Always increment
VERSIONwhen making protocol-breaking changes (with migration docs) - Test rigorously – serialization format is frozen once clients decode it
📖 Reference Documentation
For JSON UI decoding implementation details:
