All pages

Writing a feature

A new Vantage feature from start to finish - the class and its @FeatureInfo, where it is active, its options, a HUD element, a command and a key, whether it ships on, what the build checks, and the files to regenerate afterwards.

This page builds one complete feature, start to finish, using the same APIs and patterns as the features already in the mod. It is for someone who has the source building (see Building) and wants to add something of their own, in Vantage or in a fork. Each part of the example gets a section of its own, and the three biggest parts, options, HUD elements and commands, have their own reference pages. As of 2.0.1.

One file

Adding a feature to Vantage is one new class. Nothing registers it by hand: the :processor subproject reads every @FeatureInfo annotation at compile time and generates features.GeneratedFeatureIndex, and Features.registerAll() constructs every entry in it. Write the class, compile, and it is in the menu.

The example is a feature called Purse pace. It is not in the mod; it is small enough to read in one sitting and touches every part of the framework:

PieceIn the example
Identity and place in the menu@FeatureInfo with an id, a name, a description, a group and tags
Where it runsthree mining islands
Settingstwo switches, a slider that depends on one of them, a key and a button
Reacting to the gamea handler for Events.PurseChange and one for Events.KeyPress
On the screena text HUD element the player can move in the HUD editor
In chat/vt pursepace and /vt pursepace reset
Defaultoff, so a player opts in

The whole file

It goes in the mining area's package, src/main/java/dev/cherishev/vantage/features/mining/PursePaceFeature.java. The package is only where the file lives; the menu position comes from the group.

package dev.cherishev.vantage.features.mining;

import dev.cherishev.vantage.core.command.Commands;
import dev.cherishev.vantage.core.config.Button;
import dev.cherishev.vantage.core.config.Option;
import dev.cherishev.vantage.core.config.Range;
import dev.cherishev.vantage.core.config.types.Keybind;
import dev.cherishev.vantage.core.event.Events;
import dev.cherishev.vantage.core.event.Subscribe;
import dev.cherishev.vantage.core.feature.Feature;
import dev.cherishev.vantage.core.feature.FeatureGroup;
import dev.cherishev.vantage.core.feature.FeatureInfo;
import dev.cherishev.vantage.core.feature.FeatureTag;
import dev.cherishev.vantage.core.hud.TextHudElement;
import dev.cherishev.vantage.core.skyblock.Island;
import dev.cherishev.vantage.core.util.Formatters;
import dev.cherishev.vantage.core.util.Input;
import dev.cherishev.vantage.core.util.Timer;
import net.fabricmc.fabric.api.client.command.v2.ClientCommands;
import net.minecraft.network.chat.Component;

import java.util.ArrayList;
import java.util.List;

/** Coins your purse has gained in the mines, and the pace per hour. A worked example; not in the mod. */
@FeatureInfo(
        id = "purse_pace",
        name = "Purse pace",
        description = "Shows how many coins your purse has gained since you arrived in the mines, and the pace per hour.",
        group = FeatureGroup.MINING_POWDER,
        tags = { FeatureTag.HUD, FeatureTag.PROFIT, FeatureTag.KEYBIND },
        enabledByDefault = false,
        islands = { Island.DWARVEN_MINES, Island.CRYSTAL_HOLLOWS, Island.MINESHAFT })
public final class PursePaceFeature extends Feature {

    private static final String S_COUNT = "Counting";
    private static final String S_KEYS = "Keys";

    @Option(name = "Count spending", section = S_COUNT, order = 1,
            description = "Subtract coins you spend, so the total is what you kept rather than what came in.")
    public boolean countSpending = false;

    @Option(name = "Show the pace", section = S_COUNT, order = 2,
            description = "Add a coins-per-hour line under the total.")
    public boolean showPace = true;

    @Option(name = "Pace after", section = S_COUNT, order = 3, dependsOn = "showPace",
            description = "Wait this long before showing a pace, so the first minute does not read as millions an hour.")
    @Range(min = 1, max = 10, step = 1, unit = "min")
    public int paceAfterMinutes = 2;

    @Option(name = "Reset key", section = S_KEYS, order = 1, description = "Starts the count again.")
    public Keybind resetKey = Keybind.NONE;

    @Option(name = "Reset now", section = S_KEYS, order = 2)
    @Button(label = "Reset")
    public Runnable resetButton = this::reset;

    private double gained;
    private final Timer since = Timer.started();   // monotonic: a clock change cannot bend the pace

    public PursePaceFeature() {
        super();
    }

    @Override
    protected void onInit() {
        addHud(new PaceHud());
        Commands.registerSubcommand(ClientCommands.literal("pursepace")
                .executes(ctx -> {
                    if (!isActive()) {
                        Commands.replyError(ctx.getSource(), "Purse pace counts in the mines, with the feature on.");
                        return 0;
                    }
                    Commands.reply(ctx.getSource(), "Gained " + Formatters.formatCoins(gained, true) + " so far.");
                    return 1;
                })
                .then(ClientCommands.literal("reset").executes(ctx -> {
                    reset();
                    Commands.reply(ctx.getSource(), "Purse pace reset.");
                    return 1;
                })));
    }

