Основа — Horizon (MIT). История апстрима не переносится: этот репозиторий ведёт свою.
44 KiB
Horizon mods
Horizon gets its own mod system, written in Rust and run in a sandbox, plus two ways to bring over what Java players use: native Horizon ports of popular mods and the data-driven parts of packs and mods. Fabric and NeoForge jars do not run as they are: their code calls and patches Minecraft's Java classes, and Horizon has none. What carries over is the data and assets inside a jar (modpacks.md).
Mods are off and absent by default. A fresh install has no horizon-mods
folder, loads no runtime and plays exactly like vanilla 26.3.
| Part | Where | State |
|---|---|---|
| The contract (WIT) | crates/horizon-mod-sdk/wit/horizon.wit |
0.1: log, game-read, chat, hud, storage, command, ui; tick, frame, chat and command hooks |
| SDK for mod authors | crates/horizon-mod-sdk |
0.1 |
| Example mods | crates/horizon-mod-sdk/examples/coords-hud, examples/predator-missile |
coordinates, facing and time HUD, /where, /coords; /missile, a guided missile from vanilla commands |
| Horizon's official mods | mods/official/ |
the extras moving to the registry: FPS Counter (fps-overlay), Keystrokes, Zoom, Minimap; Far View (far-view), distant terrain; and Recipes, a recipe viewer |
| Runtime | crates/horizon-mods |
wasmtime 48 LTS, component model |
| Authoring tool | crates/cargo-horizon-mod |
cargo horizon-mod new [--template code|content] / build / run / test / check, and the registry's CI (registry build / review / list) |
| In the game | crates/horizon-client/src/game/mods.rs, Options > Mods |
client mods |
| Launcher mod browser (Modrinth, CurseForge with a key) | mod-browser.md | resource packs, modpacks |
Registry index (index.json, .hpack), signatures |
horizon_mods::index, api/mod-index.md |
schema 1; launcher and website next |
| Server mods, registries | this page, Roadmap | planned |
Design goals
- Safe to install. A mod is untrusted code. It runs in a WebAssembly sandbox with no file, network, process or clock access, a memory cap and a time budget per call. It can only do what its capabilities grant, and the player sees those before turning it on.
- One file for every platform. The same
.hmodruns on Windows, macOS and Linux, x86-64 and ARM: authors build once, players never meet "wrong build for my OS". - Free when unused. No mod on: no engine, no thread, no compile, one branch per hook. Measured below.
- Vanilla stays vanilla. Mods add; they cannot patch the game's code the way Mixins do. What they may change is a list of hooks the game calls at fixed points, so the parity harnesses keep testing the same game.
- Stable for authors. The contract is a versioned WIT package; a mod built for API 0.1 keeps loading on later 0.1.x games.
How it works
mod crate (Rust) Horizon
─────────────── ───────
impl Mod for MyMod { ... } horizon-mods::Mods
export_mod!(MyMod) ├─ Engine (cranelift), made on first mod
│ cargo horizon-mod build ├─ one Store per mod: 64 MiB, fuel per call
▼ ├─ Linker: log, game, chat, hud (WIT imports)
my_mod.hmod = zip { └─ calls the events export: init, tick,
horizon-mod.toml, frame, command, chat
mod.wasm (a WebAssembly component)
}
- WebAssembly components. Mods compile to
wasm32-unknown-unknown;cargo horizon-mod buildwraps the module into a component for thehorizon:mods/horizon-modworld. The WIT file is the whole API: host functions the mod imports (log,game,chat,hud) and theeventsinterface it exports. No WASI: the component cannot even name a file or a socket. - Capabilities are imports. Every imported interface except
logis a capability. The manifest declares them; the game reads the component's actual imports and refuses a mod whose code imports one its manifest does not declare (uses capability chat, missing from horizon-mod.toml). So the list the player reads is the truth, not a promise. - Hooks on request.
initreturns aregistration: the commands the mod owns and which oftick,frameandchatit wants. The game calls only those. A mod that wants nothing per frame costs nothing per frame. - Retained HUD. A mod sets its panel (corner and coloured lines) when it changes; the game draws it every frame with the extras' overlay code. Most HUD mods need no frame hook at all.
- Snapshots, not callbacks into the world. Once per tick (and before a command) the game copies what mods may read (player position, look, health, food, dimension, world clock) into a snapshot shared by all mods. Mods never hold references into the game, so they cannot stall or corrupt it.
What a mod can do (API 0.1)
| Interface | Capability | Functions |
|---|---|---|
log |
always | write(level, message), in the game log as [mod <id>] |
game |
game-read |
get-player(): x y z, yaw, pitch, health, food, dimension; get-clock(): game time, day time; raycast(distance): the first block along the view (fluids pass), up to 1024 blocks and across the distant terrain, with the point hit and the block's id |
chat |
chat |
show(text): a line only the local player sees, after the mod's name (8 per call) |
hud |
hud |
set-panel(corner, lines), clear(): up to 16 lines of 256 characters |
storage |
storage |
get(key), set(key, value), delete(key): a per-mod folder of up to 64 files, 4 MiB total, at most 1 MiB written per call |
command |
command |
run(line): a vanilla command run as the player, with the player's permissions, after the hook returns (8 per call); mods' own commands are not run. run-quiet(lines): up to 256 a call, the same rights, nothing in the chat (single player and Horizon's servers run them 64 a tick and log one line a batch; elsewhere they go as chat commands and their feedback shows) |
ui |
ui |
open-screen, close-screen, set-settings: a screen the mod owns and a page in Options > Mods, of text, button, item and list widgets the game lays out and draws with its own menus' code; browser.set-items, browser.poll: a recipe viewer's panel of items right of the inventory and container screens (not the creative one), which the game pages and draws with its own slots and search box, and the mod hears its clicks, its searches and the mod's own keys (input.register-key) pressed over any item on the screen; browser.set-recipes: recipes in the panel's place (crafting grids, cooking, stonecutting, smithing and brewing layouts, their slots cycling through their stacks, a note and an action button each), paged by the game, with clicks on their slots, action buttons, page turns and a back button. Experimental: the manifest needs experimental = ["ui"] |
render |
render |
set-hud(shapes): up to 1024 fills, four-cornered shapes, GUI sprites, items and text, kept on the HUD (over the hotbar, under chat and screens, hidden with F1) until replaced; set-corner(corner, w, h, shapes): a box the game places in a corner, stacked with its overlays and other panels, as hud's panels are; text-width(text); frame-stats(): the game's FPS, min and 1% low; gui-size(); shake(strength, seconds): the view turns, the player doesn't, scaled by Distortion Effects; set-zoom(zoom): the field of view divided by up to 10, eased or at once, with slower turning, while playing and not through a spyglass (the strongest of several mods' zooms counts); set-minimap(minimap): a map of the world around the player in a corner, as a map item colours it (the floor underground and under roofs), square or round, turning with the player or north up, with the player's arrow; the game keeps its tiles within its own frame budget (one map at a time, the first loaded mod's); set-far-terrain(far-terrain): the world past the render distance as distant terrain, out to 64 to 1024 chunks, built by the game's own workers (the player's LOD Distance, when on, wins; the first loaded mod's). The game's own HUD renderer draws them: a mod never supplies shader code. Experimental: experimental = ["render"] |
input |
input |
claim-items(ids): the use button on those items is the mod's (use-start, use-stop with the ticks held; the game doesn't place, eat or swing); register-key(name, label, key): up to 16 keys (key.keyboard.r), heard while playing as key-down / key-up (the game's own mapping on that key still runs; a screen opening lets held keys up), mouse buttons as key.mouse.4. The player rebinds them on the mod's page in Options > Mods, where the game lists them first as Key Binds rows (click, then press a key or mouse button; Esc binds none), even for a mod without ui; the choice is kept in horizon-mods.txt (key.<mod>.<name>:key.keyboard.z) and wins over register-key's default; claim-wheel(on): the wheel's steps become wheel events and the hotbar doesn't move (the one input a mod takes from the game; a mod's keys never do); held(); mapping(name): a movement or click mapping's key label and whether it is held; clicks-per-second(); poll() returns the events, at most 256 kept. Experimental: experimental = ["input"] |
recipes |
recipes |
items(): every item in the registry's order, with its name in the player's language and its tags; all(): every recipe (crafting, smelting, blasting, smoking, campfire, stonecutting, smithing, brewing), its slots as the stacks that fit each, its results, cooking time and experience, and the recipe book's id for it; known(): the book ids the player's recipe book holds; place(book-id, all): the recipe book's click on a recipe, in the open inventory's or crafting table's grid (refused for a recipe the player's book doesn't hold, as vanilla's server refuses it); give(stack): in creative mode, the stack into the player's inventory, as from the creative inventory. The recipes are the world's in single player and on Horizon's servers, vanilla 26.3's on any other server (a client is sent no others), while known follows that server's book. The game makes the tables once, when a mod first reads them. Experimental: experimental = ["recipes"] |
| Hook (export) | When |
|---|---|
init() -> result<registration, string> |
once, after loading; an error keeps the mod off and is shown |
tick(tick) |
each game tick while in a world (20 per second) |
frame(seconds) |
each rendered frame |
command(name, args) -> result<option<string>, string> |
/name args typed in chat, for a command the mod registered |
chat(text) |
each chat line the player receives, as plain text |
Commands: vanilla's command names win. A mod asking for /give does not get
it (logged), and two mods cannot share a name (the first one loaded keeps it).
The chat box colours and Tab-completes a mod's commands like vanilla's, for
every player whatever their permission level, and takes any text after the
name (the mod reads it as args; API 0.1 has no way to declare argument types).
Planned interfaces
Each is a new capability, added in a 0.x minor version so older mods keep loading. Ordered by what the first native ports need:
| Interface | For | Notes |
|---|---|---|
keys |
keybinds | registered binds appear in Controls; press/release events |
target |
the crosshair's block or entity, with its state and NBT summary | Jade/WTHIT-style tooltips |
inventory-read |
the player's inventory, held item, open container | AppleSkin, recipe viewers |
world-read |
blocks and biomes around the player, within the render distance | minimaps, schematics |
render (world) |
world-space lines, boxes and billboards, beside the HUD shapes render has |
Litematica-style placement, waypoints |
registries |
new blocks, items, entities and recipes, mostly data: JSON models, textures, loot and recipes in the .hmod's assets/ and data/ |
server side; see Multiplayer |
server |
world events (entity spawn/death), game rules, world read/write, typed server commands | after server mods 0.3's players, chat, blocks and commands |
net |
a mod's own client↔server channel (custom payloads) | only between the two halves of the same mod |
There is no file, socket, process or raw-input interface, on purpose.
Choosing the runtime
| Option | Sandbox | One file for all OSes | Typed API | Cost | Verdict |
|---|---|---|---|---|---|
| wasmtime + component model | yes: memory, fuel, no imports but ours | yes | WIT → Rust on both sides | 85 ns per empty hook call; 50 ms to compile a 64 KB mod on load | chosen |
| wasmtime, core modules with a hand ABI | yes | yes | no: pointers and lengths by hand | a few ns less per call | not worth the fragile ABI |
| wasmi (interpreter) | yes | yes | no components | no compile time, execution ~10-50x slower than cranelift code | kept in mind for a platform without JIT |
| Lua (mlua) or another script | partial (strip the stdlib, hope) | yes | no | fast to write | not Rust; weak sandbox |
native cdylib plugins |
none | no: per OS and arch, unstable Rust ABI | yes | none | unsafe for players |
| an embedded JVM | none per mod | yes | Java | 100+ MB, seconds of startup | see the research |
wasmtime 48 is a long-term-support line (security fixes until August 2028)
and needs Rust 1.95. It reads WebAssembly with the same wasm-tools release
(0.254) as the wit-component that packs mods and the SDK's wit-bindgen, so
the build carries one copy of them. Only its runtime, cranelift,
component-model and std features are on: no WASI, no async, no GC
proposal, no profiling. Its cranelift backend generates native code for x86-64
and ARM64 on Windows, macOS and Linux, which is why one .hmod serves them
all.
The game links it only with its mods feature: every release build turns it
on (release.yml), and a build from source needs
cargo build --release -p horizon-client --features mods to run code mods.
Without it (the tests and probes) wasmtime and cranelift aren't built, a
quarter of a clean build; content mods still load, and the game logs why the
code mods don't start.
Performance
Measured on the Mac mini (M4 Pro), release build, cargo test -p horizon-mods --features runtime,pack --release --test sandbox -- --ignored --nocapture:
| per call | |
|---|---|
| tick with no mod loaded | 1.2 ns (one check) |
empty tick hook (call into the sandbox and back, fuel on) |
85.5 ns |
empty frame hook |
87.5 ns |
coords-hud's tick (reads the player and clock, formats 3 lines, compares) |
2.3 µs |
loading coords-hud (read, compile with cranelift, instantiate, init) |
50 ms, once |
In the game, same world and view, horizon-ctl bench 10 three times each
(4 other sessions shared the machine, so the tails are noisy; the median is
the stable figure):
| median frame | mean FPS | |
|---|---|---|
no horizon-mods folder |
2.62-2.71 ms | 149-163 |
| coords-hud on | 2.72-2.78 ms | 151-254 |
At 20 ticks per second coords-hud costs 46 µs per second of game time: well under 0.01 ms per frame. The frame-time medians differ by less than the run-to-run spread.
Budgets. Each call gets fuel (about one unit per WebAssembly instruction):
2 billion for init, 50 million for tick, 10 million for frame, 100
million for a command or chat line. A runaway loop ends in a fraction of a
second (the sandbox test's endless /spin is cut well within its 5 s limit)
instead of hanging the game. The target budgets a mod should stay under are
tighter: 1 ms per tick and 0.1 ms per frame. The runtime keeps each mod's
average call time (Entry::cost); the Mods page will show it, and a mod over
budget for long will be flagged, then paused.
Crash isolation
A trap (panic, out-of-bounds access, unreachable), running out of fuel or
hitting the 64 MiB memory cap stops that mod only. The game says so in
chat (Mod Test Guest stopped: it took too long (an endless loop?)), marks it
on the Mods page and carries on; the other mods keep running. Host functions
clip what a mod hands back (chat lines per call, HUD lines and lengths). Tests:
crates/horizon-mods/tests/sandbox.rs (an endless loop, a panic and a 1 GiB
allocation, next to a healthy mod; cargo test -p horizon-mods --features runtime,pack).
Playing with mods
- Put
.hmodfiles (or mod folders holdinghorizon-mod.tomlandmod.wasm) inhorizon-mods/besideoptions.txt(in the launcher's game folder). - Options > Mods lists them with what each may do ("May see your
position, health and the time, draw text on your screen, ...") and a
switch. They start off; the choice is kept in
horizon-mods.txt(mod.<id>:true). - A mod that failed to load or stopped shows why, in red, on its row.
Two turned-on mods never share an id while running (it is also capability
storage's directory): the one find lists first starts, the other stays
off with the reason on its row.
Benchmarks (--benchmark) never load mods.
Making a mod
rustup target add wasm32-unknown-unknown
cargo install --git https://github.com/horizon-rust/horizon cargo-horizon-mod
cargo horizon-mod new my-mod # Cargo.toml, horizon-mod.toml, src/lib.rs, tests/hello.toml
# (--template content: a content mod, no code)
cd my-mod
cargo horizon-mod run # build, then start the game with it
cargo horizon-mod build # again after an edit: the running game reloads it
cargo horizon-mod test # build, then run tests/*.toml with no game
run starts horizon-client (on PATH, or --game PATH, or
HORIZON_CLIENT) with --dev-mod target/horizon-mod/<id>.hmod. A dev mod is
always on, and the game checks its file once a second: rebuild and it restarts
with "Mods reloaded" in chat. Its in-memory state starts over; whatever it
saved with capability storage survives the reload.
Before you submit it to the registry, run what its CI runs:
cargo horizon-mod check target/horizon-mod/my_mod.hmod # --json for the report as JSON
It reads the manifest and content, refuses imports the capabilities don't
cover, then plays the mod in the sandbox for 40 ticks, 120 frames, a chat line
and each of its commands: it must start and never trap or overrun. It checks a
.hpack and a resource pack .zip too, and a signature with --key <public key> --signature <file>.minisig.
Testing a mod
cargo horizon-mod test builds the mod, then runs each tests/<name>.toml in
the sandbox the game uses, with no game and no window: the test plays the
game's part (the player and the clock the mod reads, ticks, frames, chat lines,
commands; for a server mod, players joining, chatting, typing commands,
breaking and placing blocks and leaving) and checks what the mod showed. A script fails at its first wrong
step, with what it expected and what it got; the command fails if any script
does. --debug builds without optimizing, --hmod <file> tests a .hmod
already built, and a name runs only the scripts whose path holds it. Each
script gets a scratch copy of the mod, so its storage starts empty and is
removed afterwards.
# tests/panel.toml (examples/coords-hud)
[player] # where the script starts; unset fields keep the defaults
x = 12.5 # (0.5 64 0.5, yaw 0, 20 health and food, the Overworld)
z = -3.25
yaw = 180.0
[clock] # day-time 0 is 6:00 on day 1; each tick adds one
day-time = 29999
[[step]]
ticks = 1
expect.panel = ["XYZ 12.5 / 64.0 / -3.2", "Facing North (-Z)", "Day 2, 12:00"]
expect.corner = "top-left"
[[step]]
command = "coords"
expect.feedback = "Coordinates panel hidden"
A step's parts happen in this order:
| Key | Does |
|---|---|
reload = true |
stops the mod and starts it again, as a rebuild does in the game: its memory starts over, its storage stays |
title-screen = true |
no player and no clock until a step sets them |
blocks = [{ x = 0, y = 63, z = 0, state = "minecraft:stone" }] |
server mods with world: those blocks of the Overworld, before the rest of the step (every other block is air, every biome the dimension's plainest: plains, nether wastes, the end) |
player = { x = 3.0, ... }, clock = { day-time = 0 } |
changes those fields (x y z yaw pitch health food dimension, game-time day-time) |
ticks = n, frames = n |
that many ticks (the game's tick counter and the clock move on), then frames 1/60 s apart |
chat = "<Steve> hi" |
a chat line the player receives |
command = "where north" |
the player types /where north; a command the mod doesn't own fails the step |
break = { x = 1, y = 63, z = 0, state = "minecraft:stone" }, place = { ... } |
server mods: the player breaks or places that block |
leave = "Alex" |
server mods: that player leaves |
A server mod starts with nobody on: join = "Alex" (after reload and title-screen) brings a
player in at the defaults, in survival, with the id
00000000-0000-4000-8000-000000000001 for the first name that joined, ...2
for the second, the same id when a name joins again. player, chat,
command, break and place are then that player's doing: the one named by
as = "Steve", or the first one on. player takes game-mode there, and the
script's own [player] and [clock] are a client mod's.
Then expect checks what the mod did during the step; a key left out is not
checked:
expect. |
Checks |
|---|---|
commands = ["where"] |
the commands the mod owns |
chat = ["..."] |
every chat line it showed, in order ([]: none) |
feedback = "...", error = "..." |
the command's answer, shown plain or in red |
panel = ["..."], corner = "top-left" |
its HUD panel's lines ([]: no panel) and corner |
told = ["Alex: Welcome!"], broadcast = ["..."] |
server mods: every players.tell (as name: text) and players.broadcast, in order |
blocks = [{ x = 2, y = 64, z = 0, state = "minecraft:glowstone" }] |
server mods with world: those blocks of the Overworld after the step, the mod's set-blocks made (the script checks a state's shape; the game checks its name) |
stopped = "panic" |
the mod was turned off, for a reason holding this text; without it, a mod that traps, panics or overruns fails the test |
Unknown keys are errors, so a typo never passes silently. The same runner is
horizon_mods::testing for Rust code that drives a mod (TestGame,
Script): Horizon's own tests run the examples' scripts with it. Scripts test
any mod, whatever it was written in.
Template layout
my-mod/
├── Cargo.toml # cdylib, horizon-mod-sdk, release: opt-level "s", LTO
├── horizon-mod.toml # the manifest
├── src/lib.rs # impl Mod, export_mod!
└── tests/hello.toml # cargo horizon-mod test
A mod is a type implementing horizon_mod_sdk::Mod; its state lives in the
type:
use horizon_mod_sdk::{Mod, Registration, chat};
struct Hello { greeted: u32 }
impl Mod for Hello {
fn init() -> Result<(Self, Registration), String> {
Ok((Hello { greeted: 0 }, Registration::new().command("hello")))
}
fn command(&mut self, _name: &str, args: &str) -> Result<Option<String>, String> {
self.greeted += 1;
chat::show(&format!("Hello {args}! ({} so far)", self.greeted));
Ok(None)
}
}
horizon_mod_sdk::export_mod!(Hello);
Hooks not asked for keep their default bodies. The full example is
coords-hud. Logic
that does not touch the API (formatting, maths) is ordinary Rust and unit-tests
on the host with cargo test.
The manifest
id = "coords_hud" # [a-z0-9_]{1,64}: settings key, log name
name = "Coordinates HUD"
version = "0.1.0"
description = "..."
authors = ["..."]
api = "0.1" # the API the SDK targets
side = "client" # client | server | both
capabilities = ["game-read", "hud", "chat"]
experimental = [] # experimental capabilities it uses, e.g. ["ui"]
Unknown keys are refused, so a typo is an error, not a silently ignored
permission. An experimental capability (today ui, render and input) may change in any release
(stability): a mod uses it only by naming it in
experimental as well, so nobody depends on it by accident.
Content mods (API 0.2)
A mod with api = "0.2" and capability registries may carry assets/ and
data/ in the .hmod, and needs no mod.wasm if it has no code
(cargo horizon-mod new --template content starts one; build in a folder
with no Cargo.toml packs the manifest, assets/ and data/, and check
reads it without a trial run). It adds
blocks and items by declaring them in data/<id>/horizon/block/*.json and
data/<id>/horizon/item/*.json, each copying a vanilla template
(modpacks.md):
{ "template": "minecraft:iron_block",
"properties": { "hardness": 4.0, "light": 3, "sounds": "minecraft:amethyst_block" },
"item": true }
The fence: files go in the mod's own namespaces (assets/<id>/,
data/<id>/, and those its manifest lists), plus additions to vanilla tags
(data/minecraft/tags/) and to the convention tags NeoForge and Fabric mods
share (data/c/tags/).
Templates and sounds name vanilla blocks; unknown keys are refused.
Some Java mods ship their files under other names than their mod id (Towns
and Towers, mod id t_and_t, under kaisyn and towns_and_towers). A
content mod lists those in its manifest:
id = "t_and_t"
api = "0.2"
capabilities = ["registries"]
namespaces = ["kaisyn", "towns_and_towers"]
A listed namespace is the mod's as its id is: its assets/ and data/
files load, and the game reads its tags, recipes, names and loot. Blocks and
items are still declared under data/<id>/horizon/ only. minecraft, c
and horizon cannot be listed. Two mods that are on may not own the same
namespace (one's id or listed namespace being the other's): the game keeps
the first in load order and leaves the other's content out, with
"mods A and B both use the namespace N; turn one of them off" in the log. Paths
use vanilla's resource characters, at most 65,536 files, 16 MiB each and
512 MiB in all. A mod whose content is refused stays off, with the reason on
its row. The game reads this content into its block and item tables
(horizon_client::mod_content, docs/modpacks.md). A tag entry naming an id
nobody registers (a block a port left out, another mod's item) is dropped,
so the rest of the tag still loads; vanilla would drop the whole tag.
Tags merge as a data pack's do. Every mod's file for one tag (vanilla's
data/minecraft/tags/, the convention data/c/tags/, or another mod's) adds
its values after vanilla's and the mods' before it, in load order. A file with
"replace": true first drops what came before it, vanilla's entries and the
earlier mods', and the mods after it still add theirs, as a higher data pack
does. Since the result depends on load order, the game says so in the log:
"mod towns replaces data/minecraft/tags/worldgen/biome/has_structure/village_plains.json:
vanilla's entries and those of adds are left out, ...". Tags are the only
minecraft files a mod may hold: its other content stays in its own
namespaces.
A mod adds to structure sets and template pools that are not its own
(vanilla's villages, another mod's houses) with a set or pool file in its own
namespace holding horizon:add_to, a key vanilla data never has. Its entries
are appended to each set or pool named, after every mod's own files, in mod
and file order; the file is no set or pool itself. A name nobody defines is
skipped.
{ "horizon:add_to": "minecraft:villages",
"structures": [ { "structure": "mymod:village_beach", "weight": 10 } ] }
A pool addition holds "elements" as a pool does. This is what lithostitched's
add_structure_set_entries and add_template_pool_elements modifiers do on
NeoForge and Fabric, and horizon-port-pack carry writes them from those.
A structure (and any of its pool elements, in place of the structure's) may
hold horizon:enhanced_terrain_adaptation: MoogsStructureLib's terrain
kernel, which carves or buries terrain around the
pieces on top of vanilla's terrain_adaptation. kernel_size and
kernel_distance shape the kernel; top and bottom (carve, bury or
none) act above and below a piece's floor; bottom_offset, padding
(x, top, bottom, z) and MoogsStructureLib's band (bottom, top,
piece_heights) move the box it measures from. On a structure,
rigid_junctions adds rigid pieces' junctions as well and
inflate grows the start's box by the kernel radius. kernel_size 0 is
none.
{ "horizon:enhanced_terrain_adaptation": { "kernel_size": 24, "kernel_distance": 24,
"top": "carve", "bottom": "none", "rigid_junctions": true, "inflate": true } }
Server mods (API 0.3, experimental)
A server mod runs where the world is simulated: on the dedicated server, on a
LAN host and on single player's own server, never in a player's window. Its
manifest says side = "server" and api = "0.3" (and only a server mod may);
its component targets the WIT world horizon-server-mod instead of
horizon-mod. Experimental: it may change in any release
(stability).
| Interface | Capability | Functions |
|---|---|---|
log |
always | as for client mods |
storage |
storage |
as for client mods, in the server's horizon-mods/ |
players |
players |
online(): id, name, position, dimension, game mode, health of each player on; tell(id, text), broadcast(text): system messages after the mod's name, 8 per call |
world |
world |
get-block(dimension, x, y, z): the state in a loaded chunk, minecraft:oak_log[axis=y]; get-biome(dimension, x, y, z): minecraft:plains, ...; set-blocks(dimension, changes): up to 4096 per call, checked at once, made as /setblock makes them (neighbours, light, block entities, every player told) after the call, before that dimension's next tick |
Hook (export server-events) |
When |
|---|---|
init() -> result<registration, string> |
once, after loading |
tick(tick) |
each server tick |
player-joined(player), player-left(player) |
registration.joins |
chat(player, text) |
registration.chat: each chat message a player sends |
command(player, name, args, values) |
/name args typed by a player, for a command it registered, with its arguments' values (below); ok(some) is that player's feedback, err their red error |
block-broken(player, pos, state), block-placed(player, pos, state) |
registration.blocks, with the state as minecraft:oak_log[axis=y] |
Capabilities that act on a local player (game-read, chat, hud) are for
client mods; players is for server mods. In Rust: impl horizon_mod_sdk::server::ServerMod and export_server_mod!; the example is
welcome, and with
world, beacons.
Typed commands. Each command in registration.commands lists its
arguments, in order: a name and a kind, required or optional (optional ones
last). The server adds them to the command tree every player receives, after
vanilla's commands, so a player's chat box completes and colours them as it
does vanilla's (player names, coordinates, bounds); when a player sends one,
the server parses the line the way vanilla's parsers do and gives the mod the
values, by argument name, or answers with vanilla's error (Integer must not be more than 64, found 65, No player was found) without calling the mod.
| Kind | Takes | Value | Chat box parser |
|---|---|---|---|
word |
one word: letters, digits, _ - . + |
word(string) |
brigadier:string (word) |
text |
the rest of the line, only last | text(string) |
brigadier:string (greedy) |
integer(min, max) |
a whole number within the bounds given | integer(s32) |
brigadier:integer |
number |
a number | number(f64) |
brigadier:double |
boolean |
true or false |
boolean(bool) |
brigadier:bool |
player |
an online player's name, @s or @p (whoever typed it) |
player(player) |
minecraft:entity (one player) |
block-pos |
x y z, each a whole number, ~ or ~n (from whoever typed it) |
block-pos(block-pos) |
minecraft:block_pos |
A command with no arguments takes nothing after its name. In Rust,
Registration::command_with("beacon", [Argument::integer("height", Some(1), Some(64)).optional()]), read with args.integer("height"); command(name)
alone takes free text, an optional text argument named args
(args.text() is the line as typed either way).
Capability world. Reads are of the server's loaded chunks as they are
during the call: the game lends a server mod's call the levels' chunk maps
(moved, not copied, and only while a running mod has world), so a read
costs a map lookup and no copy of the world. A dimension with no chunk loaded
at a position reads as none. Writes go through the same code as /setblock,
so block updates, light, block entities and every client (LAN guests,
dedicated server players) see them as vanilla's; the parity harnesses test
that code. Changes for a dimension whose level isn't ticking wait for it
(in-frame single player ticks only the player's), up to 65,536 blocks; a
change in a chunk that isn't loaded when it is made is skipped, as /setblock
refuses one.
Where they run. The game that simulates the world starts the server mods that are on, and only that one:
| Mode | Runs server mods | Players they see |
|---|---|---|
| Single player (default) | the server thread | the window's player, a connection like any other |
Single player --in-frame |
the window's own game, beside its client mods | the window's player |
| Open to LAN, Play with Friends | the host's server thread (or its window, in-frame) | the host and every guest |
Dedicated server (--dedicated, horizon-server) |
the server | every connected player |
A window that is a client of a server (single player's window, a LAN guest,
any multiplayer server) never runs them; its Mods page lists a server mod with
the others, and its switch takes effect the next time a world opens. The mods
live in horizon-mods/ of the game folder, or of the server's folder on a
dedicated server, and are turned on as client mods are: Options > Mods, or
mod.<id>:true in horizon-mods.txt. --dev-mod <file> runs one always
on and reloads it when its file changes, on a server too.
When hooks run. At the end of each server tick the game tells the server
mods who joined and who left since the last one, then what players said,
broke and placed, in order, then calls tick. A command a player types
reaches its mod at once, whatever the player's permission level, and its
answer goes to that player. tell reaches one player, the window's own
chat for the in-frame player and a system message for a connection;
broadcast reaches every player and the server's log. With no server mod
on, each of these points costs one branch (measured in the PR that added
them).
Not yet. Block events are a player's own breaking and placing, not
explosions, pistons or other mods. Command arguments don't take entities,
items or block states yet, and ^ coordinates; in-frame single player's chat
box lists server mods' commands by name only (the server still parses their
arguments).
Publish your mod
Horizon's registry lists mods, modpacks (.hpack) and resource packs made for Horizon, each reviewed before it is
published. Players find them at playhorizon.dev/mods and in the launcher (Mods, then
Horizon beside Modrinth), which checks every file before it installs it.
- Run
cargo horizon-mod checkon the file (above): the registry runs the same checks. - Put the source somewhere public, under a licence that lets players run it: reviewers read it, and the file must be built from it.
- Open playhorizon.dev/mods/submit, sign in with GitHub (it reads only your
public profile) and fill in the page: the file, its name and summary, licence, source address, and optionally a
minisign public key with the file's
.minisig. - The form checks the file again and opens a pull request in
horizon-rust/horizon-mods that adds
mods/<id>.toml, with you as its owner. That repository's checks run the file in the sandbox and post their report on the pull request; answer the reviewers there. - A reviewer merges it: within minutes the file is a release of horizon-mods, and the project is on the site and in the launcher.
Once it is published, its page and its card in the launcher show its downloads (GitHub's counts of its files, refreshed every hour) and its rating: players signed in with GitHub on its page give it 1 to 5 stars, one rating per account, which they can change. Its owners can't rate it, and nobody sees who rated.
An update is the same form with a new version; only the project's owners can publish one, signed with its key if it
has one. A published file never changes. Without the form, open the pull request by hand (the repository's README
says how). The formats: api/mod-index.md.
Horizon's official mods
The Horizon extras that are mods now, and Horizon's own recipe viewer, live in mods/official/<name>/, one crate each in the game's workspace, so
cargo clippy --workspace and pr_ready cover them. They use only the public API, like any other mod, with the same
options on their settings page as the extra had and the same look. Each is published by a maintainer: build it on
main with the toolchain CI uses (cd mods/official/<name> && cargo horizon-mod build), then open a pull request in
horizon-mods with its mods/<id>.toml (owners ludovic111, source pointing at the folder) and the file's
SHA-256. The reviewer builds it the same way and merges only when the hashes match: the .wasm holds no local paths,
so the same rustc and Cargo.lock give the same file.
| Mod | Id | Replaces | Settings (storage key: values) |
|---|---|---|---|
| FPS Counter | fps_overlay |
Extras > FPS counter | corner: top_left (default), top_right, bottom_left, bottom_right; lows: false (default), true |
| Keystrokes | keystrokes |
Extras > Keystrokes | corner: as above, default top_left; cps: true (default), false |
| Zoom | zoom |
Extras > Zoom | level: 2, 3, 4 (default), 6, 8, 10; smooth: true (default), false; sensitivity (slower turning): true (default), false. The key is the game's: key.zoom.zoom in horizon-mods.txt, default key.keyboard.z |
| Minimap | minimap |
Extras > Minimap | corner: as above, default top_right; size: 64, 96 (default), 128; shape: square (default), round; zoom (blocks per pixel): 0.5, 1 (default), 2, 4; rotate: false (default), true. The game draws the map (render.set-minimap) |
| Far View | far_view |
Graphics > LOD Distance and Far Detail | distance (chunks): 64, 128, 256 (default), 512, 1024; detail: full (default), reduced. The game builds and draws the distant terrain (render.set-far-terrain); the player's LOD Distance, when on, wins |
| Recipes | recipes |
nothing (new) | give (a click on the item panel gives the item in creative mode: one, a stack with a right click): false (default), true. The keys are the game's: key.recipes.recipe (default key.keyboard.r) and key.recipes.uses (default key.keyboard.u) in horizon-mods.txt. Every item sits in the item panel (browser) with a search box (words in the name or id, @namespace, #tag); R or a click shows an item's recipes, U or a right click its uses, a tab per station, and + fills the grid with a recipe the player knows (recipes.place). On servers that are not Horizon it shows the game's own 26.3 recipes; known recipes and + follow the server's recipe book |
Versions and compatibility
- The API is the WIT package
horizon:mods@0.1.0. New functions and interfaces arrive in patch versions (0.1.1, ...); a game serving 0.1.N loads mods built for 0.1.0 to 0.1.N. A breaking change makes0.2(and later1.0); the game then links both versions side by side for at least one release cycle, so existing mods keep working while authors move. - The manifest's
apinames the line it needs (0.1); the game refuses a line it does not serve with a clear message. - Within a line, each function has an API level, the one it arrived at
(stability). A mod needs the level of
the newest function it calls, read from its file: an older Horizon refuses
it with "made for a newer Horizon: update Horizon", and the registry lists
the level so installers take a version the game can run.
cargo horizon-mod checkprints it ("needs mod API level 1 or newer"). - Horizon follows Minecraft releases; the mod API does not have to. Vanilla concepts mods see (dimensions, block and item ids) are the game's own ids, so a version port changes the data, not the API.
Multiplayer
- Client mods (
side = "client", all of 0.1) run in the player's game, in single player and on any server, vanilla or Horizon. They see only what the client already knows (the snapshot), like F3 does, and act only on the local screen and chat. A server cannot see them. - Server mods (
side = "server", API 0.3) run where the world is simulated: single player's server thread (or the window, in-frame), a LAN host and the dedicated server (Server mods). They use only vanilla messages, so any client, vanilla or Horizon, can join. - Both (
side = "both"): a server mod with a client half (new blocks, their textures, a custom screen). On join the Horizon server announces its mods (id, version, SHA-256) on ahorizon:modscustom payload channel; a Horizon client with matching client halves enables them for that session, or offers to download them from the same source the launcher uses. Vanilla clients are refused only if the server says its mods need a client. - Registries (new blocks and items) are server-owned and synced like vanilla's registry sync, so ids match on both ends.
Distribution
- The file.
.hmod: a zip holdinghorizon-mod.toml,mod.wasmand, later,assets/anddata/.mod.wasmis capped at 32 MiB. - Integrity. Every download is checked against the SHA-256 the index
lists before the file reaches
horizon-mods/. - Signing. Authors sign
.hmodfiles with an Ed25519 key (minisign format: a.hmod.minisigbeside it); the index pins each project's public key, so an update signed by another key is refused. Unsigned local files stay allowed, with an "unsigned" mark on the Mods page. - Where mods come from. Horizon's registry (Publish your mod): reviewed pull requests in
horizon-rust/horizon-mods, files as its release assets, and an
index.jsonon GitHub Pages that the launcher and the website read. A file the registry takes down (revoked.toml) is refused by the launcher and turned off where installed. - The launcher. A Mods tab per installation: browse and search the index,
install, update, turn on and off (it writes the same
horizon-mods.txt), see capabilities before installing. The launcher's CLI and MCP tools gainmods list|search|install|remove|enable|disable, so agents can set up a modded game the same way players do.
Roadmap
- Design (this page) and the Java research.
- SDK, example mod, runtime,
cargo horizon-mod, the game's Mods page: API 0.1. storage(done),keys,target,inventory-read,ui: enough for the first native ports (zoom presets, AppleSkin-style food HUD, Jade-style tooltips).- Launcher Mods tab, the index, signatures; the Mods page shows cost and update state.
recipes,world-read,render: recipe viewer and schematics ports.- Server mods,
registries,net, data-driven content from.hmoddata/andassets/, multiplayer sync.
Modpacks (modpacks.md) move data-driven registries first:
API 0.2 is content-only .hmods (blocks, items, recipes, loot, models and
textures), then server hooks (0.3) and block entities, menus, capabilities and
net (0.4), in the order the ATM10 inventory needs them.