Writing an addon#
An addon is a Fabric mod that adds to Moud itself: a class for the tree, a global for scripts, a pass
in the renderer, or a whole language. Places turn it on in place.toml, and an exported game carries
it along with the mods it needs, so players install nothing else.
The smallest addon#
An addon is a class with a moud:addon entrypoint. Everything but id() has a default, so you only
write what you use:
public final class HorizonAddon implements Addon {
public String id() {
return "voxy-horizon";
}
public void started(AddonContext place) {
System.out.println("horizon on the " + (place.client() ? "client" : "server") + ", " + place.settings());
}
}{
"schemaVersion": 1,
"id": "voxy-horizon",
"version": "1.0.0",
"environment": "*",
"entrypoints": { "moud:addon": ["com.example.HorizonAddon"] },
"depends": { "moud": "*", "voxy": "*" }
}And the place that uses it says so in its place.toml:
[addons.voxy-horizon]
distance = 2048- The table is what turns the addon on for this place. It may be empty; its keys are the addon's own settings.
<id>is the addon'sid()or its Fabric mod id.- Put the jar in the
modsfolder next to Moud, or inlocalRuntimeif you build Moud from source.
The place starting and stopping#
public interface Addon {
String id();
default void classes(ClassRegistry registry) {}
default void started(AddonContext place) {}
default void configured(AddonContext place) {}
default void stopped(AddonContext place) {}
default void tick(AddonContext place, double delta) {}
default void frame(AddonContext place, double delta) {}
default void render(AddonContext place, Frame frame) {}
default List<String> check(Map<String, Object> settings) { return List.of(); }
}startedruns when a place starts on the server, and on a client when it joins a server running that place.stoppedruns when the place closes or the client leaves. In a game you play alone both sides run in one process, so an addon sees two starts, one per side.- Switching between editing and playing does not stop the place, so an addon keeps running in the editor.
tickruns 20 times a second on each side, after the scripts step.frameruns once per frame on the client.- An addon that throws is logged, shown in Output, and left off on that side until the place starts again. The rest of the place goes on.
AddonContext is what the addon knows about the place:
public interface AddonContext {
boolean client();
Path root();
Instance world();
Map<String, Object> settings();
}root()is the place folder,world()the root of that side's tree. Read and change it throughInstanceandInstanceTree, the same as the engine does.settings()is the[addons.<id>]table. Whenplace.tomlis saved while the place runs, the context gets the new table andconfiguredruns.
Settings#
check returns what is wrong with a place's settings, one readable line each:
public List<String> check(Map<String, Object> settings) {
if (settings.get("distance") instanceof Number n && n.doubleValue() > 0) return List.of();
return List.of("distance is how far the horizon reaches, in blocks, like distance = 2048");
}The lines show in Output when the place starts or place.toml is saved, and as errors in the
export dialog. Numbers arrive as Long or Double, the way TOML wrote them.
Classes#
classes registers instance classes. A class is a subclass of Instance, or of Spatial when it has
a position, and its public fields are its properties:
public final class HorizonAnchor extends Spatial {
public double radius = 512;
}
static final ClassDef<HorizonAnchor> ANCHOR = ClassDef.of("HorizonAnchor", Classes.SPATIAL, HorizonAnchor.class, HorizonAnchor::new);
public void classes(ClassRegistry registry) {
registry.register(ANCHOR);
}Scripts add them with world:add("HorizonAnchor", { radius = 900 }), the editor lists them under
Insert Object, and they get types like any built in class.
Scripts#
An addon that also implements ScriptAddon adds to what scripts see, in every language:
public final class HorizonAddon implements Addon, ScriptAddon {
public void script(Host host, Members game) {
Members horizon = new Members("Horizon")
.function("reach", "() -> number", a -> reach(host.client()))
.field("enabled", "boolean", () -> enabled, value -> enabled = value == Boolean.TRUE);
game.value("horizon", "Horizon", horizon);
host.declare(horizon);
}
}print(game.horizon.reach())
game.horizon.enabled = falsescriptruns for every script host the place starts, on each side, and again after each reload. What it adds goes away with the host.gameis theGametable, so members added to it read asgame.horizon. For a global of its own, callhost.global("horizon", "Horizon", horizon).- The type strings are Luau types. They go into
.moud/types.d.luauand the Java addon'smoud.Api, so editors completegame.horizonlike the rest of the api. scriptalso runs once with a stand-in host when the types are written, before any place starts. Keep it to declaring members, and look up the running place inside the functions.host.client()says which side the host is on.
Drawing#
render draws into the client's frame at four points:
frame.stage() |
Where it runs |
|---|---|
AFTER_OPAQUE |
the opaque world and Moud's parts are drawn and lit; water is not |
BEFORE_TRANSLUCENT |
over water, under glass parts, particles and beams |
BEFORE_POST |
the whole world is drawn; bloom, color grading and anti-aliasing are not |
AFTER_POST |
after the screen effects, before the interface and the editor's outlines |
public record Frame(Stage stage, float[] view, float[] projection, Vector3 eye, int depth, int width, int height,
double delta, float partialTick) {}- The main framebuffer is bound, color and depth, at the window's size.
viewandprojectionare column-major 4x4 matrices, the way OpenGL and JOML take them.viewturns around the camera, not from the world origin, so draw positions minuseye.depthis the OpenGL name of the main depth texture, for reading depth in a shader.deltais the time since the last frame in seconds.- Put back what you change in the OpenGL state: the program, the vertex array, the buffers, blending and depth.
- The editor's viewport shows the same frame, so an addon draws there too, and its wireframe mode applies.
- Nothing is called when no place is running.
Mods the game needs#
The depends block of the addon's fabric.mod.json is also the list of mods an exported game needs.
File > Export puts each of them in the game with the addon, and the mods they depend on in turn:
"depends": { "moud": "*", "voxy": "*", "sodium": "*" }- Minecraft, Fabric Loader, Fabric API and what Moud already ships are left out.
- The export dialog lists each one with the addon that needs it, like "Includes Voxy, needed by voxy-horizon".
- A mod that is not installed stops the export and says which addon asked for it.
- Mods nested in a mod stay where they are, one jar deeper in the game, and Fabric Loader finds them at any depth.
Language addons#
A language addon also implements LanguageAddon. See Other languages.
