Moud

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:

java
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());
    }
}
json
{
  "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:

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's id() or its Fabric mod id.
  • Put the jar in the mods folder next to Moud, or in localRuntime if you build Moud from source.

The place starting and stopping#

java
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(); }
}
  • started runs when a place starts on the server, and on a client when it joins a server running that place. stopped runs 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.
  • tick runs 20 times a second on each side, after the scripts step. frame runs 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:

java
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 through Instance and InstanceTree, the same as the engine does.
  • settings() is the [addons.<id>] table. When place.toml is saved while the place runs, the context gets the new table and configured runs.

Settings#

check returns what is wrong with a place's settings, one readable line each:

java
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:

java
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:

java
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);
    }
}
luau
print(game.horizon.reach())
game.horizon.enabled = false
  • script runs for every script host the place starts, on each side, and again after each reload. What it adds goes away with the host.
  • game is the Game table, so members added to it read as game.horizon. For a global of its own, call host.global("horizon", "Horizon", horizon).
  • The type strings are Luau types. They go into .moud/types.d.luau and the Java addon's moud.Api, so editors complete game.horizon like the rest of the api.
  • script also 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
java
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.
  • view and projection are column-major 4x4 matrices, the way OpenGL and JOML take them. view turns around the camera, not from the world origin, so draw positions minus eye.
  • depth is the OpenGL name of the main depth texture, for reading depth in a shader.
  • delta is 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:

json
"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.