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

Trait mods, Parts and attachments

Trait mods (draft)

cargo horizon-mod new <path> --template native pins an exact Horizon commit (the newest prebuilt game's, or --horizon <commit>'s; cargo horizon-mod update repins) and writes a flat project: horizon-mod.toml with [native], Cargo.toml, src/lib.rs, and empty assets/ and data/ directories. The template greets each player who joins. Build it with horizon-launcher native-build <path> --trust-native-code; cargo horizon-mod build packages the same project into a .hmod without compiling it (the manifest).

The root Rust package depends on horizon-mod-api (lib mod_api), never on the game. Its root src/lib.rs marks one impl Hooks with the attribute:

use mod_api::{self, Hooks};

#[derive(Default)]
struct Mod;

#[mod_api::horizon_mod]
impl Hooks for Mod {
    // Implement the hooks this mod needs.
}

The attribute generates the hooks() factory and derives optional hook subscriptions from this impl. The mod type implements Default; do not write a second factory. Keep the marked impl at the crate root. Rustc's "cannot find function hooks" at the launcher's generated call means the marked implementation is not at the crate root. The launcher links this package into a generated game executable and calls the generated factory in dependency order (a mod after the mods it depends on, else in bundle order). A mod with no raw injections skips horizon-mixins; typed hooks need no injection declarations. Dependencies follow the pinned Horizon Cargo.lock rules in The mod's crate.

Dependencies between trait mods

A mod can publish functions and statics from its root crate for another mod to call. Declare dependencies in horizon-mod.toml, not Cargo.toml:

[dependencies]
base_mod = "^1"
extra_mod = { version = "^2", optional = true }

Requirements use Cargo semver syntax and are checked against each installed mod's horizon-mod.toml version, not its Cargo package version. Content-only mods use these declarations to validate their requirements and load their layers after dependencies. Code dependencies additionally require API packages for the dependent and every installed dependency. Missing required dependencies, incompatible versions and cycles stop the build with the mods named. An absent optional dependency is permitted; an installed optional dependency must match its requirement.

The launcher adds a path dependency using the mod ID as the Cargo alias and the target's actual Cargo package name. Optional dependencies also get a Cargo feature named exactly as their mod ID, enabled only when that mod is installed. Guard calls with if_installed!, which builds the block only with that feature and runs it only when the mod is installed, so the same source works when built to mod.wasm, where the library is linked at load and an absent one's functions trap:

mod_api::if_installed!(host, extra_mod, {
    extra_mod::register_extra(host);
});

Do not define these dependencies or features yourself in Cargo.toml. Public statics must use appropriate synchronization for shared and reentrant hooks; private state in another mod's hook object is not exposed.

Trait dispatch follows dependency order, with ties kept in bundle order. Mixin injection order remains horizon-mixins' priority, mod ID, then injection ID for source splices. Function-slot listeners instead use descending priority, dependency/registration order, then injection ID within a mod; call-site listeners retain mod-ID tie ordering.

Load phases and access

Native and wasm hooks share the installed dependency-ordered mod slice, including registration, config loading and reload-listener registration. The shared resolver keeps bundle-order ties, checks present optional dependencies too, and refuses cycles for both build targets. Content-only packages stay after code in the loaded-mod inventory; listener-specific edges still use ReloadOrder.

Each owning thread progresses through Packages → Registration → Setup → Config → World.

PhaseAvailable access
PackagesPackage discovery; no game Host operations.
RegistrationDeclarations through RegistrationContext, with its mod-list, registry and settings reads; no Host.
SetupClient setup reads, published asset/data queries and registry/settings/mod-list reads; no world handles or transactions.
ConfigSettings reads and the existing mod-scoped ConfigFiles service; no world handles, transactions or other game operations.
ReloadingPublished asset/data queries, registry facts, settings and mod-list reads, listener registration, and existing DataLoadContext callbacks; no world handles or transactions.
WorldThe owning game's side-typed ServerHost or ClientHost.

RegistrationContext::instance_dir() reads the instance directory captured before registration begins, as an InstanceDirectory: Unix(Vec<u8>) preserves raw Unix path bytes, and Windows(Vec<u16>) preserves Windows UTF-16 code units, including unpaired surrogates. Never convert Unix bytes with a lossy UTF-8 conversion when constructing a native filesystem path. Wasm receives the identical host-encoded value through Wire as data only; this does not grant filesystem access. A context with no engine snapshot returns RegistrationReadError::Unavailable.

Registration runs once: register, component_types, blocks, items and standalone_models receive only RegistrationContext. Its mods(), registry(name) and setting(mod_id, key) read one view captured before the first of them runs, so every registration step sees the same values whatever the dispatch order: the loaded packages, the built-in registries as registries.json lists them (not what mods declare during registration) and the settings on disk at that moment. A mod validates the keys it declares against them from Hooks::register; a name that is not a namespaced identifier, an unknown registry, an empty or path-like setting key, a package that is not loaded, or a context the engine gave no data returns a RegistrationReadError. Client setup runs once after the initial asset apply. The window's Client/Common configs load once immediately after client setup and before title, connection, input, render or ordinary tick callbacks. This includes pack-error recovery: the failed selection is cleared and saved during initialization, but its options screen opens only after Config completes. A title-screen callback is already in World: world-handle constructors can return None, rather than being forbidden in Setup. A dedicated server loads Common once before its first world; Server configs load and unload per world. A second world does not repeat registration or client setup. Config loading can read settings and use the existing scoped ConfigFiles service to read or save its own config files. World handles, transactions and other game operations panic with Config in the diagnostic. Config file polling is not a phase transition, and Server config unloading leaves the World phase.

Every data or asset reload enters Reloading, including listener registration, prepare, apply and the reload completion notice. Initial loads can nest in Setup; runtime reloads nest in World. Returning, including after a failed prepare, restores the entering phase. Published asset/data queries are classified in the catalog (resource_read), not granted by helper-name exceptions. They read the currently published generation, not pending prepare output: completion callbacks can refresh sprite and recipe caches after apply, including on first world open, without gaining world handles or transactions. Their rustdoc names their load-phase availability. Server listener registration retains &dyn Host, restricted to these published facts, registry facts, settings and mod-list queries; callbacks already taking DataLoadContext keep that context. Neither permits opening a world handle or transaction. The first client registration and prepare run on the loader thread; later registration runs on the render thread, prepare on its worker, and apply on the owning game thread. Startup's empty client Host is not an extra permission.

DataLoadOrigin and LoopSide are API-owned enums, not engine exports. Load and loop callbacks read frozen registry and settings metadata through DataLoadContext and LoopContext. The engine supplies borrowed LoadReader and LoopReader implementations; the API's owning views contain no engine types and do not copy or rescan the resource snapshot. Mod listeners keep using the context traits, with the same origin, side and registry ordering on both targets. The reader adapters themselves are native host implementation details, not wasm resources.

The game alone advances phases. An illegal or wrong-thread advance is refused before dispatch; a forbidden mod call panics naming the phase, rather than returning a Result a listener could ignore. There is no separate common-setup, load-complete or intermod event: registration and the first server reload cover common setup, client setup is client_setup, dedicated setup is server_about_to_start, and dependencies express intermod ordering. Load complete is the edge after initial Config and before server_about_to_start, not a hook.

Settings snapshots accept only regular UTF-8 files, without following file or package-directory symlinks (including internal links). Invalid UTF-8, special files and refused entries are absent, not errors. Each file is limited to 64 KiB before trimming, each mod to 1,024 captured keys, and the whole snapshot to 16 MiB before trimming. Over-limit keys are omitted in sorted package-id and key-name order. Enumeration is limited to 4,096 names per package; packages exceeding that budget are omitted entirely rather than retaining an unstable enumeration prefix. One warning per refusal class is emitted per snapshot. These generous limits cover the small scalar setting values used by the settings API; they do not apply to mod data files.

The generated executable uses the installed game's crates/horizon-client/src/native_main.rs.template. The launcher supplies only ordered literal mod IDs and hook factory paths. The template owns entry construction, the allocator and the canonical game identity. Source without this template is refused before staging trait mods: update the game first.

A mod whose bootstrap cannot complete should call cx.refuse("reason") inside Hooks::register, then return. This refuses only the calling mod's registration: there is no mod ID argument. Its declarations from that registration (before or after the call) are discarded. Later declarations do not add secondary validation errors, and another mod's declarations are unchanged. Native and wasm report the same diagnostic through the normal registration error path. This does not panic or exit the process. The first reason wins, including an empty reason. Reasons are preserved verbatim up to 4096 UTF-8 bytes; longer strings are truncated at the last character boundary within that limit.

A panic in a trait mod's hook adds in native mod <id>, hook <hook> to the panic's message, in the crash report and in the server thread's log line, with the horizon-mod.toml id. Each entry passed to entry::main_with must bind its id and hooks together: ModEntry::new("example", example::hooks()). The ordered list is Mods = Arc<[ModEntry]>; there is no separate naming step or unnamed entry. Before installation, mod_api::mods() still returns an empty list. Parts retain their registering mod's id for startup errors and hook panics. The game stops. A point listener's panic names its mod and method; render hooks are not named yet.

A native runtime that hosts converted packages (Portizon's wasm packages, a Java interpreter's jars) declares the packages it loaded with mod_api::content::crash::ConvertedPackages::new(runtime_id, ids) and marks a guest call with mod_api::content::crash::ConvertedPackage::enter(export, site). The scope nests, restores the previous one on return and while a panic unwinds, and adds in converted package <id>, export <export>, call site <site> below the runtime's in native mod line in the report. A package id the runtime did not declare is refused, as is a scope entered while another mod's call is the current one, so guest code cannot impersonate a mod the runtime did not load.

The guard is thread-affine (not Send or Sync), and attribution ends when the native frame that entered it exits, even if the converted guard is retained. A nested native frame hides the outer frame's package until it returns. Worker threads have no scope unless the runtime enters a native call and package scope there. Registry ids are stored, not interned: entering a package clones an owned id on every call (borrowed static ids do not allocate).

A code mod may listen at the game's injection points as a rare escape hatch, through a crate-root function named by [[inject]].with, taking a mod_points record; the form and its limits are in Injections.

A linked trait mod implements mod_api::Hooks. A hook function is not an injection point, so [[inject]] never runs inside a hook's dispatch; a head injection that cancels the game function calling a hook keeps the hook from firing at all. A claimed cancellation reaches only trait listeners that receive cancelled events. Otherwise, trait mods run by priority, then in dependency order. Once a mod cancels, later mods are skipped unless they receive cancelled events. For replacement hooks, the first Some wins. Collection hooks call every mod. Receiverless hooks have no Host.

To save from inside a hook, use level.request_save(host) so the save runs at the next settled start-of-tick boundary. A synchronous save-all while an entity pass or held-out row is borrowed fails with vanilla's commands.save.failed feedback and a failed command result, without saving.

To emit a vibration-producing game event, use level.game_event(host, event, at, context). GameEventContext carries an optional source entity and affected block state; see game events with context for listener semantics and refusals.

#[listener] on a hook method sets both, as NeoForge's EventPriority and receiveCanceled do. Two mods, each its own crate:

#![allow(unused)]
fn main() {
#[mod_api::horizon_mod]
impl Hooks for Protection {
    // Runs before mods at the default priority, 0; higher runs first.
    #[listener(priority = 100)]
    fn break_block(&self, host: &mut dyn ServerHost, event: &mut BreakBlockEvent<'_>) -> Flow {
        if self.protected(host, event.pos) { Flow::Cancel } else { Flow::Continue }
    }
}

#[mod_api::horizon_mod]
impl Hooks for Override {
    // Still told after a cancel, and may undo it.
    #[listener(priority = -100, receive_cancelled)]
    fn break_block(&self, host: &mut dyn ServerHost, event: &mut BreakBlockEvent<'_>) -> Flow {
        if event.cancelled && self.allowed(host, event.pos) {
            event.cancelled = false;
        }
        Flow::Continue
    }
}
}

priority is an i32, 0 when omitted; mods of equal priority keep dependency order, as [[inject]] priorities do (Injections). Each hook's order is fixed once, when the mods are installed. Every cancellable hook's event has a cancelled field: a mod without receive_cancelled always sees false, since it is not called once the event is cancelled. A mod that receives cancelled events and sets it back to false lets the game's behaviour run, and the mods after it are told again. An event the game hands over already cancelled reaches only mods that receive cancelled events. receive_cancelled on a hook without a cancel does not compile, and #[listener] is for a trait mod's hooks only: a part is called for its own thing, in no order.

Native and wasm trait mods use the same listener ordering and cancellation rules. Wasm subscription records carry the hook name, signed priority, and receive-cancelled flag through the shared-memory Wire boundary at load time. The event's cancelled field crosses with its other writable fields. No component-model adapter is involved.

Each hook method takes its host, where it has one, and one event:

#![allow(unused)]
fn main() {
fn living_incoming_damage(
    &self,
    _level: &mut dyn LevelRandomHost,
    event: &mut LivingIncomingDamageEvent<'_>,
) -> Flow {
    if event.kind == "minecraft:pig" {
        let halved = event.container.new_damage() * 0.5;
        event.container.set_new_damage(halved);
    }
    Flow::Continue
}
}

The event is a generated struct named after the catalog hook, in CamelCase plus Event, with the hook point's arguments as public fields: values as values and borrowed ones as references (event.kind). A number or bool a mod may change is a plain field, written back once every mod has run, even after a Cancel; anything else a mod may change, a game struct or a list, is a &mut field. A cancellation or replacement is still the return value; a collection hook's list is a field (event.lines). One event goes through every mod, so each sees the changes before it. Because no mod can build or match the whole struct, a later version can add a field without breaking any mod, as NeoForge's event objects do; the cost is that a mod's own tests drive a hook through its dispatch function (Testing a trait mod).

Hook methods take &self and require Send + Sync. The process-wide mod list is installed once, like mixin module statics. One mod instance serves both the window's game and the server-thread game. A hook can also fire again inside the same mod: for example, LevelRef::set_block fires block_changed. Mods keep mutable state with interior mutability so these calls can share the instance safely. Data-load hooks can run on any loading thread; item default changes must be identical on every side.

