For addon authors

Vortex Dread is written to be built on. No mixin is needed, no fork either, and nothing has to be declared in your fabric.mod.json beyond a dependency on the mod. Put the jar on your classpath, call the entry class, and that is the whole setup. This page describes every public thing there is: four types, one call surface, two events. An addon compiling against anything else is compiling against internals, and internals move.

The public surface

Everything stable lives in oas.dreyka.vortexdread.api and its subpackage oas.dreyka.vortexdread.api.event. Two enums from elsewhere are part of the contract because the signatures hand them to you: oas.dreyka.vortexdread.wind.EfScale, which carries the six ratings, and oas.dreyka.vortexdread.tornado.TornadoStage, which carries the five stages. Those two will not move either. The rest of the tree changes from version to version and that does not count as a regression.

The mod publishes under group oas.dreyka.vortexdread, artefact vortexdread, version 1.0.0. No remote maven repository is open yet: a ./gradlew publishToMavenLocal from a clone puts the artefact in your ~/.m2, and depending on the jar from a GitHub release works just as well. The surface described here has existed since the first published version.

Wiring it into your build

The whole thing, since the lines that trip an addon up are the ones nobody writes out. Loom is a Gradle plugin and it does not live on the plugin portal, so settings.gradle.kts has to name Fabric's own maven before build.gradle.kts can ask for it:

pluginManagement {
    repositories {
        maven("https://maven.fabricmc.net/") { name = "Fabric" }
        gradlePluginPortal()
        mavenCentral()
    }
}

rootProject.name = "your-addon"

Then the plugin itself, at the top of build.gradle.kts. The version below is the one this mod is built with, and Java 21 is what 1.21.11 runs on:

plugins {
    id("fabric-loom") version "1.17.12"
    java
}

java {
    toolchain.languageVersion = JavaLanguageVersion.of(21)
}

Then the dependencies, in Kotlin DSL:

dependencies {
    minecraft("com.mojang:minecraft:1.21.11")
    mappings(loom.officialMojangMappings())
    modImplementation("net.fabricmc:fabric-loader:0.19.3")
    modImplementation("net.fabricmc.fabric-api:fabric-api:0.141.4+1.21.11")

    modImplementation(files("libs/vortexdread-1.0.0.jar"))
}

The same three blocks in Groovy, since a build file written in one form leaves the other form's author guessing:

// settings.gradle
pluginManagement {
    repositories {
        maven { url = 'https://maven.fabricmc.net/'; name = 'Fabric' }
        gradlePluginPortal()
        mavenCentral()
    }
}

// build.gradle
plugins {
    id 'fabric-loom' version '1.17.12'
    id 'java'
}

dependencies {
    minecraft 'com.mojang:minecraft:1.21.11'
    mappings loom.officialMojangMappings()
    modImplementation 'net.fabricmc:fabric-loader:0.19.3'
    modImplementation 'net.fabricmc.fabric-api:fabric-api:0.141.4+1.21.11'

    modImplementation files('libs/vortexdread-1.0.0.jar')
}

Use modImplementation, never implementation or compileOnly. The published jar carries intermediary names for every Minecraft type it touches, and only a mod-aware configuration asks Loom to remap them into your own. Get this wrong and the compiler reports things like cannot access class_2960 or Identifier cannot be converted to class_1937, which look like a broken API and are a missing three letters.

Fabric API belongs on the same configuration for the same kind of reason. The two callbacks below are Fabric Event objects, so your code touches that class at compile time even if it never calls Fabric API itself, and Loom only passes Fabric API's access widener to a project that declares the module. On modRuntimeOnly you get a missing net.fabricmc.fabric.api.event.Event and, stranger, half the vanilla registration calls turning private.

Mappings are not a free choice either: the mod is built against official Mojang mappings, and every signature on this page is written in them. A Yarn project sees ServerWorld where the tables below say ServerLevel, and the two do not meet. In fabric.mod.json, the id to depend on is "vortexdread".

Six vanilla types appear in the signatures below, and the tables give their short names. Where they come from, so nothing has to be hunted for:

import net.minecraft.world.phys.Vec3;
import net.minecraft.world.phys.AABB;
import net.minecraft.world.level.Level;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.core.BlockPos;
import net.minecraft.world.level.block.state.BlockState;

import oas.dreyka.vortexdread.api.VortexDreadApi;
import oas.dreyka.vortexdread.api.TornadoView;
import oas.dreyka.vortexdread.api.event.TornadoLifeCallback;
import oas.dreyka.vortexdread.api.event.BlockTakenCallback;
import oas.dreyka.vortexdread.wind.EfScale;
import oas.dreyka.vortexdread.tornado.TornadoStage;

What an addon can call and listen to

Nothing registers at boot: there is no entrypoint to declare, no interface to implement at mod level, no load order to respect. Both events go through net.fabricmc.fabric.api.event.Event, the machinery Fabric already ships, so you subscribe from your own onInitialize and the mod calls you when it has something to say. A listener that throws is blamed once per class in the server log, and the storm carries on without it.