    @Override
    protected void onEnable() {
        reset();   // a fresh count each time the feature becomes active
    }

    @Subscribe
    private void onPurse(Events.PurseChange event) {
        double delta = event.to() - event.from();
        if (delta > 0 || countSpending) gained += delta;
    }

    @Subscribe
    private void onKey(Events.KeyPress event) {
        if (!Input.matches(resetKey, event)) return;
        event.cancel();
        reset();
    }

    private void reset() {
        gained = 0;
        since.start();
    }

    private final class PaceHud extends TextHudElement {
        PaceHud() {
            super("Purse pace", new Position(Anchor.TOP_RIGHT, 6, 60, 1f));
        }

        @Override
        protected List<Component> lines() {
            if (!isActive()) return List.of();
            List<Component> out = new ArrayList<>();
            out.add(pair("Gained: ", Formatters.compact(gained)));
            long elapsed = since.elapsed();
            if (showPace && elapsed >= paceAfterMinutes * 60_000L) {
                out.add(pair("Per hour: ", Formatters.compact(gained * 3_600_000d / elapsed)));
            }
            return out;
        }

        @Override
        protected List<Component> sampleLines() {
            return List.of(pair("Gained: ", "1.2M"), pair("Per hour: ", "4.8M"));
        }
    }
}

Everything it calls exists in the mod: Formatters.compact writes 1.2M, Formatters.formatCoins(…, true) writes 1.2M coins, Input.matches compares a key event against a Keybind, Timer is the stopwatch the house rules ask for in place of the wall clock, and the HUD element is the same shape as the Volcano explosivity readout, a real feature of about sixty lines that is worth reading next.

@FeatureInfo, field by field

The annotation is the feature's identity. The Feature constructor reads it and throws if it is missing or its id or name is blank, so a feature cannot exist without one.

MemberDefaultWhat it is
idrequiredlower_snake_case, unique across the mod, and the key the feature's settings are saved under. Renaming it later moves every player's settings unless you write a migration.
namerequiredSentence case: "Purse pace", not "Purse Pace".
descriptionrequiredOne or two sentences ending in a full stop. It is shown in the menu, matched by search, and printed on this website.
grouprequiredA FeatureGroup constant: the heading the feature sits under. The category comes from the group.
tagsnoneFeatureTags: what kind of thing the feature is. Give at least one.
enabledByDefaultfalseThe starting value of the master switch.
islandseverywhereThe islands the feature is active on. Empty means anywhere in SkyBlock.
requiresSkyblocktrueSet false for a feature that also works outside SkyBlock, such as 11:11.

Choosing a group and tags

Groups are a closed list in core/feature/FeatureGroup.java. Each constant names its category, a display name and a short description, and the order the constants are declared in is the order the menu shows them. Pick the group a player would look under. MINING_POWDER is "Powder and profit" in the Mining category, which is where a mining coin readout belongs.

Keep a group to twelve features or fewer. SelfTest reports any group above twelve, because past that a category page stops being scannable. When a group is full, adding a group to FeatureGroup is a deliberate, reviewable edit; that is the point of it being an enum.

Tags are the second axis. The category tree says where in SkyBlock a feature lives; tags say what it is, and the menu's search matches them, which is how searching "waypoints" finds waypoint features in every category. Apply a tag for what the feature does, not what its name suggests.

TagMeans the feature
HUDcontributes a movable on-screen HUD element
WAYPOINTSdraws a world marker the player navigates to
SOLVERcomputes the answer to a puzzle or minigame
TRACKERcounts things over a session and keeps them
PROFITvalues something in coins
HIGHLIGHToutlines or colours a block, entity or slot
HIDES_CLUTTERremoves something the game draws
TOOLTIPadds lines to an item tooltip
CHAT_FILTERhides, replaces or compacts chat messages
KEYBINDbinds a key
SOUNDplays or mutes a sound
WARNINGraises a title, toast or alert on a condition
TIMERcounts down or up to a game event
MENUmodifies or overlays a Hypixel menu
PRICESreads Bazaar, auction or NPC prices
MAPdraws a 2D map or minimap
PARTYreads or writes party state or party chat
AUTOMATIONdoes something on the player's behalf without a keypress

The example does not take TRACKER, because it keeps nothing between sessions.

Where it is active

A feature is enabled when its master switch is on. It is active when it is enabled and also allowed to run here and now. The registry re-checks this every client tick, and again on world join, world leave, entering or leaving SkyBlock, and island changes:

active = enabled
     and (not requiresSkyblock, or the player is in SkyBlock)
     and (islands is empty, or the current island is one of them)
     and shouldBeActive()