The trait has every hook declared under crates/horizon-mod-vocab/src/hooks/ (Adding a hook). Level hooks use access-specific hosts rather than Host. LevelHost provides block_state through LevelAccess::block_state, client-side detection and state_with_property. state_string(state) serializes the full name and properties, or returns None for an unknown state; min_y() and height() read the level's build range. It also reads light and loaded state, through the game's own queries: brightness(LightLayer::Block | Sky, pos) (getBrightness, the stored 0 to 15), raw_brightness(pos, sky_darken) (getRawBrightness, which CropBlock.randomTick asks with 0) and is_loaded(pos) (Level.isLoaded: inside the build height and in a loaded chunk), is_raining_at(pos) (Level.isRainingAt, false where pos is not loaded) and enabled_features() (LevelReader.enabledFeatures, a borrowed FeatureFlags<'_>). Neither allocates. None of them loads a chunk. Light above the build height reads as full sky and below it as none, and a chunk that isn't loaded reads as the game's query does (full sky, no block light), so check is_loaded first. It also answers tag and fluid questions without a Host: block_in_tag(block_key, tag), fluid_state(pos) (getFluidState, None where the block read is), state_fluid_state(state) and fluid_in_tag(fluid_name, tag). Fluid reads return FluidState with a FluidKey, amount, falling flag and engine-derived source flag; is_empty() and is_source() report those states without a registry lookup. block_state_id(name) parses a block and properties, and block_state_default(block) gives a block's default state, as on Host; both return None for invalid input or unavailable context. Tag reads are None when authoritative tags are unavailable, including on a client before its server's tags are applied. For item membership and member lists, use Host::tags(): resolve a TagKey with Tags::key, then call Tags::is_in_tag, Tags::items or Tags::fluids. Membership reads return None on clients until server-sent tags are applied, which tags_updated announces; they never substitute local resource-pack tags. LevelHost has no random, by design: a draw from a read-only hook inside a level write would shift the level's random sequence away from vanilla's order. A hook that may draw is given a LevelRandomHost. LevelRandomHost provides vanilla's Level.random so mod draws come in vanilla's order. LevelTicksHost adds scheduling through native_schedule_block_tick and native_schedule_fluid_tick, while LevelWriteHost adds random access, set_block, set_block_property, native_play_sound and native_send_particles and native_level_event (levelEvent, queued in order with the write's own events). set_block(pos, state, flags) is Level.setBlock on a whole state, as LevelRef::set_block takes it: it removes the old block's block entity and creates the new block's in the write (LevelChunk.setBlockState), runs the neighbour and shape updates flags ask for, and queues the write's level events in order. A mod block entity it creates starts with the data the block_entity hook gives, which is fired in the write and receives the level (LevelSide, read-only: vanilla's newBlockEntity gets no level), as the Host's native_set_block fires it. It returns true when the block changed, and false off an authoritative server, for a position that is not loaded or outside the build height, an invalid state, or the state the block already has. It never loads a chunk. The game owns the tick queue; mods do not pass it back to these helpers. Frame drawing through DrawContext, BlockEntityDrawContext and WorldDrawContext stays in mod_api. Raw GPU access is native-only, unstable and outside that API, in mod_render::render (horizon-mod-render). Register its listed frame functions through [[inject]] with access = "internals" and kind = "head"; the ordinary listener registry owns ordering and disabling. render_needs collects requests; world_drawn runs before the hand and HUD, and hand_drawn runs after the hand, on the render thread.

Removed path or registrationCurrent route
mod_api::render raw GPU typesmod_render::render (native escape hatch)
RenderHooks / Hooks::render[[inject]] internals listeners on the three listed frame functions
API draw contextsUnchanged; use these for API-typed drawing

The typed API from mixin code

An [[inject]] listener can use the typed API: an API point's listener gets the side's Host and calls attachments and handles through it. The worked examples, and which shape to choose, are in Injections; each mixin injector's form is in From mixins and legacy kinds.

A catalog hook's trait method, event and return make up its hook_version contract (Named hook points). These per-hook contract versions are catalog metadata, not minimum game versions. Catalog hooks, Host calls and internals [[inject]] declarations all build against the game's exact source revision. Rebuild the mod when that contract changes.

Every impl Hooks must have #[mod_api::horizon_mod]. The attribute derives subscriptions from the methods implemented in that block, so dispatch knows them at installation.

Parts

A part is code a trait mod attaches to one block, block entity, item or mod menu type, its own or vanilla's (the spec). A mod attaches its parts once, at startup, in Hooks::register. Each part trait implementation must have #[mod_api::horizon_part]; the attribute subscribes it only to the methods it implements. A BlockEntityPart also needs a separately annotated BlockPart implementation:

#![allow(unused)]
fn main() {
struct Gauge;
#[mod_api::horizon_part]
impl BlockPart for Gauge {
    fn has_analog_output(&self, _: &dyn LevelHost, event: &mut HasAnalogOutputEvent) {
        event.output = true;
    }
    fn analog_output(&self, _: &dyn LevelHost, event: &mut AnalogOutputEvent) {
        event.output = 15;
    }
}

#[mod_api::horizon_mod]
impl Hooks for MyMod {
    fn register(&self, r: &mut dyn Registrar, _: &RegistrationContext) {
        let stone = r.block_key("minecraft:stone");
        r.block_part(stone, Gauge);
    }
}
}

On r: &mut dyn Registrar, r.block_key(id) and r.effect_key(id) request registry entries, not new content. Registrar is the generated union of the kind-local registration traits (such as BlocksRegistrar and EffectsRegistrar); mod registration hooks and the horizon-machine-sample continue to use dyn Registrar. Each kind owns its declarations and implementation in parts/<kind>.rs; the union and WebAssembly bindings discover those traits without a shared method list. The capability-provider lookup union is assembled the same way. Declare them in register, retain the returned BlockKey or EffectKey in the mod or Part, and reuse them for registry operations. Requests are canonicalized (a bare id uses minecraft:), deduplicated across mods and checked after every mod's content is known. An unknown entry refuses the load with the requesting mod and canonical id, even if the key was never attached to a Part. A later mod may supply the requested entry.

The completed block identity table includes every vanilla and mod block, even blocks no mod requested. Completion preserves requested slots and appends the remaining canonical names in sorted order. Names belong to the installed mod registry; world load and reload prepare a separate numeric mapping for every retained key before publishing it. A missing requested block refuses completion without publishing a partial table. Immutable block definitions (property names, legal values, state combinations and defaults) are published with those names before mesh tables are constructed. Workers share read-only definitions; property reads allocate nothing. Pack reload does not mutate definitions or identities. The world's numeric mappings and tag membership remain separate reloadable data.

Metadata contexts are read-only and borrow the same table on the host and on workers. Wire transport records are internal, not saved block values. Persist canonical identifiers and property strings rather than slots or state ordinals.

BlockState is a small owned value with private fields, not a tint-specific snapshot or an engine numeric ID. state.block() reads its BlockKey; state.ordinal() reads its local ordinal. Property operations take explicit metadata: retain let metadata = host.block_states() and call state.property(&metadata, "facing") or state.with(&metadata, "facing", "east"). BlockState::of(&metadata, key, constraints) starts from the published default and applies the supplied StateConstraints. Changes return a new value and never mutate the original. Unknown keys, ordinals and property combinations return typed BlockStateErrors.

SceneBlock::new(pos, state, entity) and StructureTemplateBlock::new(pos, state, nbt) use this owned BlockState, including all properties. Structure reads preserve vanilla palette selection, mirror-then-rotation and placement order; an unavailable state mapping refuses the whole snapshot with None. DrawContext::draw_scene checks the entire scene before drawing: an unknown key, invalid ordinal or unavailable mapping draws nothing, never a partial scene or a replacement air block. Native and wasm use the same values and refusal rules.

Tint callbacks receive the same immutable metadata resource explicitly, without a Host. The game prepares their owned state array before constructing mesh tables; renderer workers only copy those values and invoke metadata reads. When world numeric state IDs are reordered, the game resolves each state's property combination against the published definition, so local ordinals retain their meaning across reloads.

host.block_states().states_of(key) returns a BlockStateRange in that same immutable local ordinal order. Its iterator yields owned BlockStates; it is not a span of world numeric IDs. BlockStateRange::new(first, count) returns a typed error if the span would exceed the u16 ordinal domain, and decoded transport spans obey the same check. Membership checks both the block key and the ordinal. A zero-count span is empty.

A restricted LevelHost::block_state(pos) read returns Option<BlockState>. A successful engine read retains its vanilla result: unloaded or out-of-height reads that produce air return Some(air), using the owning registry's real air state. An unavailable owning state index or a refused engine-to-API conversion returns None; the boundary never fabricates a key or air state and never panics to satisfy a mandatory value. Other optional/result read contracts are unchanged; in particular, this does not change LevelRef's loaded-position policy.

LevelRef::set_block, update_neighbors and neighbor_changed take owned block states and return Result<bool, LevelWriteError>. Stale identifies a closed or reopened level; Unavailable identifies the wrong side, phase or nonactive write target; InvalidState retains the block-state refusal. Errors do not mutate the level. Ok(false) is still vanilla refusal, such as an unloaded or out-of-height position or an unchanged block, not a lost handle or a guessed replacement state. Update flags retain their vanilla effect.

Walking a whole registry uses the typed keys, never names: host.block_keys() and host.item_keys() return every entry in the registry's registration order (vanilla's entries first, then mods' in the order they registered); host.entity_type_keys(), host.menu_keys() do the same for the built-in entity-type and menu registries. host.enchantment_keys() uses global sorted loaded-registry order, with vanilla and mod data-pack entries sorted together. A block registry lists a name once per state, and its iteration lists the block once. Build lookup tables from these lists at load time instead of resolving names per tick. A table is empty before its registry loads (blocks and items during rest load, enchantments with the item tables); registration-time RegistrationContext does not see any of them (#1001).

Keys are small, private u32 registration slots on both native and wasm. key.id(host) returns an owned canonical String through the generated read backend for diagnostics; it requires a game-backed host and an invalid slot is a boundary error, not an empty name. Block defaults come from immutable API metadata and cross through the game's sole forward/reverse state index. Behavior queries obtain their engine block from the resulting engine state; there is no separate per-key numeric block binding. Reload preserves key identity and canonical names. A refused resource rebind clears the active index, while failed transactional Parts preparation leaves the previously published Parts and index intact. Effect registration is process-owned and fixed until restart, matching the engine's effect registry.

A name that only arrives at run time (data, config, a command, a network message) turns into a key with the host: host.block_key("minecraft:stone"), host.effect_key("mymod:rage") and host.fluid_key("minecraft:water") return Option, and key.id(host) gives the name back for blocks, effects, fluids and items. Names are full registry names; the lookup reads the completed registry and does not allocate, so a hook can call it. It returns None for a name the registry does not hold, and before the registry is complete, so a mod uses r.block_key(id) in register. These work on the client and the server, for native and wasm mods alike.

Block tag membership is bound from the same merged item/block tag snapshot at world load and reload, including worlds without pack overrides. A read checks that the owning state index is available, looks up the tag name and tests membership in a prepared API BlockKey set; it never resolves the block's name. A fully namespaced tag needs no allocation (a bare tag adds minecraft:). Binding visits each merged block-tag member once and resolves its API key; it replaces the previous snapshot rather than accumulating membership. No membership work is added to ticks that do not call this read. Tags and block mappings belong to their resolving thread; missing authoritative context or an unavailable mapping returns None, not a stale membership result.

host.tags().blocks(tag) reads the current authoritative block members in vanilla order as BlockKey values, including blocks no mod requested. Resolve tag once with Tags::key and retain it across reloads. Unknown or empty tags return Some(Vec::new()); an unavailable context or any unresolved member returns None, never a partial member list. The same service is available through restricted level Hosts, without borrowing an engine registry.

host.block_state_default(stone), host.block_states().states_of(stone), host.block_item(stone), host.block_in_tag(stone, tag) and restricted-level schedule_block_tick(pos, stone, delay) use BlockKey. Property expressions accepted by block_state_id, tag names and existing fluid operation arguments remain strings for now.

Declare a tank-only fluid in your own namespace with r.declare_fluid("chocolate"); the returned FluidKey identifies it for the mod load. Request vanilla or another mod's declared fluid with r.fluid_key("minecraft:water") or r.fluid_key("other:chocolate"). Requests resolve after all registrations, so declaration order does not matter; an unknown request stops the load and names the requesting mod. Tank-only fluids have no world simulation or world state. Fluid stacks and recipe ingredients use these keys; remaining fluid operations move to them in subsequent changes. Declare a fluid that exists in the world with let mut fluid = WorldFluid::default(); fluid.drop_off = 2; r.declare_world_fluid("oil", fluid);, using mod_api::WorldFluid, the code form of a fluid file's world object (content mods): it adds the block mymod:oil, the flowing fluid mymod:flowing_oil and the bucket mymod:oil_bucket, and its fluid file, if it has one, still says how it looks. r.fluid_part("mymod:oil", Part) attaches a FluidPart, whose fluid_rules can change the declared values by position on the server. It is asked for every cell a spread looks at, so answer fast.

A saved tank whose fluid's mod is absent carries an opaque unresolved identity. Its stack's key() is None; UnresolvedFluid::id(host) reads its saved name. It can move between name-backed mod tanks, but not into vanilla buckets or cauldrons.

ActiveEffect::id and effect hook payloads carry EffectKey; living effect queries, application and removal use the same resolved key. ActiveEffect::new(speed, …) uses a key requested through r.effect_key("minecraft:speed"). Beacon-selection hooks carry Option<EffectKey> for each power; None is no selection. Enchantment effect-component names (for example minecraft:damage in has_enchantment_effect) are a different registry and remain strings, as do labels and tags.

Hot item and block identities

Resolve item and block keys during load and keep them for tick and menu calls. item_burn_time, item_compost_chance, give, enchantment_supports's item, and GUI item / item_model take ItemKey. block_item, fluid_bucket and screen_hovered_item return item keys. level.block, lit_block_name and Hit.block return block keys; BlockEntityDrawContext::block takes one. HeldItemSlot.id (including use-start/stop events) and Stack.item carry item keys, not names. Unknown incoming slots are refused at the boundary. block_sprites takes API-owned BlockState values, preserving properties without parsing state strings or exposing numeric engine state IDs. The pre-change block event carries resolved from / to states and does not allocate their names. Property names and values in set_block_property are not block identifiers and remain strings.

These calls remove string transport across wasm. Engine metadata reads may still hash borrowed registry names; no formatted identifier is built for these inputs.

Matched recipe lookup

level.recipe_for(host, &recipe_type, &inputs) replaces the crafting-only lookup. Use RecipeInput::Crafting { width, height, slots } for minecraft:crafting (row-major slots, dimensions 1 through 3), Single(stack) for smelting, blasting, smoking, campfire cooking and stonecutting, or Smithing { template, base, addition } for minecraft:smithing. Recipe types are not serializers: shaped and shapeless files both belong to minecraft:crafting, and transform/trim both belong to smithing.

The server uses the same engine matchers and assembled outputs as its menus, including data-pack overrides of vanilla recipe types. A RecipeMatch contains the matched recipe identifier, typed output, ordered remaining items, experience and processing time. Lookup consumes nothing; the caller applies inventory changes transactionally. Keep the result until inputs or the recipe load change.

Ok(None) means valid input without a match. Invalid shapes/items and unavailable levels are typed errors. A type without an engine matcher returns RecipeLookupError::NotMatchable, not an empty match. For mod-owned types, retain recipes_of_type once per load/reload and match that cached data inside the mod, with zero crossings per tick. Dynamic custom outputs and contextual predicates remain mod-owned; the host does not call guest code to match recipes.