The five entry points, all under oas.dreyka.vortexdread.api
Entry pointWhat an addon does with itSince
VortexDreadApiStatic call surface. Lists the tornadoes in a level, finds the nearest one to a point, samples the wind, starts one.1.0.0
TornadoViewRead-only interface on one live tornado. Thirteen accessors, all current for as long as the storm exists.1.0.0
event.TornadoLifeCallbackForming, touchdown and gone. Where a warning system or a scoreboard hooks in.1.0.0
event.BlockTakenCallbackA veto on every block the storm is about to take. What a claim mod needs.1.0.0
wind.EfScaleThe six ratings, with their gust floor in metres per second and their translation key.1.0.0
The methods on VortexDreadApi, all static
SignatureWhat it answersSide
tornadoes(Level)List<TornadoView>, every loaded tornado, in no particular order.both
nearest(Level, Vec3)Optional<TornadoView>, the one whose axis is closest to the point.both
windAt(Level, Vec3)Vec3, wind in blocks per second, summed over every storm that reaches the point. The zero vector anywhere else.both
spawn(ServerLevel, double, double, EfScale)TornadoView on the tornado made at the given x and z. The rating is a ceiling, not a promise.server
The accessors on TornadoView
SignatureWhat it answersUnit
position()Where the axis meets the ground.Vec3
alive()False the moment the storm is over, and nothing moves after that.boolean
rating()The rating read off the current gust, which climbs and falls again.EfScale
peakRating()The strongest rating it has reached. What a warning should be written against.EfScale
stage()Where it is: FORMING, TOUCHDOWN, MATURE, ROPING, GONE.TornadoStage
ageTicks()Ticks since it formed.int
wind()Tangential wind at the radius of maximum wind.float, m/s
coreRadius()Radius of maximum wind. The visible condensation is a little narrower.float, blocks
funnelHeight()Ground to cloud base under the axis.float, blocks
descent()0 hanging out of the cloud, 1 on the ground.float
groundLoad()0 clean, 1 wrapped in what it took.float
tint()The colour the debris has given it, packed as 0xRRGGBB.int
influenceBox()The box the wind reaches into. Outside it the storm does nothing at all.AABB

A whole addon

This one tells the players when a funnel forms and when it is over, and refuses the storm anything above y 200. It is one file and it compiles as it stands, given fabric-api and the Vortex Dread jar on the classpath.

package example.stormalarm;

import net.fabricmc.api.ModInitializer;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerLevel;
import oas.dreyka.vortexdread.api.TornadoView;
import oas.dreyka.vortexdread.api.event.BlockTakenCallback;
import oas.dreyka.vortexdread.api.event.TornadoLifeCallback;

public class StormAlarm implements ModInitializer {

    @Override
    public void onInitialize() {
        TornadoLifeCallback.EVENT.register(new TornadoLifeCallback() {

            @Override
            public void onFormed(ServerLevel level, TornadoView tornado) {
                announce(level, "Funnel forming, " + tornado.peakRating() + " expected");
            }

            @Override
            public void onTouchdown(ServerLevel level, TornadoView tornado) {
                announce(level, "On the ground, core " + (int) tornado.coreRadius() + " blocks");
            }

            @Override
            public void onGone(ServerLevel level, TornadoView tornado) {
                announce(level, "All clear");
            }
        });

        // Nothing comes out of the ground above y 200, whatever the storm is worth.
        BlockTakenCallback.EVENT.register((level, tornado, pos, state) -> pos.getY() < 200);
    }

    private static void announce(ServerLevel level, String line) {
        level.players().forEach(player -> player.sendSystemMessage(Component.literal(line)));
    }
}

Both onTouchdown and onGone have an empty default body, so a class that only wants the birth writes only onFormed. BlockTakenCallback has one method and therefore takes a lambda. Answering false leaves the block where it is, and no other listener can undo that. The view handed to either event is live: reading it later in the same tick is fine, holding it past the death is not, since it answers false to alive() from then on.

Four small guarantees that save an addon from guessing. Nothing here ever returns null: tornadoes gives an empty list on a level with no storm in it, and nearest gives an empty Optional. One tornado is one TornadoView instance for its whole life, identity comparison included, so keying a map on it holds a tally together across a thousand callbacks. Both events fire on the server thread, and the three reading calls answer from whichever side asks, which is what lets a client-side overlay use the same code as a server rule. And BlockTakenCallback is handed the tornado, the position and the block that was there, never a cause: the storm is the cause, and an addon that wants to tell wind damage from debris damage reads the damage type on the entity side instead.

Without writing a line of Java

A good deal of what an addon would do is settable from config/oas/vortexdread.json, server side, forty-two options across five groups. A map where the storm breaks nothing is one line (breakBlocks to false). A map where only the operator decides when a tornado arrives is naturalTornadoes to false plus the /vortex spawn ef4 command. Values are clamped rather than refused, so an absurd number gives a playable game and a line in the log.

Everything else is internal. The packages storm, tornado (other than TornadoStage), wind (other than EfScale), damage, debris, entity, compute, client, config and mixin carry no stability promise. Calling into them works today and breaks one version later, and that will not be treated as a regression or fixed for you. If the public surface is missing something you need, ask for it on the Issues page instead of working around it: github.com/Dreyka-Oas/VortexDread/issues.

Licence. Vortex Dread is MIT. Take it, change it, redistribute it and publish a commercial addon on it without asking anyone. The one obligation is keeping the copyright notice and the licence text with any substantial copy of the code. A mention in your addon's description is welcome as a courtesy and never as a condition. The licence text