islands covers most cases. For a condition the island list cannot express, override shouldBeActive(); it is called every tick, so keep it cheap. /vt features lists every feature as off, on or active, and /vt toggle <id> says "(inactive here)" when a feature is switched on but not active where the player stands.

The lifecycle

HookCalledUse it for
constructoronce, by the generated indexnothing but super(); the index needs an accessible no-argument constructor
onInit()once, after the config is loadedaddHud(…), Commands.registerSubcommand(…), change listeners
onEnable()each time the feature becomes activeresetting per-visit state
onDisable()each time it stops being activetearing down what onEnable set up
onTick()every client tick while activepolling, when no event fits
@Subscribe methodsfor each matching event, while activereacting to the game
badge()when the menu draws the carda short chip such as "NEW" or "BETA", or null

Each hook is wrapped: an onInit, onEnable, onDisable or onTick that throws is logged and the rest of the mod carries on. A malformed @Subscribe method leaves only that feature inactive, logged once, rather than retrying every tick.

Code that several features in one area share, such as a read model or event wiring, goes in that area's XFeatures.register() (for mining, features/mining/MiningFeatures.java). Every area's bootstrap runs before any feature is constructed, so a feature can rely on its area's shared state existing.

Listening to the game

The example reacts to two events. Events.PurseChange carries the old and new purse, and is posted only when both are known, so a jump from an unknown purse to a real one is never counted as a gain. Events.KeyPress is posted for key presses in the world with no screen open; cancelling it stops Minecraft's own key mappings seeing the press.

Handlers are subscribed when the feature becomes active and removed when it stops, which is why neither handler checks isActive(). The HUD element and the command are different: the HUD manager and the command tree hold on to them whether or not the feature is active, so the element and the main command check isActive() themselves. Every event the bus carries is listed in Architecture.

To reach another feature, ask the registry rather than keeping a static reference:

FeatureRegistry.INSTANCE.get(ItemBrowserFeature.class).ifPresent(browser -> { /* … */ });

It returns empty when that feature failed to register.

Options, HUD and command

Each of the three has a reference page; this is what the example does with them.

Options. Every @Option field becomes a row on the feature's page, with a control chosen by its type: the two booleans are switches, the int with @Range is a slider, the Keybind is a key button and the Runnable is a button labelled by @Button. dependsOn = "showPace" greys the slider out while "Show the pace" is off, and the two section names split the page into two sheets. Values are saved as purse_pace.countSpending and so on. Options and config covers every type and attribute.

The HUD element. addHud(new PaceHud()) in onInit registers it under the feature's id, so its saved position is purse_pace in hud.json. lines() is what it shows in game; returning an empty list hides it. sampleLines() is what the HUD editor shows, so the player can place it before they have earned a coin. HUD elements covers the base classes, anchors and the editor.

The command. Commands.registerSubcommand adds pursepace under both /vantage and /vt. Commands.reply and replyError prefix the answer with [Vantage]. Commands and keybinds covers arguments, opening screens and keys.

On by default, or not

enabledByDefault decides two things. It is the master switch's starting value for every player who has never touched it. And the setup wizard treats it as Vantage's recommendation: when a player ticks an area in "What do you play?", every feature in that category that ships switched on is switched on. Nothing else needs editing for the wizard to know about a new feature.

Ship a feature on when most players of that area would want it and it costs them nothing. Leave it off when it is niche, noisy, or reaches the network on the player's behalf. Changing the default of a feature that has already shipped does not reach players who already have it saved; Options and config explains why and what to do.

What the build checks for you

The annotation processor fails the compile for a duplicate id (naming both classes), an id that is not lower_snake_case, a blank description, @FeatureInfo on an abstract class, an annotated class that does not extend Feature, and a missing or private no-argument constructor. Building lists the exact messages.

Two more checks are not part of Gradle. SelfTest runs from /vt dev selftest and inside the UI smoke test, and reports a group with more than twelve features and a feature with no tags. The UI smoke test opens every Vantage screen, your feature's page included, and writes its verdict to run/screenshots/smoke-report.txt. Testing explains both.

After you add one

Regenerate the files that are built from the annotations, and commit them with the feature:

python tools/tracker.py             # docs/TRACKER.md
python tools/tracker.py --catalog   # docs/catalog.json
python tools/sitedocs.py            # docs/site-catalog.json, which this website is built from

CI's tracker-current job runs python tools/tracker.py --check, then regenerates docs/catalog.json and fails if the committed copy differs, so forgetting either of the first two turns the pipeline red. tools/sitedocs.py is not checked in CI; python tools/sitedocs.py --check exits 1 when its file is stale.

Then see it in the game:

./gradlew compileJava test
./gradlew runClient

In the development client, /vt features mining lists the new feature, /vt toggle purse_pace switches it on, and /vt config purse_pace opens its page. The page is generated: a card with the description and the master switch, the HUD element drawn live as a preview, then the two option sections. /vt hud opens the editor with "Purse pace" listed under Powder and profit.