At publication, call ingredient.item_keys(host) once per ingredient to retain Arc<[ItemKey]> for local matching. This load-time read returns exactly the plain item membership for lists, expanded tags and standard shapes, using the engine matcher. It returns IngredientKeysError::NotEnumerable if any custom or component predicate occurs anywhere in the tree; a displayed item list is not a safe substitute. UnknownItem refuses a missing registry identity. Do not call this method per tick. The sample's ProcessingRecipes::publish retains these keys; result then checks input.item() locally, with no Ingredient::test host call per candidate.

Stack placement predicates and drawing consumers (HUD items, GUI item decorations, block-entity items and tooltip images) encode API stacks without enforcing resident count or damage maxima. Engine-legal over-limit stacks can still be inspected and drawn. Unknown item keys and invalid/unrepresentable effect payloads still refuse encoding; this is not a total conversion. Insertion and other world writes retain strict validation.

API-owned values: migration table

Previous engine valueAPI-owned valueMod changes
Engine stack in TooltipDisplay::for_stackmod_api::ItemStackPass the owned API stack supplied to the tooltip event. Its effective tooltip component includes vanilla's implicit defaults. Normal game rendering borrows the engine component without creating an API stack.
Engine loot context and candidate stackmod_api::{LootContext, LootStack}Contexts own their facts; candidate items are ItemKeys. Counts remain signed. Ordered Option<Tag> patches preserve removals and NBT types, and names are API Text trees. Writeback is atomic.
Engine ParticleType and optionsParticleKey and API ParticleOptionsRetain a key requested through Registrar::particle_key. Every parameter-family constructor takes its key; simple(key) also replaces string-based custom provider construction. The engine enum is no longer exported.
Engine damage containersmod_api::{DamageContainer, DamageValues, Reduction}Hooks receive an owned scalar snapshot. Read reductions with reduction; edit damage and cooldown through the container setters. Sequencing methods remain game-only.
Engine item property valuesmod_api::{ItemBar, ItemRarity}Resource methods retain their signatures; damageability, glint and repair checks use game-owned metadata reads.
Package manifest Sidemod_api::SideMatch Client and Both on the package metadata snapshot. This is not HostSide or the executing logical side.
Command Suggestionmod_api::SuggestionConstruct with Suggestion::new((start, end), text); optional tooltip and int_value remain writable. Offsets are byte offsets.
Package block and item declarationsmod_api::{Block, Item}Use API-owned declaration records in the registration events. Component patches are ordered (String, Option<Tag>) entries, with None removing the component; the game converts once at load and applies content-file admission checks.
Package block entity type storagemod_api::BlockEntityTypeDeclExisting r.block_entity_type(...).build() registration is unchanged. The declaration snapshot no longer exposes the package parser's model; the game converts it once when declarations freeze.
Component protocol vocabularytext::{TextContent, TextNbtSource, TextObject, TextClick, TextHover}Match these enums exhaustively: their serialized vocabulary is closed. Records remain non_exhaustive.
Renderer GUI commandsmod_api::GuiDrawDrawContext::gui_paint returns a typed text-conversion result; handle refusal before drawing. HUD hooks reject invalid writeback as a whole.
File-opening click actionsNot in text::TextClickMods can neither produce nor round-trip OpenFile; untrusted file-opening action metadata is dropped. Core tracks local/remote origin per node, including mixed trees; exporting JSON or TextForm and decoding it makes every node remote. Adding API OpenFile remains a separate vocabulary/link-contract change.

mod_api::GameRuleValue owns the Bool(bool) and Int(i32) cases, in that order. level.game_rule(host, id) returns Result<Option<GameRuleValue>, LevelReadError>: Ok(None) means unknown rule, Stale means a gone/reopened level, and Unavailable means the side or phase cannot read it. level.game_rules(host) returns the typed result containing every available rule, not an optional list. The existing boolean key getter is unchanged. The game converts values after the subscription guard for game_rule_changed; no subscriber means no conversion work.

The API's GameRuleDecl records a mod's boolean rule name, category and default. Its internal RuleSchema input describes vanilla categories and dense rule positions through read-only lookups rather than a copied vanilla list or fixed slot numbers. Schema validation resolves category references after all mods have declared them and returns either all declaration slots or every refusal; it never installs a partial table. The game supplies this schema at Parts installation, then builds each world's engine rule registry from the installed GameRuleDecl snapshots. Existing rule registrar calls are unchanged; engine registry construction is no longer exposed through the API.

The game owns package and completion conversion. Mods do not import package-loader or client-core types for these values. Completion edits are copied back after subscribed listeners run; no listener means no snapshot allocation. Package metadata is copied once at startup, retaining declared namespaces and dependencies.

Opaque NBT metadata

The shared JSON/NBT conversion is iterative and follows vanilla's 512 nested list/compound limit; scalars and NBT typed arrays add no recursive container. Conversion to JSON intentionally loses numeric tag widths, boolean-as-byte identity and typed-array identity. NonFinite refuses NaN and infinities: serde_json::Value cannot hold them, and Gson's strict JSON writer also refuses them. InvalidString refuses an unpaired UTF-16 surrogate in a Tag string or compound key. Neither case is replaced with null, a string or U+FFFD. JSON input already uses UTF-8, so valid non-BMP pairs survive; a lone surrogate cannot reach this converter through that input representation.

Owned component trees

The text module defines owned component data: literal and translatable contents, style overrides, ordered children, selector and NBT sources, inline objects, and click and hover actions. Translation keys and argument components stay unresolved until the game displays them. None style fields inherit; Some(false) overrides an inherited decoration. Metadata, dialog/custom click payloads and hover-item component patches are owned Tag data, never JSON strings or engine state. TextProfile carries an optional name and UUID, signed property records and opaque unknown profile/property fields. Signed record order and unsigned values within each property name are preserved. Player names use U+0021..U+007E and at most 16 characters. Vanilla's unsigned property-map form allows at most 16 distinct names; signed or metadata-bearing records use a 16-entry list, with UTF-16 limits of 64, 32767 and 1024 units for property name, value and signature respectively. Inline player and atlas objects use Option<Box<Text>> for their fallback: None keeps the engine-computed default; Some preserves a styled or translatable component rather than flattening it to a string.

The native boundary plans borrowed source topology before cloning payloads. It reads source-less spans directly and validates every Tag's JSON representation before building an engine component. Text::validate() checks tree/payload bounds and profile fields without cloning; validate_depth() is the component-only preflight. NaN/infinity and unpaired UTF-16 remain legal Tag data but cause typed NonFinite or InvalidString refusals when engine emission requires JSON. Nested JSON null is refused as NestedNull when capturing opaque NBT fields; it is never silently replaced. Host methods accepting or returning Text return Result<_, TextFormError>; mixed item/Text writes preserve the refusal in StackWriteError::Text, and unspawned-mob reads use UnspawnedMobError::Text. TooDeep means a component path or opaque NBT container path exceeds 512. Unavailable backends are distinct from invalid text or unrepresentable payloads. A refused hook capture does not dispatch; a refused writeback leaves every field unchanged. No subscriber means no conversion or snapshot allocation. Mutable stack arguments are compared using value equality with the snapshot passed to the listener. An unchanged argument retains the original engine value without strict write validation (including engine-legal stacks above effective count or damage maxima). A changed argument is validated in full; a refusal still commits no outputs. The same rule applies to anvil/grindstone records and menu cursor/quick-move stacks.

Recursive Clone, Eq and Drop of a tree a mod itself built are ordinary Rust ownership, not part of the boundary guarantee.

Text::new(content) constructs any content kind without exposing an engine value. The content, NBT-source, object, click and hover enums are exhaustive protocol enums; new cases change the link contract, just as enum-case order does. Mod matches should name every case rather than silently treating a new protocol case as an old one. Typed content and style fields are authoritative; metadata retains unrecognized properties, not overrides of those typed fields. Game-side encoding is fallible and reuses the engine's translation-argument and fallback construction rules. Capture keeps dynamic contents and metadata rather than flattening the rendered spans. It shares the engine's SNBT flag, shadow-colour, click-policy and inline-object parsers. File-opening metadata is discarded before a listener receives the owned value; a no-op listener cannot promote an untrusted file action into a local action. A translatable's fallback is a literal format template, not another language key. A missing key whose fallback equals an existing key therefore still displays the fallback text itself; parsing and native constructors follow the same rule.

Particle identities

ParticleKey is an opaque slot in the load-owned registry tables. One completed snapshot retains both canonical names and provider identities: a vanilla provider's numeric index or a custom named sprite provider. The load resolves each provider once; reads of a retained key do not parse identifiers or scan the provider list. Unknown forward references refuse publication of the entire particle snapshot. Name-table completion shares the item registry's requested-slot preservation and sorted enumeration.

Placement and loot snapshots

mod_api::PlacementContext owns the placement inputs, including world-space click coordinates and the placer view/flags. Its item is Option<ItemKey>: None is a real empty hand, including falling-block placement contexts, not an invented air item. Nonempty items resolve through the existing load-owned item table. The game captures this read-only snapshot after the scoped subscription guard; no subscriber means no snapshot or item lookup. The block-state answer is applied as before.

The snapshots module owns placement inputs and loot-roll facts. Placement and tool items are resolved ItemKeys. Loot context entities, damage sources, tools, biome, block and place facts are owned values; retaining one never retains a borrowed engine context or a live level. A loot candidate is not an inventory ItemStack: its signed count can be non-positive while loot functions run, in which case it produces no item. Exploration searches remain deferred data, and name components remain unresolved text.

Item values and tag names

Every item has an ItemKey, including vanilla items no mod requested during registration. r.item_key(id) retains a forward reference in the same load-owned table; after the item registry is complete, host.item_key(&identifier) resolves any registered item. A key is still a private u32 index, not a copy of an item definition. Resolve once and retain it. host.max_stack_size(key) and host.max_damage(key) read item defaults without exposing the engine's item table.

ItemStack is owned API data with private fields. ItemStack::new(key, count) refuses zero, while ItemStack::of(key) makes one item. Count mutation refuses zero and arithmetic overflow. Registry-dependent maximum count and damage, including component overrides, are checked by the game when accepting a write; an oversized value is refused with a typed error, never silently clamped. This is the same boundary on native and wasm. Consuming split(n) returns StackSplit { taken, remainder }, both optional; consuming shrink(n) returns the remainder or None. There is no empty-stack sentinel. Component descriptors read and write an optional owned NBT patch; a plain stack has no patch allocation.

host.tags() and a restricted level host's tags() expose the same Tags service. Resolve a validated identifier once with tags.key(&identifier) and retain its TagKey. The runtime owns tag-name slots for the process; reload replaces membership, not names or key identity. tags.is_in_tag(item_key, tag_key) tests merged item membership. tags.items(tag_key) and tags.fluids(tag_key) return registered keys in vanilla's member order. Unknown tags return false or empty lists. Membership reads return None on clients, before authoritative tags load, or if the relevant registry mapping is unavailable. Key resolution itself can be refused if the runtime cannot allocate a name slot. No lookup stringifies a retained key. Block member enumeration moves with the registry-wide BlockKey table in the next type slice. The engine TagRegistry, StackData, StackView and DisplayContext are not API types.

A hook whose catalog entry has a scope (block, block_entity, item, menu) and a key, the argument naming the thing it is about, is a method on that scope's trait only: BlockPart, BlockEntityPart (a BlockPart too), ItemPart and MenuPart, as vanilla's and NeoForge's Block and Item methods are. It has no Hooks method: a thing without parts is never asked, and the game asks the parts attached to it in dispatch order, through the hook's contract, so a Cancel or a replacement skips the parts after it. Where a NeoForge bus event is the counterpart for that moment, the event is a hook of its own on Hooks, asked first for every thing, and its cancel skips the parts: a mod reacting to its own blocks uses a part, one reacting to every block the event. A part's kind is its type: the same kind twice on one thing, an invalid key declaration, or an unnamespaced id in the other Part registrations stops the game at startup with a message naming the mods. A panic in a part adds , part <type> to the mod and hook it names. Parts take &self and are Send + Sync, like Hooks.

Damage and item metadata snapshots

The three damage hooks convert only after their subscription guard. DamageValues retains the entire sequence, including reductions and the pre-hook input, so changing new_damage still reaches the engine's original NaN and negative normalization point. DamageValues::set_reduction edits snapshot data; it does not run a damage calculation. The fixed reduction storage is private; use its accessors rather than an array index. Reduction case order is append-only. Engine record_*, enter_pre, capture_inflicted and pre_change are not mod API methods.

ItemResource::damageable, foil and repairable delegate to the game's existing property readers through metadata access. Defaults, explicit overrides, component removals, compass glint and repair tags follow gameplay's single property path.

Recipe-file fields and small values

Host::recipe_ingredient and Host::recipe_result take the field's JSON text, not a serde_json::Value. Pass a quoted item id (r#""iron_ingot""#), a quoted #tag, or an ingredient array. Parse failures return RecipeJsonError::InvalidJson; unknown items and empty tags still return Ok(None). Result objects retain their components patch. These helpers are reload-time reads, not per-tick JSON parsers.

Former engine valueAPI-owned value
mod_list::KeyConflictContextmod_api::KeyConflictContext
firework::FireworkShapemod_api::FireworkShape
manifest::JarInputsErrormod_api::JarInputsError, with owned diagnostics and original-jar identity
Recipe helper &serde_json::ValueJSON &str, returning Result<Option<_>, RecipeJsonError>

BlockPart::clone_item_stack answers on the server when a player picks the block. It receives the level, position, block state and include_data, true when a creative player picks with Ctrl. Return None to keep the game's item.

BlockPart::fall_on runs at vanilla's landing moment on a read-only level random host. Request a deferred write with event.request(LandingWrite::SetBlock(state)), where state is an API BlockState, or LandingWrite::DestroyBlock { drop }. The game checks the complete write batch before queuing it. An invalid block key or ordinal refuses that callback's writes and other outputs, including damage; it never applies a valid prefix. After the entity batch ticks, writes apply in request order if the block still matches its original landed state. That guard runs once, so a first write can change the state without suppressing the next write. SetBlock notifies neighbors and clients; DestroyBlock preserves the requested resource-drop flag.

