Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Personal client mods

Personal mods run under the user’s grants in local and remote worlds. Server consent does not control these grants. For server-delivered client parts, use paired mods. Personal mods use mod-api, not the Experience SDK’s client world.

Build and load

The hello example uses the extension world:

rustup target add wasm32-unknown-unknown
cargo build -p hello-mod --target wasm32-unknown-unknown --locked
cargo run -p mod-host --bin mod-host --locked -- pack \
  target/wasm32-unknown-unknown/debug/hello_mod.wasm /tmp/cinnabar-hello.wasm
cargo run -p mod-host --bin mod-host --locked -- probe /tmp/cinnabar-hello.wasm

Import the component in Settings → Mods, review permissions and Enable it. Reload rereads the installed path; atomically replace that file to update the same registration. Invalid replacements retain the previous instance. Successful reloads reset guest memory and preserve companion settings. A guest trap quarantines that mod and clears its outputs. Reload a valid changed component to recover.

For explicit development selection, set CINNABAR_MOD_COMPONENT to an absolute component path. CINNABAR_MOD_PACKAGE takes precedence, and CINNABAR_MOD_SET can select an ordered JSON list. Registered enabled mods run before environment mods. The client still needs its normal runtime carriers.

API and grants

extension.wit defines the low-level world. The player-mod world supports package screens and session catalogs; implement PlayerMod and call export_player_mod!. Init and frame are required; event callbacks default to no-ops. Input edges expire, while latest layout/session changes wait for a callback lane. Frame, event and load work have separate budgets.

Optional grants default off in ModGrants. A component may request them in top-level cinnabar:grants JSON; requests grant nothing by themselves. Commands authorize only the requested names. Settings can revoke individual grants or all grants; permission changes reload that mod.

Joining, disconnecting or changing connections creates a fresh guest so observations cannot carry across connection boundaries. Same-connection dimension transfers retain guest memory and HUD surfaces, including while world observations are unavailable. World drawing handles, observations and pending world output are cleared on transfer and rebound to the destination; mods must rebuild their world drawing. Replacements reuse an in-memory compiled component only for identical mod bytes and engine policy. Changed bytes invalidate that entry, and unloading the mod releases it. Explicit environment-selected components get the environment grant and opt into other grants with the development switches.

Capability groupContract source
Read-only inventory/effects, poses and physicsplayer-state and gameplay in extension WIT; unavailable data is optional, not inferred
Camera, item use, movement and attack requestsWIT gameplay; frame-local policies expire even when a callback is deferred
Native damage shakecamera.read-damage-shake with camera: the last presented shake at full strength, in view-tilt degrees, so a suppressing tilt can redraw it at another intensity
Controls, settings and personal panelsWIT input, settings, panel; settings use a bounded companion file
Touch buttonsWIT touch; controls; labelled buttons in the built-in touch style, placed by players in the touch layout editor
HUD surface, text metrics and vanilla elementsWIT hud; see HUD surfaces; retained output clears on quarantine/reload/unload
Nametag style, actor annotations and tints, hurt overlay, tooltip lines, chat actionsWIT presentation; hud, plus players/entities for per-actor rows; merged across mods in load order
Item, food, potion and effect facts, and every installed foodWIT facts; player-state, with its own per-callback read budget
Time/weather and local menu brandingWIT environment; changes presentation, not server state
World primitives and post passesWIT render; render example
Retained world drawingdrawing WIT; separate material/depth/post/through-wall grants
Entity masksMask reference
Nearby terrainTerrain reference
Block search and fullbrightterrain.find-blocks drawn with world drawing; environment.set-fullbright
Attack/use filteringOpt-in filter-actions export through mod_api::filtered_player_mod; controls
Remaining callback fuelWIT budget; no grant: it reads only the running callback’s own allowance

Callbacks publish only after success. Session changes, reload, revocation and traps invalidate scoped handles and outputs. Check typed errors rather than silently assuming an import succeeded. Shared values and ceilings live in mod-api, drawing limits and host limits.

gameplay.set-attack-reach requires interaction; a non-none request also requires a current gameplay frame. It sets the local actor pick and attack distance up to MAX_ENTITY_REACH_BLOCKS. none restores the current mode and input profile. The override preserves attack eligibility and block obstruction checks, leaves mining and block-use reach alone, and does not change the server’s attack validation. The existing generic interaction grant covers this request; individual mod features do not get separate grants.

HUD surfaces