BlockPart::contents_changed(host, event) observes a world block's storage at setChanged, with a ServerHost, typed event.level, event.pos: BlockPos and event.state. Attach it to the specific blocks you observe, including vanilla containers; it has no global Hooks counterpart. The backing contents are already written, before dirty/comparator updates. Native transactions notify once per backing block at root commit, never on abort or simulation. An explicit BlockEntityRef::set_changed notifies even if contents are unchanged. A double container notifies each component, not once again for the other half's comparator refresh. A listener's writes still apply, but a nested notice for that same backing block does not re-enter the listener; writes to a different backing block notify normally. Entity/menu placeholder coordinates are not block notices. This replaces Create's positional contents_changed mixin for depot/package/schematic storage; there is no NeoForge bus event. A compiled listener that reads the committed items through the typed level (the game's own test, so it cannot drift):

#[derive(Clone, Debug, PartialEq, Eq)]
struct Notice {
    level: LevelRef,
    pos: [i32; 3],
    amount: u32,
    version: Option<i32>,
}

#[derive(Clone, Default)]
struct Changes(Arc<Mutex<Vec<Notice>>>);

#[mod_api::horizon_part]
impl BlockPart for Changes {
    fn contents_changed(&self, host: &mut dyn ServerHost, event: &mut ContentsChangedEvent) {
        let pos = [event.pos.x, event.pos.y, event.pos.z];
        let amount = event
            .level
            .capability(host, &ITEMS, pos, None)
            .map_or(0, |items| {
                (0..items.size(host))
                    .map(|slot| items.amount(host, slot))
                    .sum()
            });
        let version = host
            .block_entity(event.level, pos)
            .and_then(|entity| entity.data(host))
            .and_then(|data| match data.get(&"Version".into()) {
                Some(Tag::Int(value)) => Some(*value),
                _ => None,
            });
        self.0.lock().unwrap().push(Notice {
            level: event.level,
            pos,
            amount,
            version,
        });
    }
}

Declare a block entity type and its valid blocks during Hooks::register, then attach its part:

let press = r.block_entity_type("press")
    .blocks(&["mymod:press", "mymod:big_press"])
    .build();
r.block_entity_part(&press, Press).expect("native block entity type");

The builder qualifies the type ID with the registering mod's namespace. It owns the save ID, wire type and valid blocks; block_entity returns only initial Compound data (or none for an empty compound). It is fired inside the write that creates the entity (LevelChunk.setBlockState) and receives the read-only level, a LevelSide, on a server and on a client of one. Unknown blocks, duplicate types, overlapping membership and attempts to own a minecraft: block are refused at load. Refused native block, item or block entity declarations stop startup, as does unreadable known-components input; the game does not fall back to vanilla content. An explicitly declared mod template block uses its declared type; undeclared template blocks retain their vanilla behavior. Adding mod blocks to vanilla types is not supported by this builder. The hooks about the entity (block_entity_tick, block_entity_update_tag, pre_remove_side_effects, menu_button, menu_quick_move) find it by that declared type; the others (block_entity, screen, the block hooks) by block. The factory is a BlockEntityPart method, not a global Hooks method.

r.block_entity_type_key("other:press") looks up another mod's type, and r.block_entity_type_key("minecraft:chest") looks up a vanilla type. Both return the same opaque BlockEntityTypeKey as the declaration builder: equality and hashing use the shared load-owned registration slot, not the type's blocks. Bare names use the minecraft: namespace. The final registry resolves requests after all declarations and refuses unknown IDs, naming the requesting mod and type. key.id(host) reads the canonical name for diagnostics; a key retains its identity until restart. Use a type ID, not a block ID (minecraft:shulker_box, not minecraft:red_shulker_box). BlockEntityRef::kind(host) returns Option<BlockEntityTypeKey>. living.hurt_and_break(host, slot, amount) changes equipped durability on the server; negative amounts repair. It returns the pre-shrink stack only on break, and None when nothing breaks or the entity or slot is unavailable. Creative players' equipment is exempt. The operation does not fire equip effects.

block_entity.replace_data(host, data) replaces its saved payload, clearing omitted fields while retaining identity and position. Like merge_data, it loads transactionally and synchronizes the change; it returns Err(BlockEntityError::Stale) for an invalidated handle, or Ok(false) for data the entity cannot load or an unchanged result. Lookup keys may answer capabilities for vanilla and native types. block_entity_part accepts only native types and returns BlockEntityPartError::VanillaType immediately for a vanilla key. Native Parts are expanded after every mod has registered, so a Part can target a type declared later.

Item and tag Parts

For an item-specific Part, request its canonical registry key once in register: let stick = r.item_key("minecraft:stick"); r.item_part(stick, Sharp);. Bare names use the minecraft: namespace. Requests may refer to content declared later; the final item registry refuses unknown IDs with the requesting mod. ItemKey equality uses its load-owned slot, and key.id(host) reads its canonical diagnostic name. Item capability registration and item helpers still use strings; their migration is a separate change.

r.block_part_tag("minecraft:logs", Rotates) and r.item_part_tag("minecraft:axes", Sharp) attach a part to every member of a tag (its name without #). Each game resolves them against its own tags when its world opens, again on /reload, and when a client joins a server, so the window's game and its server thread each answer from the tags they play with; a block that leaves the tag is no longer asked. A mod's parts on one thing run in the order it registered them, by key or by tag. A part a tag gives a thing that already has that kind of part warns, naming both mods, and the earlier mod's part stays. A tag that doesn't exist warns. Opening a world fails, naming the mod, when a part is attached to a block or item the game doesn't have. Scoped hooks run on a game's thread; one dispatched from any other thread (which no game hook does) can't see tag parts, and warns once.

Instant item use

ItemPart::use_item receives UseItemEvent { stack, hand }. hand: Hand is the interaction hand of this click (Hand::Main or Hand::Off), including air clicks with identical stacks in both hands. Read it directly when choosing which held stack to shrink. living_used_item_hand instead answers only during active item use and cannot identify an instantaneous click. The Part runs on both sides with HostSide, after the global right-click event and before the item's own use, on native and wasm. Return Some(InteractionResult::Success) to consume and swing on the client, Some(SuccessServer) to consume and swing on the server, or Some(Consume) to consume without a swing. Some(Fail) skips this item's own use without swinging and allows the off-hand sequence to proceed; unlike a block-use Fail, it does not end the entire click. None (the default) lets later Parts answer; if none answers, the item's own use runs. Some(Pass) stops dispatch to later Parts and continues directly to the item's own use. TryWithEmptyHand belongs to block use and is treated as Pass here. A thrown item can return Success on the client and Consume on the server. The hand field corresponds to NeoForge's PlayerInteractEvent.RightClickItem.getHand().

Hooks::right_click_item runs before Item.use, including after the same hand's unhandled block or entity interaction. A block success or FAIL, or an entity success, stops fallback. The server fires it for the separate use-item packet, once for that packet's hand. Canceling skips the item Part and vanilla action on native and wasm alike.

Item wear

ItemPart::damage_item is IItemExtension.damageItem: the wear a stack of the part's item is about to take (ItemStack.hurtAndBreak), after Unbreaking, as NeoForge passes it. The part reads event.amount, the points about to be lost, and changes it (0 or less leaves the item whole) or returns Flow::Cancel for no wear at all. It runs on every wear path: a player's tools, armour, shield and mace, a tool a dispenser uses, and a mob's armour. It is not asked when Unbreaking already saved the item or for a creative player's tools, and an item with no part costs one table lookup. With no part, the vanilla amount applies.

The global item_damaged hook is gone: this method is its one moment, and a part acts only on its own item. What the global hook could do and a part cannot is change the wear of another mod's or vanilla's items, which no part is attached to. Do that with an enchantment effect in data (a minecraft:item_damage effect on the enchantment, the component Unbreaking uses), or with an injection. A part does not get the wearing entity; host.players().acting_player() names the player on the paths a player takes.

A capability is answered by providers, registered by block entity type as NeoForge's RegisterCapabilitiesEvent registers them: obtain pipe with the declaration builder or r.block_entity_type_key("mymod:pipe"), then r.block_entity_capability(&ITEMS, &pipe, |host, at| Some(Box::new(PipeItems))) gives a handler for the block entity and side at (a BlockSide), or none to leave it to the next provider. A provider may answer for a vanilla block entity type too, using its lookup key. Providers add up: level.capability(host, &ITEMS, pos, side) (Level.getCapability, on a LevelRef) asks those registered with r.block_entity_capability_before_game first, then the game's own (a block entity's slots by side, by the rules hoppers follow, and a mod block entity's block_entity_container), then the others, each in registration order, and the first to answer wins. That order is the fork's choice, the game first and _before_game to go ahead of it: NeoForge 26.3's javadoc doesn't state one between mods' providers and the game's. It returns a Capability, the handler and who answered. A handler holds no borrow: each call is given the host, so it may call a neighbour's capability, and it keeps its state in the block entity's slots, compound or attachments. A provider is given the block entity and side, so the handler it makes knows where its contents live. Item handlers are NeoForge 26.3's ResourceHandler<ItemResource>: indices from 0 (size), what each holds (resource, an ItemResource: an item and its components, without a count) and how much (amount), how much of a resource an index holds when it holds that (capacity, none where it may not go), and insert and extract, which take a &Transaction and answer how much moved. There is no simulate: a write happens in a transaction (host.transaction(|host, tx| …), below), and to try one, abort it. move_resources(host, &from, &to, filter, max, tx) (NeoForge's ResourceHandlerUtil.move) moves what filter accepts, up to max, index by index out of from into wherever to takes it, each move all or nothing: what to won't take stays put. It answers how much moved, or Overanswered (below). Hoppers use a mod's handler where one is registered, each move in a transaction of its own. A mod's own capability is a CapabilityKey::new("mymod:chemicals"), answered only by parts.

A Capability may be held for the tick: look a neighbour's up once per tick and reuse it for every move that tick, as pipes and machines do. Ask again on the next tick, since the block entity behind it may be gone by then. For a wasm mod each lookup crosses the sandbox (about 2.5 µs, see Building a mod to mod.wasm), so holding it matters more there.

An item's own capability is reached where its stack lives, an ItemPlace: a player's slot, hand or cursor, a block entity's slot, an item entity, a stack inside another item (ItemPlace::Nested) or a mod's own place (ItemPlace::Mod, read and written by the PlaceProvider it registers with r.place_provider). r.item_capability(&ITEM_ITEMS, "mymod:backpack", |host, place| Some(Box::new(Backpack))) answers ITEM_ITEMS for an item with a ResourceHandler<ItemResource> made for that place, and r.item_capability_before_game ahead of the game's own over item_container; place.capability(host, &ITEM_ITEMS) (ItemAccess.getCapability) asks them in the same order. A handler keeps its contents in the stack, and reads and writes it through the place, as NeoForge 26.3's ItemAccess: place.stack(host), place.resource(host) and place.amount(host), and place.insert, place.extract and place.exchange(host, tx, &resource, amount), which answer how much moved. Each runs in a transaction of its own, nested in tx, and changes the place where it is, journaled, so an abort puts it back at every level of an item inside an item. A handler answering more than it was asked or offered is a mod bug: the move it was part of is undone, and the game warns once, naming the mod. What doesn't stack with the place, or doesn't fit, goes where vanilla puts an item split off it, so exchange(host, tx, &filled, 1) on a stack of empty buckets is vanilla's filled bucket: from a player's place into their inventory, else dropped with no thrower (a creative player's hand takes anything and gives them one if they have none, as forInfiniteMaterials); from an item entity dropped beside it; from a block entity's, an entity's or an item's slot into that container's other slots (forHandlerIndex). A handler rewrites its item with an exchange of one. An exchange inside one of two backpacks is done in one of them: that one is rewritten holding the change and splits off, and the other stays as it was.

A stack's components change the same way, where it lives (NeoForge's ItemResource.with and without). A component is named by a ComponentId, checked once as a namespaced id (ComponentId::new("minecraft:damage"), None for Damage or !minecraft:food). resource.component(&id) reads the stack's own value in its patch as saved; resource.with_component(host, &id, value) sets it as vanilla's PatchedDataComponentMap.set does, keeping no value of the stack's own where value is the item's default (host.item_default_component(item, id), compared as the component's codec reads it: a vanilla component's numbers by exact value, custom_data and the other raw NBT components and a mod's own type for type), and resource.without_component(host, &id) removes the component as remove does: the stack's own value goes, and where the item has the component by default a removal ("!minecraft:max_damage": {}) is recorded in the patch. An exchange of one puts the changed item in place: place.exchange(host, tx, &place.resource(host)?.with_component(host, &damage, Tag::Int(5)), 1) damages one sword of a stack, splitting it off.

An entity's items

entity.capability(host, &ENTITY_ITEMS, ()) (NeoForge 26.3's Capabilities.Item.ENTITY) is an entity's items, None for a stale handle and an entity outside the level in place. The game answers it for a player with their inventory (PlayerInventoryWrapper), indices as vanilla's Inventory numbers them: 0..36 the main inventory (0..9 the hotbar), 36..40 the armour from feet to head, 40 the off hand. Each index is the player's ItemPlace::PlayerSlot and takes only what fits there: an armour slot one of what its equippable names it for (ArmorSlot: its allowed entities hold the player by id or by tag), and bound armour comes off only in creative. insert_any, without an index, goes where Inventory.add puts a stack: onto the same item in the selected slot, the off hand and the main inventory in order, then into its empty slots, never the armour. For a chest or hopper minecart, a chest boat and a chested donkey, mule or llama the game answers with their container's slots (ItemPlace::EntitySlot). A handler kept past its entity's removal or its world's close reaches nothing. r.entity_capability(&ENTITY_ITEMS, "mymod:robot", |host, robot, ()| …) answers it for a mod's entity type, and r.entity_capability_before_game ahead of the game's own, asked as block entities' providers are. Giving, taking and counting a player's items go through it:

#![allow(unused)]
fn main() {
// A trade: each offer is (index, the exact stack it must still hold). A stale offer or no
// room for one aborts the transaction, and every move in it is put back.
let (a, b) = (seller.capability(host, &ENTITY_ITEMS, ())?, buyer.capability(host, &ENTITY_ITEMS, ())?);
host.transaction(|host, tx| {
    for (from, to, offer) in [(&a, &b, &seller_offer), (&b, &a, &buyer_offer)] {
        for (index, stack) in offer {
            let offered = ItemResource::of(stack);
            let still = from.resource(host, *index).as_ref() == Some(&offered)
                && from.amount(host, *index) == stack.count();
            if !still
                || from.extract(host, tx, *index, &offered, stack.count()) != stack.count()
                || to.insert_any(host, tx, &offered, stack.count()) != stack.count()
            {
                return Err(());
            }
        }
    }
    Ok(())
})
}

Cauldron interactions outside a transaction keep vanilla's effect order. Inside an open transaction, their statistic award (including player_stat_award), sound and overflow item toss/spawn wait until the outermost successful commit; an abort emits none of them.

Entity energy and fluids

entity.capability(host, &ENTITY_ENERGY, side) and entity.capability(host, &ENTITY_FLUIDS, side) reach a mod's entity energy and fluid handlers (NeoForge 26.3's Capabilities.Energy.ENTITY and Capabilities.Fluid.ENTITY), where side is the Option<Direction> of the entity the lookup reaches it from, None for no particular side. Vanilla answers neither; register a provider with r.entity_capability(&ENTITY_ENERGY, "mymod:robot", |host, robot, side| …), which is given that side and may answer differently for each. An entity capability's context is its key's second type: () for ENTITY_ITEMS, Option<Direction> for these two and for ENTITY_AUTOMATION. The existing move_energy and move_resources transfer between these and block or item handlers inside a transaction.

Entity automation

entity.capability(host, &ENTITY_AUTOMATION, side) (NeoForge 26.3's Capabilities.Item.ENTITY_AUTOMATION, "automation-accessible inventory of an entity (boats, minecarts, etc)") is the items of an entity as a hopper reaches them, with the same Option<Direction> context as the energy and fluid keys. The game answers it for the container entities EntitySelector.CONTAINER_ENTITY_SELECTOR picks (chest and hopper minecarts, chest boats) with the slots their ENTITY_ITEMS handler has; a player and a chested donkey, mule or llama answer none. A hopper pushing into the entity in front of it asks for the side it faces the entity from (Up for a hopper over it), a hopper pulling from the entity above it for Down, and a dropper for the side it drops onto. Beside the game's container entities, those entities at the target whose mod provider answers it join the candidates, and the hopper picks one at random with the draw getEntityContainer makes; a mod handler takes the container's place and moves one item at a time in a transaction, as a mod block's ITEMS does. r.entity_capability(&ENTITY_AUTOMATION, "mymod:robot", |host, robot, side| …) registers one, and r.entity_capability_before_game answers ahead of the game's own for a minecart or a boat.

A menu's slots

Mod-created recipe input sets

Hooks::recipe_property_sets runs once after recipes resolve for each load and again after /reload. Add RecipePropertyAddition::new("mymod:cooking_pot_input", items) to its event.additions: a key in the listener's manifest-owned namespaces creates a set. Ownership uses the package ID plus the manifest's namespaces, so a package named ported_farmersdelight with namespaces = ["farmersdelight"] can create farmersdelight:cooking_pot_input. A later listener may extend an existing set but cannot create a new foreign key. Invalid keys, unknown vanilla keys and air are skipped with a warning; unregistered items do not participate in acceptance or network sync.

Attach a MenuPart to the registered menu type and filter its input slot with the same set:

struct PotInput;
#[mod_api::horizon_part]
impl mod_api::MenuPart for PotInput {
    fn menu_slot_rules(
        &self,
        context: &dyn mod_api::LoopContext,
        event: &mut mod_api::MenuSlotRulesEvent<'_>,
    ) {
        if event.slot == 0 {
            event.may_place &= context.mod_recipe_property_contains(
                "farmersdelight:cooking_pot_input", event.stack.item(),
            );
        }
    }
}
// In Hooks::register:
// registrar.menu_part("farmersdelight:cooking_pot", PotInput);

The Host exposes the same mod_recipe_property_contains(set, ItemKey) query outside restricted menu callbacks. This query covers mod-created sets only: unknown sets, unknown items and vanilla set keys return false. Existing vanilla station acceptance is unchanged. The server reads its owning load; the client reads local preload sets before its first UpdateRecipes, then the authoritative server generation. A packet replaces the entire generation, including an empty one. Closing a screen or changing dimension retains it; disconnecting discards it. Reads allocate nothing and do not rescan recipes or registries. There is no second hook and no per-slot load callback.

Client hooks can read the local player's menu with host.local_player()?.container_menu(host), including the inventory when no container is open. MenuRef::slot_count, slot and carried read on either side; slot and carried return an outer None when the handle or slot is unavailable and Some(None) for an empty slot. Each read checks the world epoch and menu generation, so a handle cannot read a replacement, reopened or respawned menu. These methods do not click or change slots.

Mutable client hooks can submit a vanilla click with menu.click(host, &[mod_api::MenuClick::QuickMove { slot }]). The slot is the same menu-order index used by slot, not a hotbar index or a GUI slot id. For example:

/// Shift-click a slot in the local player's current menu.
pub fn quick_move(host: &mut dyn mod_api::ClientHost, slot: usize) -> bool {
    let Some(menu) = host
        .local_player()
        .and_then(|player| player.container_menu(host))
    else {
        return false;
    };
    menu.click(host, &[mod_api::MenuClick::QuickMove { slot }])
}

The typed actions are Pickup (with MouseButton::Left or Right), QuickMove, Swap (with SwapWith::Hotbar(0..=8) or Offhand), Clone, Throw, Drag and PickupAll. Pickup accepts None for outside, dropping the carried stack. A Drag { kind, slots } owns its menu-order slot list and always submits a complete gesture; DragKind::Split divides evenly, One places one per slot and Clone fills slots in creative mode. Every action in a batch is validated before any prediction or sending; out-of-range slots/hotbar indices, creative-only actions in survival and empty batches refuse without effects. Stale/replaced/respawned menus, spectators, non-client hosts and the creative inventory screen refuse with false. true means submitted, not server acceptance: prediction uses the normal menu engine and sends ContainerClick with its state id, changed slots and carried item. Server corrections still win. Inventory reads and direct server-side item transactions keep their existing contracts; a click does not expose direct client inventory writes or enforce a mod's locked-slot policy. Call from an explicit input action rather than submitting every render/tick. Diverges from upstream: new helper.

menu.close(host) closes the menu's screen as Escape does and tells the server (ContainerClose); it returns false while that screen is not open, so the inventory menu closes only while the inventory screen shows. menu.click_button(host, button) presses one of the open menu's buttons (ContainerButtonClick, as a loom's pattern list does), for input the screen's own buttons don't take; the server's menu decides what it does, and the inventory menu has none.

ItemPlace::MenuSlot { menu, slot } is slot slot of a player's open menu, a MenuRef (player.container_menu(host)). It is a fork addition: NeoForge 26.3's ItemAccess reaches a player's inventory slot (forPlayerSlot) and cursor (forPlayerCursor), not a menu's slot. It is the storage the slot shows, named by menu.slot_backing(host, slot) (MenuSlotBacking), and a write goes to that storage through its own handler, so it is kept once by the transaction and the storage's rules hold: the player's inventory, armour and off hand by their ENTITY_ITEMS index, a chest's or a furnace's slot through its ITEMS handler, a chest minecart's or a chested mount's through its ENTITY_ITEMS, and the player's ender chest, inventory crafting grid and a mod's chest menu (open_menu), kept by the game. The slot's own rules come first, as a click has them: menu.slot_holds(host, slot, &stack) is how many of a stack it holds (Slot.mayPlace, then Slot.getMaxStackSize: a furnace's fuel slot one bucket, an armour slot one piece that fits it, a shulker box's slot no shulker box, a result slot none), and menu.slot_gives(host, slot) whether it gives up what it holds (Slot.mayPickup: bound armour stays on outside creative). An insert fills that slot alone, and what doesn't fit stays out. A crafting grid a transaction changed has its result made again at the outermost commit, once for each player however many writes it took (InventoryMenu.slotsChanged, with native_prepare_item_craft); the result slot takes nothing and gives nothing, since taking it is a craft. A menu closed, replaced or reopened since the MenuRef was made, or one whose player left, reads and writes nothing. menu.cursor() is that menu's cursor (ItemPlace::PlayerCursor { menu }, forPlayerCursor), which reaches nothing once the menu is gone, so a kept cursor never reaches the next menu's. The player's screen follows with the tick's container sync.

#![allow(unused)]
fn main() {
// Put four of the menu's offered emeralds into slot 0 of the open chest, or nothing.
let (slot, cursor) = (ItemPlace::MenuSlot { menu, slot: 0 }, menu.cursor());
host.transaction(|host, tx| {
    let took = cursor.extract(host, tx, &emerald, 4);
    if took == 4 && slot.insert(host, tx, &emerald, 4) == 4 { Ok(()) } else { Err(()) }
})
}

A block's fluids

Fluids are the capability FLUIDS (NeoForge's Capabilities.Fluid.BLOCK), answered as items are, by a ResourceHandler<FluidResource> in millibuckets. A FluidResource is a fluid id and its data components, without an amount, kept as an item stack's are (a potion fluid's contents; water and lava have none), and a tank fills only from empty or with the same fluid and the same components. A FluidStack is one with an amount, as tanks keep it (resource.to_stack(amount), FluidResource::of(&stack)). The game's own answer is a mod block's tanks by side (block_entity_fluids) and a cauldron, one tank holding the content its block has: vanilla's water (a bucket in thirds) and lava (a bucket only whole), or one a mod registered through register_cauldron_fluid_content, filled and drained by whole levels; a cauldron has no block entity and is answered by position, and a powder snow cauldron holds no fluid. A cauldron takes the fluids that have a content whatever their components and discards them, so what comes out of it is plain: the one place a fluid's components are dropped. A mod block's saved tanks take no fluid with components. level.capability(host, &FLUIDS, pos, side) finds a handler in a LevelRef's level, as ITEMS' are found, and move_resources moves fluids as it moves items. A move the target takes only part of is undone and tried again with that part, so a cauldron, which gives whole thirds and never more than asked, gives what fits: asked for a bucket with room for 500 mB, it gives one level, 334 mB.

Fluid held by an item is ITEM_FLUIDS (NeoForge's Capabilities.Fluid.ITEM), found where its stack lives, as ITEM_ITEMS is: place.capability(host, &ITEM_FLUIDS) gives a ResourceHandler<FluidResource> for the item at that ItemPlace, and a write rewrites the item there by an exchange of one, so from a stack of more than one the changed item splits off (r.item_capability(&ITEM_FLUIDS, "mymod:canister", …) answers it for a mod's item). The game answers it for vanilla's buckets: one tank of 1000 mB, filled and drained whole, an empty bucket taking plain water or lava and becoming its full bucket, a full one becoming an empty bucket. move_resources moves between an item and a block as between two blocks: a full cauldron fills a bucket in a player's slot, and two thirds of one fill none.

A block's energy

Energy is the capability ENERGY (NeoForge's Capabilities.Energy.BLOCK), answered by an EnergyHandler: an amount and a capacity, with no resource, and insert and extract written in a transaction as a ResourceHandler's are. r.block_entity_capability(&ENERGY, &battery, …) (with a declared or looked-up type key) registers a provider, asked as ITEMS' are, and level.capability(host, &ENERGY, pos, side) finds one. The game's own answer is a mod block entity's block_entity_energy buffer, as NeoForge's SimpleEnergyHandler: a fill through the sides it comes through, up to the buffer's room and its limit per insert, and a drain by the sides it leaves by, up to the limit per extract; a side that reaches neither finds no handler. move_energy(host, &from, &to, max, tx) moves between two (EnergyHandlerUtil.move) as one step of move_resources: all or nothing, a move the target takes only part of tried again with that part.

Energy held by an item is ITEM_ENERGY (NeoForge's Capabilities.Energy.ITEM), found where its stack lives with place.capability(host, &ITEM_ENERGY), as ITEM_FLUIDS is, and moved by move_energy as a block's is. A mod answers it for its item with r.item_capability(&ITEM_ENERGY, "mymod:cell", …), keeping the energy in the item's own stack and rewriting it by an exchange of one, so from a stack of more than one the charged item splits off. Vanilla has no energy items, so the game answers it for none.

The game never trusts an answer past what it asked or offered. A handler answering more than it was asked has its move undone, in its transaction, and is warned once naming the mod, and move_resources or move_energy returns Overanswered naming it. Returned from the closure of host.transaction with ?, it aborts the whole move, so neither end keeps any of it: a broken handler can't make a move overflow, duplicate or lose items, fluid or energy, or panic the game.

A LivingRef on the server can apply one effect from an item's loaded consumable with living.apply_consume_effect(host, &stack, index). The index is zero-based in component order; this does not eat food, shrink the stack or run consume triggers. Some(true) or Some(false) is the effect's result; None means the item, index or target is unavailable or the effect is unsupported. Mob random teleport, armor stands and dragons are not supported. Player random teleport uses the level random as normal.

Mod-defined kinds dispatch through the ordinary consume_effect hook. For these kinds, Some(true) means dispatch was entered, not that a listener changed game state; an unclaimed kind still does nothing. Reapplying the same kind from inside its own handler on the same call stack returns Some(false) without recursion. Another kind, including one owned by another mod, dispatches normally, and a later independent call can apply the original kind again. This deliberately reverses #550's original blanket exclusion of mod-defined kinds; it is not a second consume hook or an alternate dispatch path.

On a ServerHost, import HostTransaction to call host.transaction(|host, tx| …) (NeoForge 26.3's Transaction). It runs a closure whose Ok commits and whose Err aborts, nested in the open transaction if any. tx, a &Transaction (NeoForge's TransactionContext), is what every handler and place write takes, so none happens outside one. Inside, a capability write applies at once, so later steps see it, and the game keeps each storage as it was before the transaction first wrote it: a block entity's item slots, saved compound and attachments, a hopper's cooldown, a cauldron's block state, a player's inventory (slots, armour, off hand and cursor, whether the player is the host or a guest), an entity's inventory (a chest or hopper minecart, a chest boat, a chested donkey, mule or llama) and an item entity's stack (one emptied stays, empty, until the outermost commit removes it). A nested commit hands its changes to the transaction around it, and only the outermost commit makes them final. Then the world is told of each change once, as SnapshotJournal.onRootCommit: setChanged and comparators, a jukebox starting or stopping, a cauldron's block update, and an item dropped at a block, thrown by a player (what a filled bucket's split no longer fits) or by an entity or split off an item entity. An abort puts every storage back quietly, so nothing outside saw the change. A panic unwinding out of the closure aborts that transaction as it passes, and the one around it stays open. Open one with host.transaction. The game, not the values it hands out, holds the authority, and it keeps writing apart from ending. The closure, like any handler, is lent the Transaction: it names one open transaction by a token the game drew (unguessable in practice), and a write given one that names no open transaction changes nothing. Ending a transaction takes its TransactionSeal, which only host.transaction's guard (or whoever called the raw transaction_open) holds; the raw transaction_close refuses a seal that names no open transaction. So a handler can neither commit the transaction it was lent, nor end one around it, and a value decoded from bytes or kept past its closure does nothing. A transaction left open inside another is aborted when the one around it closes, so a caught panic or an Err still finds the rollback done. A handler keeps no journal of its own: its state is the game's, so the game's journal covers it. Direct writes are not covered: an item entity's stack, LivingRef::set_equipment, and the player and armor-stand slot_set paths take effect and are final at once, even inside a transaction. Any equip/unequip event and equip sound from set_equipment also happen immediately; an abort does not undo them. This differs from writes through journaled capability handlers.

ItemPlace::EntitySlot { entity, slot } is slot slot of such an entity's inventory (NeoForge's Capabilities.Item.ENTITY), read and written like any other place. Reading or writing a minecart's slots unpacks its loot table with no player, as getChestVehicleItem does.

A mod's own capability

A mod can define a capability of its own, as Mekanism defines chemicals: a resource type and a key, published from its root crate (Dependencies between trait mods) for other mods to use.

#![allow(unused)]
fn main() {
// The chemistry mod's root crate
#[derive(Clone, PartialEq)]
pub struct Chemical(pub String);
pub const CHEMICALS: CapabilityKey<dyn ResourceHandler<Chemical>> = CapabilityKey::new("chem_api:chemical");
}

A mod that names it under [dependencies] builds against the same Chemical and CHEMICALS, so its providers, r.block_entity_capability(&chem_api::CHEMICALS, &vat, …) (where vat is a declared type key or r.block_entity_type_key("chem_pipe:vat")), answer the same capability, and level.capability(host, &chem_api::CHEMICALS, pos, side) finds either mod's handler. move_resources moves a chemical as it moves items, in the caller's transaction. The game has no answer of its own for a mod's key, so only the mods' providers do, and a lookup with none registered costs one table check. A handler that keeps its amount in its block entity's saved data (BlockEntityRef::merge_data) is covered by the game's journal, so an aborted transaction puts it back. ItemCapabilityKey::new and EntityCapabilityKey::new define item and entity capabilities the same way.

A key is known by its name, which must be namespaced (chem_api:chemical), and a name has one handler type. Two mods that each declare their own Chemical under one name, rather than sharing the defining mod's, would never see each other's handlers. So the game refuses that at startup, naming both mods and both types, and it refuses a key whose name has no namespace. Both checks run once, when the mods register.

Entity parts and mob goals

r.entity_part("minecraft:zombie", ZombieGoals) attaches an EntityPart to one entity type, a mod's own or vanilla's. Its method register_goals runs at every Mob construction of that type — spawned or loaded, the moment vanilla's registerGoals fan-out ends — and the goals it adds join the mob's two selectors beside the species' own, with the same priorities, flag locks and interruption:

#![allow(unused)]
fn main() {
struct ZombieGoals;
#[mod_api::horizon_part]
impl EntityPart for ZombieGoals {
    fn register_goals(&self, goals: &mut GoalRegistrar<'_>) {
        // goals.mob().id() is "minecraft:zombie": branch as per-species code does.
        goals.add_goal(7, Stare::default());
        goals.add_target_goal(3, Remember::default());
    }
}
}

goals.add_goal(priority, goal) and goals.add_target_goal(priority, goal) are GoalSelector.addGoal on the mob's two selectors; GoalFlags claims MOVE, LOOK, JUMP and TARGET as vanilla's Goal.Flag does, and a higher priority (a lower number) interrupts a running goal holding the same flag. A goal object is per mob, built fresh at every construction, and its state is never saved: a goal that needs memory across loads keeps it in attachments. A mob whose type has no part, or a game with no entity parts at all, pays nothing — the wrapper and the tick's read view exist only where a mod goal runs.

A Goal reads and writes through its GoalContext: mob() is a fresh handle of the mob it runs on, host() the tick's read view, random() the mob's own random (mob.getRandom(), so a mod's draws keep the mob's stream in shape). The read view answers the ticking mob itself directly, the player the tick sees, and every other entity from the tick's snapshot — a getEntities of the moment the mob list was taken out — so a read the snapshot does not carry (a mob's gear or effects, its species id) answers None, and the handle docs mark the reads that carry the snapshot rule. Writes apply to the mob itself through the engine's own paths: move_to(pos, speed) and stop_navigation() (PathNavigation), look_at(pos) (LookControl), jump(), and set_target(Option<LivingRef>) (Mob.setTarget, with the write handle's rules: a fox, an enderman or a zombified piglin stores it with its setter's side effects, and a brain-backed species refuses, since its target is a brain memory that MobRef::set_brain_target writes from a server host). Writes to other entities are not part of a goal; the write handles a ServerHost takes do not exist in a goal tick. Clearing a fox's target ends defending without changing its trusted entities. An enderman's target controls its creepy flag and attacking speed modifier; clearing it also clears its stared-at flag. A zombified piglin schedules its first anger sound and next alert when it acquires a target from none, not when switching between targets. Targeting a player does not count as player damage.

Brain behaviors

Native mods only: entity parts do not cross to wasm yet.

EntityPart::register_behaviors receives a BehaviorRegistrar whenever a brain-driven species builds or rebuilds its brain. Use add(activity, priority, min_duration, max_duration, requirements, behavior) to join a fresh Behavior to the species' supported activity. Registration returns a BehaviorRefusal for an unsupported activity or invalid duration. Behaviors start and stop in vanilla Brain.tick order: priority, activity bucket, then insertion order, with mod behaviors after vanilla behaviors in the same bucket. Their state is not saved. BehaviorCx provides the mob, host, random source and typed brain memory access; each species refuses memories it does not support.

The part's other method, spawn_cluster_size, changes how large a natural spawn group of the type grows (Mob.getMaxSpawnClusterSize); controlling spawns shows it.

Entity parts are a native mod's for now: the goal surface does not cross to wasm (see the attach_entity_part note), so r.entity_part is absent from a wasm build.

Block-state facts

A BlockState is an API-owned value pairing a BlockKey with a block-local canonical ordinal, not a numeric engine state ID or a live object. Facts that need no world are a small read-only service on any host, both sides: host.block_states() (import HostBlockStates). Each reads the game's own tables through checked crossings. Fact queries and matching allocate nothing; definition copies the property data for the caller. Property operations on BlockState borrow explicit immutable block metadata; block() and ordinal() need no host:

MethodVanilla
replaceable(state) -> Option<bool>BlockState.canBeReplaced()
destroy_speed(state) -> Option<f32>BlockBehaviour.Properties.destroyTime (getDestroySpeed): stone 1.5, -1 unbreakable
sound(state) -> Option<BlockSoundType>BlockState.getSoundType(): volume, pitch and the break, step, place, hit and fall events
has_block_entity(state) -> Option<bool>BlockState.hasBlockEntity()
map_color(state) -> Option<u8>BlockState.getMapColor: the MapColor.id a map item colours a column by, 0 for none
states_of(block) -> Option<BlockStateRange>StateDefinition.getPossibleStates, as owned values in canonical local-ordinal order
definition(block)Property names and legal serialized values
matches(state, block, constraints)Partial property match with typed invalid-property and invalid-value errors

Use StateConstraint for each partial constraint; StateMatchError identifies an invalid property or value by index. StateProperty describes a property's legal values. These types are shared by state-definition services.

None for an unknown state or block, including an invalid local ordinal, and on a host with no game behind it (a mod's test double). The ordinal order is defined by the complete canonical property constraints, not by the engine's numeric enumeration.

Fluid-state facts

Import HostFluidStates to reach host.fluid_states() on either owning side. definition(key) returns Some(FluidDefinition::World { default, properties }) for a world fluid, Some(FluidDefinition::TankOnly) for a registered tank-only fluid, and None without a loaded registry. The properties and typed default come from the loaded preparation, not a second vanilla table. These metadata reads are available in setup, reload and world callbacks after the fluid keys resolve, not during registration or configuration.

validate(key, constraints) checks partial StateConstraint values in input order without matching any particular state. It returns Result<(), FluidStateError>: UnknownProperty(index), InvalidValue(index) or Duplicate(index) identifies the first offending input. NoWorldState means the fluid is tank-only or the host has no definition; use definition to distinguish them.

type_properties(key) returns a FluidTypeProperties record, or None without a loaded registry or for a key it doesn't hold; host.fluid_key(name) is None for an unknown name. The record is the same in every level: description_id, light_level, fall_distance_modifier, and whether the fluid can push entities, be swum in, drown, extinguish, hydrate and carry boats. Flowing forms answer as their source. The empty fluid and tank-only fluids emit no light, do nothing to an entity and have a fall modifier of 1. Anything that depends on a level, such as lava's current or source conversion, isn't in the record. The record also carries three Fluid facts: explosion_resistance (100 for water, lava and a mod's world fluid, 0 for the empty or a tank-only fluid), randomly_ticks (lava only) and drip_particle (minecraft:dripping_water or minecraft:dripping_lava, none for the rest).

legacy_block(state) is FluidState.createLegacyBlock: the water, lava or mod-fluid block that holds a fluid state (a source is level 0, a flowing amount is 8 - amount, falling adds 8) and air for the empty fluid. It is None for a state no fluid can be in: a tank-only fluid, an amount above 8, a zero amount for anything but the empty fluid, or a source that isn't full.

What depends on a position is on LevelRef, not in this service: fluid_tick_delay(host, fluid, pos) (water 5, lava 30 and 10 in the Nether, a mod fluid's as its fluid_rules parts change it there), fluid_height, fluid_height_for_camera and fluid_can_be_replaced_with(host, pos, fluid, direction). fluid_tick(server, pos) runs a fluid's flow step now; schedule_fluid_tick queues one.

FluidState::matches(constraints) is a pure local check of carried vanilla fields. Unknown properties or noncanonical serialized values return false. A falling source remains a source; a flowing state of level 8 remains flowing. Empty constraints match any state. Validate separately for indexed errors. Neither matching nor validation allocates; explicitly copying a definition may allocate. Unresolved saved tank identities remain FluidIdentity::Unresolved(handle), not world states or tank-only keys.

Fake players

host.fake_player(level, uuid, name) makes, or finds again, a player with no connection in the level in place, as NeoForge's FakePlayerFactory does: a machine that acts as a player (a deployer, a block breaker). It is no online player: the tab list, player counts, sleeping, natural spawning, chunk tickets, statistics and saves leave it out. player.is_fake(host) tells one apart; player.remove_fake(host) removes it.

player.perform_action(host, action) makes it act as a player does, through the game's own path for the action, as that player's packet would run it:

  • PlayerAction::UseOn { hand, pos, hit } is a click on a block (ServerPlayerGameMode.useItemOn): reach, the hit's place on the block, the build height, spawn protection and the border, then the block's use and the item's useOn, which is how a block item is placed;
  • PlayerAction::UseItem { hand } is a use in the air (ServerPlayerGameMode.useItem), by the item's own ray from the player's eyes and look: a bucket clicked on a block passes, as vanilla's does, and empties when used.
  • PlayerAction::InteractOn { hand, target, location } interacts with that entity (Player.interactOn), after the border and interaction-range checks, with the hit relative to its origin. A passed click does not fall back to using the item in the air. The fake keeps its own supplied look, rather than aiming a new ray;
  • PlayerAction::Attack { target } is a hit with the main hand (handleInteract's ATTACK, then Player.attack): the border and the attack range, the native mods' attack_entity, then the blow on a player, a mob or another entity, or a fireball, wind charge or shulker bullet turned back. The blow is scaled by the attack strength, as a player's is, so a fake that hits every tick does a fraction of a full blow. A mob doesn't take the fake as the player to answer;
  • PlayerAction::BreakBlock { pos } breaks the block at once (ServerPlayerGameMode.destroyBlock): reach, the build height, spawn protection and the border, then the game mode's and the held tool's rules (adventure mode can't, a sword in creative can't, and outside creative a block no digging breaks, destroy time -1 such as bedrock, stays), the native mods' break_block, the tool's wear and the drops. There is no digging time: a machine that digs over time counts its own progress and breaks the block when it is done.

Its items are its own inventory: set the stack in its hand first (through ENTITY_ITEMS or an ItemPlace), and read what is left after. The answer says what happened:

AnswerMeaning
Ok(ActionOutcome::Consumed)Vanilla's InteractionResult consumed (a success or a consume), the blow landed, or the block broke
Ok(ActionOutcome::Passed)Nothing used the click, a mod or the game stopped the blow, or the block stayed
Ok(ActionOutcome::EffectBlocked(effect))The click ran, but what it would start was stopped: a menu or screen, sleeping, riding, or going to another dimension (FakeEffect). The click's own side effects have still happened then: the swing seen by others, the hooks that run before the use, vanilla's Success
Err(ActionRefused::…)Nothing ran: NotFake (a real player's handle), Stale, Busy (another player's turn, an open transaction, or a hook inside another perform_action), OutOfReach, Protected, InvalidSetup (non-finite scoped viewpoint or entity hit), or InvalidTarget (an interaction or attack on the fake itself or an entity not in its level)

For a machine using an isolated stack, player.perform_action_scoped(host, action, setup) composes that same persistent player's action with temporary state. Build PlayerActionSetup::new(stack, eye, yaw, pitch, secondary_use): the fake acts in survival with a fresh standing body (eye height 1.62), that stack in its main hand, and no other inventory or equipment. Its supplied rotation is used for item rays. Secondary use holds semantic sneak without changing that standing eye height. No second player is created.

The ScopedActionResult contains outcome (the same ActionOutcome), held (the nonempty main-hand remainder) and other (other nonempty inventory stacks, then offhand, carried stack and armor). These stacks are returned to the caller, not kept by the fake. The fake's persistent body, inventory, game mode, look, secondary-use state and action timers are restored even on refusal; world changes and the usual interaction/Parts hooks remain. The ordinary fake safety rules still apply: no screens, sleep, rides, dimension travel, online participation, statistics, advancement credit or network connection. Neither action method can run inside another player turn or an open transaction; the action's own ordinary write paths still run transactionally.

On either side, player.entity().is_shift_key_down(host) reads semantic secondary use rather than a physical key mapping or a crouching pose. On the client obtain the reachable local handle with host.local_player() (or host.entity(id)); it reads live secondary use even before the next movement tick updates the body's shift flag. On the server the existing entity flag is read, including a scoped fake's setup. Stale handles and clients without a joined local player return None.

What a machine does beyond a player's action stays in the mod: its own mining progress and catching world drops. Scoped actions retain player reach, protection and border checks; eye placement is supplied by the mod.

Testing a trait mod

Enable the testing feature on the mod's horizon-mod-api test dependency. Then testing::with_mods takes an owned Mods list and runs a closure with it installed on the current thread. It restores the previous list even if the closure panics. The test allocation is retained because dispatch exposes a static slice. Call dispatch_<hook>(&mut host, args…) inside the closure to build the event as the game does.

testing::FakeHost is a server and client host built from one small fake per service, so a test runs without the game: registries (item reports, items with slots of their own, the loaded mods), players (who is online, their inventories by slot, what was given to them and shown on their action bar), level (blocks, block entities with item slots, fluid tanks and energy storage as the game answers them, and entities), commands and messages (what ran and what was said), and journal, which keeps the players and the level on each transaction and puts them back when it aborts. Set the world up through those fields, run the code under test with &mut host, then read the fakes back:

#![allow(unused)]
fn main() {
use mod_api::testing::{FakeHost, with_mods};
use mod_api::{self, ClientHost, ClientTickPreEvent, Hooks, ModEntry, dispatch_client_tick_pre};
use std::sync::{Arc, atomic::{AtomicUsize, Ordering}};

#[derive(Default)]
struct Counter(Arc<AtomicUsize>);
#[mod_api::horizon_mod]
impl Hooks for Counter {
    fn client_tick_pre(&self, _: &mut dyn ClientHost, _: &mut ClientTickPreEvent) {
        self.0.fetch_add(1, Ordering::Relaxed);
    }
}
let count = Arc::new(AtomicUsize::new(0));
let mods = [ModEntry::new("counter", Box::new(Counter(count.clone())))];
with_mods(mods.into(), || dispatch_client_tick_pre(&mut FakeHost::new()));
assert_eq!(count.load(Ordering::Relaxed), 1);
}

A test of item moves puts two chests in the level and reads back what went where; the transaction's journal is the fake's:

#![allow(unused)]
fn main() {
use mod_api::testing::FakeHost;
use mod_api::{HostTransaction, ITEMS, ItemResource, ItemStack, ServerHost, move_resources};

let mut host = FakeHost::new();
let level = host.level.level();
host.level.block_entity([0, 64, 0]).with_items(1);
host.level.block_entity([1, 64, 0]).with_items(1);
let stone = ItemStack { item: "minecraft:stone".into(), count: 5, damage: 0, extra: None };
host.level.block_entity([0, 64, 0]).items.as_mut().unwrap().slots[0] = Some(stone);

let server: &mut dyn ServerHost = &mut host;
let from = level.capability(server, &ITEMS, [0, 64, 0], None).unwrap();
let into = level.capability(server, &ITEMS, [1, 64, 0], None).unwrap();
let moved = server.transaction(|host, tx| {
    move_resources(host, &from, &into, |_: &ItemResource| true, 3, tx)
});
assert_eq!(moved.ok(), Some(3));
let into = host.level.block_entity_at([1, 64, 0]).unwrap().items.as_ref().unwrap();
assert_eq!(into.count("minecraft:stone"), 3);
}

A Host method no fake answers panics naming it, as every Host method defaults to, so an unexpected call fails loudly. A test can still write its own double for what the fakes don't model, implementing only what its hook calls; testing::FakeJournal gives such a host the same transaction journal over its own state.

If a Host method returns a game value a test can't build, a double can return None instead, as with Players::acting_player() -> Option<PlayerRef>. If the dispatch function itself takes an unconstructible value, such as dispatch_perform_command's &ServerPlayer, test the logic beneath the hook instead. The level hosts are small enough to implement whole. NeoForge's GameTest is the in-game counterpart.

Adding a hook

A new hook needs its declaration in the area file of crates/horizon-mod-vocab/src/hooks/ (/// lines and #[hook(side = "server", contract = "cancel", out = "cancel")] on fn name();; the argument list is in that folder's mod.rs), its native_* function with the placeholder body let _ = (every, parameter); (all its parameters, in order), and a run of cargo run -p xtask -- mod-api. Put that function in crates/horizon-client/src/game/native_hooks/<area>.rs, matching the declaration's area filename. Alternatively, a named hook may live in any logical module under game/, including area folders, mod.rs and #[path] modules. Its target must name that module and Game<'_>::native_<name>. Exactly one matching Game method must exist. Outside native_hooks/, the method must call dispatch_<name> itself: placeholders are not filled there. Remove macros, cfg and cfg_attr from that method so injections can patch it. A refusal names the hook and what to change. These checks run at build time and add no dispatch-time work. The build discovers native hook modules automatically; do not add a module list entry. Shared support lives in shared.rs; fire.rs includes generated dispatch entry points, not hook implementations. That command only replaces the placeholder in its owning area file with the dispatch_<hook> call; commit that source change and the declaration. Fills are idempotent; rerun the command after a failed write. The horizon-mod-api-gen build dependency generates the hook traits, Host methods and dispatch code into Cargo's OUT_DIR when the game and renderer build, including release builds. These generated Rust files are not kept in the source tree or committed, and no manual regeneration is needed after a merge. Every hook needs a mod_api entry: the build fails naming any hook without one. A hook that stands in for a method a vanilla block, block entity, item or menu overrides also gets a scope and a key ({"contract": "collect", "out": "output", "scope": "block", "key": "state"}): the key is a BlockState (routed by its prepared block key for block parts), an &ItemStack, a ModBlockEntity or an id &str, and the generator also puts the method on that scope's part trait (Parts). A body that is neither the placeholder nor already calls the dispatch is an error, and the generator never changes it. Hooks that are free functions outside Game are wired by hand: those crates can't see the mod list, so each calls a callback the executable installs once, such as NativeLoadHooks in horizon-world and NativeClientHooks in the client library (menu_slot_rules, living_breathe, ingredient). A build reports a placeholder still to fill instead of changing hand-written source; run cargo run -p xtask -- mod-api to fill it. Mixin injections go at the head of the body, before the dispatch, so they still run first.

Server attachments

A mod can declare an AttachmentType<T> once and call its get, set, remove, has and get_existing methods with the hook's host and a Holder. T is Clone + Send + Sync + 'static; get returns a clone and inserts the default on first access. has and get_existing read without inserting (NeoForge's hasData and getExistingData): has says whether a value is stored, saved or set, even before it decodes, and get_existing returns None instead of the default. Neither adds anything to the holder's saved data. set runs the optional codec before changing the value, returning an AttachmentError if it cannot encode. Keys are the pair (namespace, name); a key already decoded as another Rust type is an error. On a server, a missing holder is an error, not a new phantom holder; a client keeps a copy for any holder it is asked about (see the sync paragraph below). AttachmentError distinguishes MissingHolder, TypeMismatch, Decode, Encode, InvalidNamespace (a malformed saved compound), InvalidCopyPolicy (death-copy without a codec) and InvalidSyncPolicy (sync without a codec). Every host is an AttachmentStore (and host.attachments() gives it): the typed methods stand on its few erased ones, so they work the same in a native mod and a wasm mod. A value keeps its own Rust type, shared: reading one clones it out, and copying a holder's data copies no value.

AttachmentHolder keeps an existing typed handle when a system needs to store a holder identity: Entity, Player, BlockEntity, Chunk or Level. It is Copy, does not keep anything loaded, and follows each handle's ordinary stale-world and unload rules. Unlike the storage-relative Holder, it carries the handle's world scope.

Import AttachmentQueries to use host.query(&TYPE) on either host side. The returned batch is a snapshot of membership and stored value bindings in the host's current dimension. Its iter() borrows &T without cloning values and lazily decodes saved entries; malformed or incompatible values produce row errors, not defaults. Later sets, removals and unloads appear only in fresh queries. This is a residency view: an entity queued for removal but not yet swept can still appear. Captured handles do not keep holders loaded, and stale writes fail. Interior mutability inside T is not deep-frozen by a snapshot.

On a server, import AttachmentQueriesMut for host.query_mut(&TYPE) (T: Clone). Each borrowed row has holder(), value(), and explicit set(host, replacement) / remove(host) methods. These validate residency against the host's current dimension and then use the ordinary attachment journal. There is no &mut T, implicit transaction, or commit on drop: wrap a system in host.transaction for all-or-nothing writes. Unload or replacement produces AttachmentRowError::StaleHolder. Rows retain captured values even after successful write-through updates.

Traversal follows dense index order: insertion appends, and removal fills its slot with the last row. The same ordered mutation history is deterministic, but the order is neither sorted nor stable across structural changes. Retaining a batch can make the next live mutation copy row metadata and shared pointers once; retaining many batches keeps their old bindings alive. Native acquisition clones only the bucket Arc. Erased values from an incompatible type domain report TypeMismatch rather than falling back to saved decoding.

#![allow(unused)]
fn main() {
use mod_api::{AttachmentCodec, AttachmentError, AttachmentType, Compound, Holder, Tag};

static COUNTER: AttachmentType<i32> = AttachmentType {
    namespace: "example", name: "counter", default: || 0,
    codec: Some(AttachmentCodec {
        encode: |n| Ok([("count".into(), Tag::Int(*n))].into()),
        decode: |data: &Compound| match data.get(&"count".into()) {
            Some(Tag::Int(n)) => Ok(*n), _ => Err(AttachmentError::Decode),
        },
    }),
    copy_on_death: false,
    sync: false,
};
// Inside a server hook, or mixin code, with `host: &mut dyn ServerHost`:
if !COUNTER.has(host, Holder::Chunk([0, 0]))? { /* chunk carries no counter yet */ }
let old = COUNTER.get(host, Holder::Chunk([0, 0]))?;
COUNTER.set(host, Holder::Chunk([0, 0]), old + 1)?;
Ok::<(), AttachmentError>(())
}

For mixin code, see The typed API from mixin code.

Use Holder::Level, Holder::Chunk([x, z]), Holder::BlockEntity([x, y, z]), Holder::Entity(id), or Holder::Player(uuid). Entity IDs cover every entity in the level apart from players: mobs, items, experience orbs, projectiles, tridents, falling blocks, primed TNT, fireworks, boats, minecarts, armor stands, cushions, item frames, paintings, End crystals, the ender dragon, ominous item spawners and the server's OtherEntity records. An entity keeps its UUID through saves, so its saved attachments load back with it; an orb saved with a count above one loads as several orbs, and only the first keeps the attachments. Player UUIDs cover online guests and the acting player. Mutable level hosts can access their level, loaded chunks and block entities; read-only level hosts cannot access attachments. Unserialized values disappear on unload or restart. A saved value is stored under horizon:attachments, then the mod namespace and attachment name, as {value: <compound>, copy_on_death: 1b, sync: 1b} (a flag is omitted when false). Unknown mods' saved entries survive load and save without being decoded. Level attachments use the dimension's data/minecraft/horizon_attachments.dat SavedData file, not level.dat.

A type with sync: true sends its value to Horizon clients when get creates the default, on set and remove, and on demand with AttachmentType::sync_data(host, holder); changes made in one tick are sent once, at its end. The recipients are the players who track the holder: guests who were sent a chunk for a chunk or block entity, who track an entity for an entity, the player and those tracking them for a player, and everyone in the level for the level. A client also gets all of a holder's synced values when it starts seeing it: a chunk sent (with its block entities), a level joined or re-entered, an entity tracked, and its own player. With no native mod loaded nothing is sent. The wire form is the codec's compound, so sync requires a codec.

Only Horizon clients are sent values. A message with a namespace or name over 256 characters, a value that cannot be written or a payload past 1 MiB is not sent.

On a client, Host::attachments returns a copy of what the server synced, which get decodes; set and remove change only that copy, which is dropped when the chunk, entity or player goes. A client keeps no copy for the level hosts (LevelRandomHost): they stay server-only there, though their changes on a server are synced.

copy_on_death requires a codec. Player respawn copies opted-in entries via their encoded representation, including raw entries of an absent mod; other entries are removed. Living-entity conversion is not supported. This follows the default supplier, optional server-side persistence, player-respawn option and sync with a StreamCodec (with syncData) of NeoForge 26.3's public AttachmentType.Builder contract, and IAttachmentHolder's hasData/getExistingData, but not its holder-aware supplier, custom copy handler, conditional serialization, the sendToPlayer predicate, AttachmentSyncHandler partial updates or a wire codec apart from the disk codec.

The API owns these public names. Their engine-independent storage and query index live in horizon-mod-runtime, which depends only on the shared vocabulary in horizon-core; world and game storage use the same implementation.

Host exposes the documented Game helpers declared by area in crates/horizon-mod-vocab/src/host/<area>.rs, one trait per area; Host, ServerHost and ClientHost are the unions of those traits, so a mod calls through them and never names an area trait. Each helper keeps its receiver and documentation; a helper without self (config_files) takes &self in Host. Level services stay on the level hosts above. For the per-method comparison with NeoForge's block, item, entity, block-entity and fluid extension interfaces, including which gaps are tracked, see NeoForge extension coverage. For item registry facts, host.item_crafting_remainder(&stack) returns a count-one plain remainder stack, or None when there is none or the item is unknown. Horizon currently stores only the remainder item id, not a template's count or components. host.block_item(stone) returns its item registry name; an unavailable block mapping returns None, while a known block with no item returns Some("minecraft:air"). Both reads are available on either side.

For engine contributors, every area method declares its load access once with #[access(...)]. Metadata admits registry facts during setup and reload; WorldOnly, ClientSetup and PublishedResources retain their phase-specific contracts. The area's host_area attribute builds the test-double defaults, and the API generator emits every game forwarder at build time. Implement only the inherent Game function with the same name, not a hand-written forwarder. Static implementations use #[static_call]; service accessors use #[service]. A helper's receiver also exposes it on that handle. Receivers come from public *Ref structs in mod_api.rs: BlockEntityRef gives block_entity, for example. Adding a handle requires no separate generator registration. The following documented Game helpers are not in the trait yet:

  • spawn_mob, mob, mobs, block_in_tag, item_in_tag, server_block_in_tag, server_item_in_tag, server_block, set_display, sidebar, experience_orbs, item_entities, player, panel_recipes, recipes, component, set_component, remove_component, spawn_falling_block, explode, container_slots uses gameplay-private types.
  • with_mod_block_entity is generic, so its signature cannot be called through dyn Host.

Experience-orb values use vanilla's signed i32 representation in ExperienceOrb, ServerHost::set_experience_orb, and the pickup and merge hooks. Reading or changing a value preserves zero and negative saved values rather than clamping them through an unsigned representation. A negative value does not grant positive experience when picked up.

Advancement triggers instead live on PlayerRef::trigger_criterion, with a mod-owned CriterionTest and the game's scoped CriterionContext resource. The context keeps real world-aware item predicates; it is not a conditions-only snapshot. See advancement triggers for the contract and the wasm runtime's current same-group callback limitation.

A mod that consumes a block use can run vanilla advancement predicates and rewards through PlayerRef::trigger_block_use(host, pos) or PlayerRef::trigger_item_used_on_block(host, pos, original, after). These server operations use the player's own level and never read inventory. Default use fires default_block_use, then any_block_use, both without a tool: the default helper is specifically tool-free, not general default block use while holding an item. Item use fires item_used_on_block against original (copied after a before-block PASS and before block/item effects), then any_block_use against after (that original hand stack mutated in place, excluding a replacement swapped into the hand), in that order. Consuming right-click/before-block cancellation already fires automatic any_block_use only; do not call either pair helper for it, which would duplicate any and add an inner trigger. This follows ServerPlayerGameMode.useItemOn and ServerGamePacketListenerImpl.handleUseItemOn; the predicate evaluators are DefaultBlockInteractionTrigger, ItemUsedOnLocationTrigger, and AnyBlockInteractionTrigger.

Ok(()) means the player was reached, even if no criterion matched. Failures are NotServer for a remote client, PlayerUnavailable for a stale, offline or displaced handle, UnloadedChunk for a position outside loaded chunks, and Stack(StackWriteError) when the item boundary rejects either stack before any trigger fires. Native and wasm use the same generated operations. The minecraft: guard on trigger_criterion remains: this grants no general access to fire arbitrary vanilla triggers.

The following catalog hooks are not in the trait:

  • player_command, item_decorations, client_resources_reloaded, camera_shake, screen_key_pressed_pre, screen_char_typed_pre, screen_mouse_scrolled_pre, use_duration, use_animation, can_eat, living_entity_use_item_start, living_entity_use_item_finish, render_hand, shader_params and block_highlight.
  • biome_colors, camera_distance, compute_fog_color, compute_fov, data_pack_files, mob_submit, mouse_button_pre, mouse_button_post, player_respawned and tags_updated.

The client render_tooltip_texture hook (NeoForge's RenderTooltipEvent.Texture) picks a tooltip box's look. Setting texture to a tooltip style <namespace>:<name> draws the GUI sprites <namespace>:tooltip/<name>_background and _frame from the resource packs, nine-sliced by their gui.scaling metadata or stretched without it, as vanilla draws an item's tooltip_style; the atlas a resource reload restitches is the one drawn. Left None, the box keeps Horizon's solid fills, whose background and top and bottom border gradient colours it changes as mutable ARGB u32 values; it does not change text colours. It covers solid-fill item tooltips (including creative, recipe book, chat, trading and bundle-selected item names), screen text boxes, and native panel tooltips. The stack is None for text-only boxes, including recipe-book item lines where the stack is discarded before drawing. Sprite-based screen tooltips (including options and title screens) use a separate renderer and are not covered.

Tooltip component geometry

render_tooltip_gather_components preserves the mixed text/image list, including an image inserted immediately after the name. Wrapping splits text in place. The text pass visits the entire list before the image pass starts; both passes advance through the same component heights, with 2 extra pixels after the first component of an item tooltip. Each image callback receives the box's left edge and its own running top, but the whole content box size, not TooltipImage.size. That field is the component measurement used to reserve space. The box width is the widest component; its height is the sum of heights, minus 2 normally, with no subtraction for item tooltips containing multiple components. Text components measure 10 pixels high. Factory registration and selectable fonts are separate capabilities, not provided by this geometry contract.

Camera and collision reads

ClientHost::is_paused() reads the applied client pause state, not whether a pause screen is open. A multiplayer game continues running while its pause screen is open. The pause-change pre hook sees the old state; the post hook sees the new state.

ClientHost::playing() is true while the player is in the world with nothing over it: no screen, title, sign editor or book, so keys and zoom apply. ClientHost::mapping(name) reads a vanilla key mapping (key.forward, key.left, key.back, key.right, key.jump, key.sneak, key.sprint, key.attack, key.use) as a KeyMapping: whether it is held (KeyMapping.isDown) and the label of the key it is bound to, as Controls shows it. It is None for any other name. Both read without &mut, so the HUD and screen hooks can ask them.

ClientHost::clipboard_text() reads the system clipboard (KeyboardHandler.getClipboard, empty text when it holds none) and ClientHost::set_clipboard_text(text) writes it (setClipboard; empty text copies nothing). Both work only while the game handles a key, mouse or typed-text event, from the window or from the control socket (horizon-ctl key, mouse, type and its clicks): a key, mouse or screen hook that fires inside one, such as Ctrl+V into a mod's own field. At any other moment (a tick, a frame, a packet) the read is None and the write is false with nothing copied, so no mod reaches the clipboard without the player doing something. Both are None and false on a server, and take &self, so a screen hook can call them. Vanilla's KeyboardHandler has no such rule: Horizon adds it.

The camera() helper returns the most recently extracted world frame's camera, or none before one exists. Its position, interpolated eye height, partial tick, angles, final FOV, near/far planes and dimension accompany column-major view and projection matrices. The view is camera-relative and includes bob and spin; projection uses reversed Z. Earlier callbacks in a frame can see the previous frame. block_collision(pos) returns block-local [min_x, min_y, min_z, max_x, max_y, max_z] boxes with an empty collision context; unloaded blocks and collision errors give no boxes.

ClientHost::set_camera_type(type) is a queued client-only effect and takes &self, so shared callbacks such as camera_pose can request a perspective. camera_type() reports the latest request immediately; several requests coalesce to the last one. Requests before or during camera_pose apply before that frame's final camera, FOV and spectator post effects. The pose hook runs only once: its explicit position or angle overrides survive a refreshed vanilla camera. Requests from later render callbacks apply at the next frame's extraction. Setting a type never emits camera_type_cycle; F5 alone cycles, and its final event choice wins over requests made inside the cycle callback.

ClientHost::crosshair_target() returns the ordinary interaction-range target as CrosshairTarget::Block { pos, state, hit } or CrosshairTarget::Entity { entity, point }. The block hit contains its world-space location, face and inside flag; the entity point is also in world coordinates. It uses the game's current targeting ray and block/entity reach, respects blocks occluding entities, and returns None on a miss or without a client world. It can be read with a screen open. entity is an EntityRef carrying the current client level's epoch and network entity ID, including dragon parts. It becomes stale when the client level is replaced or the client disconnects; a same-level respawn keeps its epoch. This supplies identity, not client entity reads: the server-only entity data methods still reject client handles. Unlike view_hit and raycast, this does not find blocks in distant terrain or return a block behind a targeted entity.

Combat targeting

ClientHost::targeting() and set_targeting(ClientTargeting) control liquid picking, entity occlusion and an optional crosshair entity override. Defaults preserve ordinary vanilla targeting. Set pick_liquids to include source and flowing fluid shapes, or hit_through_walls to ignore block occlusion for entity picks and attacks. An entity override must name a live entity in this client level; stale or self handles make the setter return false without changing the controls. The setter requires a logged-in client world. An override is rechecked for reach, held-item range and occlusion when used and disappears from reads after its level is replaced.

Reach remains attribute-backed: use the server player's living handle and set_attribute_base(host, "minecraft:block_interaction_range", value) or "minecraft:entity_interaction_range". The client's cached reach follows the server's attribute updates. Client targeting controls do not grant extra server reach.

client_attack_target receives a mutable targets list before an ordinary melee click (spear jabs use their existing path). It starts with the crosshair entity. Replace it for redirection, append for multiple hits, or clear it to suppress an entity attack. Flow::Cancel cancels the batch. There is no batch cap; duplicates are sent once in first-occurrence order. Each target is checked again against live handles, attackability, reach, held-item range and the occlusion control. Clearing an entity hit does not mine the block behind it; an empty list on a block or miss keeps the ordinary path. Each accepted hit uses normal attack packets and prediction. Server validation and cooldowns still apply. Native and Wasm mods use the same typed API. Conditional, numeric and categorical item model properties and item tint sources receive an API-owned ItemStack snapshot. The engine's resolver keeps its own StackView; that engine type is not part of mod_api. This applies to nested bundle contents as well as the outer item being drawn.

HookSnapshotRuntime selectionAnswer
item_model_propertyevent.stack: &ItemStackevent.bundle_selected: i32event.value: bool
item_model_range_propertyevent.stack: &ItemStackevent.bundle_selected: i32event.value: f32
item_model_select_propertyevent.stack: &ItemStackevent.bundle_selected: i32event.value: &mut Option<String>
item_tint_sourceevent.stack: &ItemStackevent.bundle_selected: i32event.color: u32

bundle_selected is the runtime bundle selection index: -1 means none. It is not a saved component and is deliberately separate from the stack's component patch. The patch contains explicit overrides and removals, not merged defaults. The host is &dyn ClientHost for each callback. Only answer property names or sources the mod owns; unanswered callbacks retain their existing defaults.

ItemTintSourceEvent.config is a borrowed Tag, converted once from the tint's JSON definition at load. Read fields with Tag::get and match tag variants. Invalid payloads refuse model loading; resolving a tint does not convert or clone its payload.

Former resolver-view accessOwned API access
event.stack.itemevent.stack.item() returns ItemKey; retain keys for comparisons
event.stack.countevent.stack.count()
event.stack.patchevent.stack.components() returns an optional owned NBT patch; use event.stack.get(&descriptor) for a typed component
event.stack.bundle_selectedevent.bundle_selected

A native or wasm listener sees the same owned values. Snapshot construction occurs only after the subscription check, so a model resolution with no listeners does not allocate or encode a snapshot. An empty or unresolved resolver view does not dispatch an invalid stack; its property or tint retains the unanswered default.

ItemStack is the mod API's own nonempty value, not an engine inventory slot or a borrowed rendering view. It contains an ItemKey, a nonzero count, an optional numeric damage override and a lazily allocated component patch. Plain stacks allocate no component storage. An empty slot or a consumed stack is None; it is never a zero-count ItemStack.

Every loaded item, vanilla or modded, has an ItemKey. Resolve identifiers through Host::item_key once when loading data and retain the key. Forward references requested during registration keep their slots when the load completes; unresolved references refuse the load. Keys are process-local identities, not persistent names. Tags likewise have retained TagKey identities, while their item and fluid membership is read through Host::tags and changes on reload. Unknown tags are empty; unavailable authoritative mappings return None.

Stored values and effective components

Count and component edits do not consult the game. new refuses zero; set_count and grow refuse invalid or overflowing counts without changing the original value. Consuming split and shrink return optional portions so consuming the last item needs no empty sentinel.

The damage representation has three states:

Stored stateEffective meaning
No numeric override and no removalInherit the item's prototype, including its default damage of zero when present. A new plain sword needs no Components allocation.
Some(0)An explicitly present zero-damage component, even on an item without a damage default. set_damage(0) creates this state without allocating a patch.
No numeric override and !minecraft:damage in the patchThe component is explicitly removed, including an inherited default. clear_damage creates this state.

damage() returns the stored number or zero. damage_component() distinguishes an explicit zero from no numeric override. set_damage(u32) is infallible; the game validates its effective maximum when accepting a write. Adding only a maximum-damage override to an item does not invent a damage component: explicitly adding Some(0) is what makes that component present.

All numeric damage lives in the optional field. Raw damage entries normalize out of the component patch; malformed numbers, conflicting numeric values, or a simultaneous value and removal refuse the entire replacement. Generic component setters and raw set_components therefore return Result. A refusal leaves the stack unchanged. Typed damage setters do not need an error path.

ItemResource::with_component is also fallible for raw component values. Its default-value elision and without_component preserve the normal prototype-plus-patch semantics. The latter records real NBT removal entries for default components; it is not an unknown-value fallback.

Game boundaries

A consume or death-protection override whose opaque native effect payload cannot be decoded refuses the whole stack write with StackWriteError::InvalidEffectPayload, before any resident state changes. The engine's infallible preserved-component setter remains infallible: invalid replacements retain its prior raw component and decoded cache rather than erasing them.

The items host area owns metadata reads such as max_stack_size(ItemKey) and max_damage(ItemKey). A stack does not take a host just to read a maximum, and an unavailable maximum never turns into a fabricated limit of 64.

The game validates effective count and durability limits, including component overrides, when accepting a resident write. An effective max_stack_size must be in 1..=99, and the presence of max_damage forbids a stack limit above one, even when the current count is one. Removing max_stack_size uses the absent limit of one. Invalid values produce typed errors, not clamped values. A transfer request is different: ItemResource plus a separate amount can request or return more than one resident stack's capacity. The resident writes it produces still have to pass validation.

Game inventory, menus, trades and models keep their engine-owned representations. Native hooks and host methods exchange owned API snapshots. The game converts only after a listener guard, and stages every mutable result before committing any captured output. Invalid hook edits retain those captured engine values. Consuming mutable events such as bonemeal and item_after_vertical_collision lend Option<ItemStack> so a listener can consume the value by setting None.

The item_stacked_on_other bridge commits its clicked slot, cursor and cancellation flags as one unit, including the menu's slot-operation queues and map-id reservations. The block-entity tick, menu-button and menu-quick-move bridges validate their captured snapshot outputs together before applying them. These are output guarantees, not whole-world transactions: independent ServerHost storage writes, commands, messages and other effects are not rolled back on a refused output. Carrier-form callbacks such as bonemeal and item_after_vertical_collision also retain their captured stack outputs on refusal but do not journal independent host writes. The storage-journal follow-up covers that gap. Restricted in-write LevelHost callbacks keep their existing write and random-draw order.

Item-model callbacks receive the owned stack and a separate bundle_selected number (-1 means none). Runtime selection is not a persistent component. Drawing receives the API-owned ItemDisplayContext, not the renderer's enum.

The NBT escape

ItemStack::from_nbt(host, compound) and to_nbt(host) are the NBT escape. They live on the plain API value, not as a second engine implementation in the items host area. They use only typed host key lookup and canonical id metadata, so native and WebAssembly mods execute the same parsing and serialization code. Persistent NBT contains identifiers, never process-local key slots. Unknown ids, malformed values and zero counts fail decoding; unavailable id metadata fails encoding.

The escape preserves explicit zero damage, default-component removals and opaque component NBT. It does not apply resident maxima or materialize prototype defaults. Those remain the responsibility of the metadata service and game write boundary respectively.

A server mod asks for a call back after some server ticks with schedule_task, the stand-in for vanilla's MinecraftServer.tell(new TickTask(getTickCount() + delay, runnable)). A closure cannot cross into a wasm mod or outlive the host a hook borrows, so the callback is a hook: the mod passes a u64 token and Hooks::scheduled_task hands it back.

const REMINDER: u64 = 1;

impl Hooks for MyMod {
    fn player_logged_in(&self, host: &mut dyn ServerHost, player: u128, name: &str) {
        // 20 server ticks from now: one second at the normal rate.
        if let Ok(id) = host.schedule_task(20, REMINDER) {
            // Keep `id` if the reminder may need to be withdrawn: host.cancel_task(id).
        }
    }

    fn scheduled_task(&self, host: &mut dyn ServerHost, id: TaskId, owner: &str, token: u64) {
        if owner != "mymod" || token != REMINDER {
            return; // every mod is asked about every task
        }
        // The task has fired and its id is spent.
    }
}
CallMeaning
host.schedule_task(delay_ticks, token) -> Result<TaskId, ScheduleError>A task due delay_ticks server ticks from now. 0 and 1 both mean the next tick.
host.cancel_task(id) -> boolWithdraws a task. True only if it is yours and still waiting.
Hooks::scheduled_task(host, id, owner, token)The callback, on the server thread.

Timing. Delays count ticks the server runs: a paused integrated server does not advance them. A task fires at the start of its due tick, right after server_tick's start phase, in the order the tasks were scheduled. A task never fires in the tick that scheduled it, whichever hook scheduled it, the tick's start hook and scheduled_task itself included: it fires on a later tick.

Ownership. The task belongs to the mod whose hook called schedule_task; the game takes that from the running hook, never from an argument. Outside a mod's hook the call returns ScheduleError::NotInMod. cancel_task answers false for another mod's id, an unknown one, and one that has fired or been cancelled. Every mod hears scheduled_task for every due task, so a mod compares owner with its own id and ignores the rest. Ids are never reused in a world.

Limits. A mod may have MAX_PENDING_TASKS (4096) tasks waiting; the next schedule_task returns ScheduleError::TooMany until one fires or is cancelled. A task that panics does not drop the others due on the same tick: they still run, and the panic goes on afterwards as any hook's does.

Lifetime. The queue belongs to the running world. It is dropped when the world opens or closes (including a failed open or close), and is never saved. A mod that must outlive a restart writes its deadline to its own storage and schedules again when the world opens. A mod's tasks are not cancelled when the mod unloads; the world's close is the end of them.

EntityRef::extinguish_fire(host) exposes vanilla Entity.extinguishFire on players, guests, mobs, armor stands and dropped items. If the entity is burning, it first emits minecraft:entity.generic.extinguish_fire at its feet with volume 0.7 and vanilla's pitch from two ordered random float draws. It then clears positive remaining fire ticks, preserving a negative fire-immunity countdown. Fire-immune mob kinds and dropped stacks resistant to in-fire damage do not count as burning, even when their timer is positive.

The operation reuses the engine's mob sound outbox and entity random, and the player/armor-stand cosmetic random and captured sound route. Dropped items use their own random. Sounds use the entity's sound source, including Players for a guest rather than a generic sound category. It does not draw pitch randomness for a non-burning entity. It returns false for stale or unsupported handles.

EntityRef::clear_fire(host) is the distinct vanilla Entity.clearFire operation: it is silent, draws no random, and preserves the same negative countdown. Choose it only when the original behavior explicitly calls clearFire, rather than using it as a substitute for extinguishing.