hud.set-surface publishes the mod’s JSON-UI layer over gameplay, below vanilla screens and never hit-tested. It takes the panel surface schema, screen, document, bindings, collections and transitions, without input actions, glass backdrops or cinnabar_hud_control. Empty text removes it. hud.update-surface takes remove_bindings, bindings (replace all), binding_changes (sparse), collection_order and controls.

remove_bindings:["#row_7","#row_8"] unbinds names before the patch’s other bindings apply, which frees room in the binding budget. Each name must be bound and listed once, the same patch must not also set it, and the list holds at most the 512-binding limit; otherwise the whole patch is rejected. The document may still read a removed name: it then draws exactly as an unbound name does in a fresh set-surface, and a control that followed a removed projection stays at its authored place.

controls replaces or removes top-level control definitions of the document by name, without namespace:

{"controls":{"armor_card":{"type":"panel","size":[60,20],"controls":[]},
 "preview_ping@card":{},"old_preview":null}}

A key may carry an @ base as a document key does. The patched document is admitted exactly as set-surface would admit it whole, with the same errors, but the host rechecks only the replaced definitions and the root children that read them, so patching a control every frame stays cheap. A patch to the root’s own properties, a rejected patch, and documents with collections, a root base or an @ inside a string value are validated whole. Only the controls whose resolution reads a replaced definition, embedded ones included, are laid out again; everything else keeps its cache. Values, documents and limits live in surface: 512 bindings, 192 KiB of document and 64 KiB per update. Only the screen root draws on gameplay; other named controls in the document stay embeddable. The layer hides with the gameplay HUD, under F1, screens and panels that hide it; it stays under a panel that sets show_gameplay_hud. Several mods’ layers draw in load order.

panel.set-content sets a panel’s show_gameplay_hud, and a panel.update-content patch {"show_gameplay_hud":true} changes it, so one panel can show the HUD on only some of its routes. The flag lasts until another patch changes it or set-content replaces the panel.

Each top-level control of the root is laid out, painted and cached on its own, so a binding change repaints only the controls that read it. A root that draws itself, a stack_panel root or sizes measured from children or siblings keep the root as one cached control.

Surfaces in HUDs and panels accept two host-resolved values:

{"#helmet":{"item":{"identifier":"minecraft:diamond_helmet","metadata":0}},
 "#home":{"project":{"world":[10.5,64,-3]}},
 "#target":{"project":{"actor":42,"offset":[0,2.1,0]}}}

Bind an item value to an inventory_item_renderer node’s #item_renderer_data; unknown items draw nothing. A projected value becomes the point’s [x, y] GUI offset from the root’s top left every frame, using the actor’s interpolated feet; bind it to #offset. The host also publishes #home_visible, true only while the point is in front of the camera, inside the root and, for an actor, present. Actor points need players for players and entities for other actors. When a control’s own #offset and #visible are its only projected bindings, the host moves its cached artwork instead of painting it again.

A panel node {"type":"custom","renderer":"cinnabar_hud_control","control":"armor"} draws the named control armor of the same mod’s HUD document, with the HUD’s bindings. "control_binding":"#item_card" reads the name from a text binding or a collection field instead. The control is centred in the node. By default its laid-out size is fitted uniformly to the node, so authored padding frames a preview. "scale":s instead draws the laid-out box at s times its HUD size. "fit":f, a fraction in (0, 1], fits the control’s painted ink instead, ignoring scale: the ink fills f of the node along its limiting axis, at most max_scale times its HUD size, 5 by default, so a short caption fills its preview as a card does. scale and max_scale accept 0.25 to 5. Each property reads its binding override first, #scale, #fit or #max_scale; the surface rejects literals out of bounds, and a bound value out of bounds draws nothing. The bounds live in hud_control. A mod keeps up to 128 named controls built for embedding; the least recently drawn makes way for another.

Panels lay out in panel units, which differ from the HUD’s GUI units with the GUI scale and a reference_size fitted to a small window. panel.read-layout, with controls, reports the open panel root’s size in panel units, the HUD GUI units one panel unit spans and the root’s offset from the HUD root, so a pointer p from input.read-controls lies at (p - offset) / scale in panel units.

budget.remaining-fuel returns the fuel left in the running callback, roughly the instructions it may still execute. It needs no grant and exposes no game state; use it to spread large work, such as encoding a document, over later callbacks.

hud.read-viewport reports the HUD root in GUI units and the GUI scale. hud.measure-text returns a line’s width at text scale 1 in the native or sans face, as a label lays it out. hud.set-hidden-elements hides the crosshair, status effects, sidebar or hunger row; hud.read-elements reports whether vanilla draws each this frame, ignoring hiding, and its bounds in GUI units when laid out.

Action filtering

A player mod opts into filter-actions(action-frame) -> action-verdict with mod_api::filtered_player_mod and the controls grant; ordinary PlayerMod exports claim nothing. Only the earliest active, granted exporter runs, after ordinary frames and before gameplay, with its own fuel and no host imports. It sees resolved Attack/Use transitions, physical and logical holds, a continuity epoch and monotonic microseconds, and returns one accept/suppress decision per transition. Suppressing a release retains the hold; suppressing a fresh press blocks that gesture until physical release. Traps, invalid verdicts, owner changes, absorbing UI, suspension and session changes clear continuity and change the epoch. The host owns projection and lifecycle only; the module owns every filtering rule. Pulses from gameplay.pulse-attack and item-use.pulse-use reach the filter as repeats on a physically held control.

Touch buttons

With the controls grant, touch.set-buttons retains up to four labelled buttons, drawn in the built-in touch style while touch controls are shown. Each has a default anchor, inward offset and size; players move it in the touch layout editor, and the placement is saved under the button id, so prefix ids with the mod’s name. Across mods the earliest use of an id wins and at most sixteen buttons are shown. A tap completes when the finger lifts inside the button without interrupting other fingers, and touch.read-presses returns it during the owner’s next frame. A mod can then open its panel with panel.set-open(true) or take any other action it is granted.

Packages and screens

A package directory contains mod.toml, mod.wasm, ui/ templates and textures/. Use screen-probe as the complete example. The manifest indexes declared files with SHA-256 and names permissions, templates, actions and keys. IDs and templates must belong to its namespace. The build script uses experience_sdk::declarations::generate_mod, then the guest includes generated constants with experience_sdk::include_declarations!().

The manifest request and loader grant must both allow each package capability. Inventory requests also need the explicit inventory development switch. Reload narrows against the original authorization, so editing a manifest cannot add grants. The settings companion is <package>.settings.json, outside the indexed package.

An overlay draws beside the active container, clipped away from its panels and excluded areas. A view hides the container’s presentation without closing its server container. Escape returns to the container; closing the container closes both. screen_changed reports layout in GUI units, and view_closed reports a host-closed view. Declared actions, keys, text, wheel events and data revisions arrive through PlayerMod callbacks. UI values and collections use the same tagged bindings as client parts.

Single-file resources

Only binary components support embedded resources. Put each in one top-level custom section, not inside a nested core module. Resource budgets are separate from executable bytes; directory packages retain their own lifecycle.

SectionPayload/grant
cinnabar:extension/panel-bundle@1Panel JSON; controls
cinnabar:extension/font@1Outline font; controls
cinnabar:extension/menu-logo@1PNG; environment
cinnabar:extension/menu-title@1Single-line UTF-8 heading; environment
cinnabar:extension/notices@1UTF-8 copyright/license text
cinnabar:extension/icon@1PNG Mods-tab icon

SDK constants define byte/image limits. Duplicate, nested, malformed or oversized presentation resources are rejected; invalid icon/metadata affects only the card. CINNABAR_MOD_FONT and CINNABAR_MOD_UI sidecars override embedded font/panel data. Mod cards read standard component name, description, version, authors and licenses metadata.

Optional GitHub update checks

A component can append top-level UTF-8 cinnabar:extension/update-repository@1 containing its public GitHub repository URL for release notifications. For automatic installation, use cinnabar:extension/update-url@1 containing an exact latest-release WASM asset URL of the form https://github.com/owner/repo/releases/latest/download/mod.wasm. A download URL takes precedence over a repository-only declaration.

Updates require standard SemVer version metadata and the same declaration in the replacement. Drafts, prereleases and older/equal versions are skipped. Downloads require GitHub’s SHA-256 digest, compatible components and matching versions. Only unchanged files in the managed mods directory are replaced automatically; external files can receive notifications. Companion settings and grants survive. The Mods tab’s Update action works when automatic installation is disabled. Settings → Updates controls mod checks and automatic installation separately. Mods without either declaration make no update requests.

Trusted packet mods

CINNABAR_PACKET_MOD selects a packet mod on its own; the packets grant exposes session packets and pack insertion through the private core relay. A packet mod cannot share a registration with gameplay/package mod authority. Grant changes revoke current authority; reload selects the next session’s source. Optional CINNABAR_PACKET_MOD_WASI=1 adds clocks, randomness and captured output, never files or network. See packet WIT for callbacks.

Native-frame guests must be rebuilt with the current SDK, including guests using only the legacy frame callback. The version-one JSON payload now includes look, movement and elapsed-time fields; older strict decoders reject these added fields even though the version number is unchanged.