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.
On this page
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:
| Piece | In the example |
|---|---|
| Identity and place in the menu | @FeatureInfo with an id, a name, a description, a group and tags |
| Where it runs | three mining islands |
| Settings | two switches, a slider that depends on one of them, a key and a button |
| Reacting to the game | a handler for Events.PurseChange and one for Events.KeyPress |
| On the screen | a text HUD element the player can move in the HUD editor |
| In chat | /vt pursepace and /vt pursepace reset |
| Default | off, 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.
| Member | Default | What it is |
|---|---|---|
id | required | lower_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. |
name | required | Sentence case: "Purse pace", not "Purse Pace". |
description | required | One or two sentences ending in a full stop. It is shown in the menu, matched by search, and printed on this website. |
group | required | A FeatureGroup constant: the heading the feature sits under. The category comes from the group. |
tags | none | FeatureTags: what kind of thing the feature is. Give at least one. |
enabledByDefault | false | The starting value of the master switch. |
islands | everywhere | The islands the feature is active on. Empty means anywhere in SkyBlock. |
requiresSkyblock | true | Set 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.
| Tag | Means the feature |
|---|---|
HUD | contributes a movable on-screen HUD element |
WAYPOINTS | draws a world marker the player navigates to |
SOLVER | computes the answer to a puzzle or minigame |
TRACKER | counts things over a session and keeps them |
PROFIT | values something in coins |
HIGHLIGHT | outlines or colours a block, entity or slot |
HIDES_CLUTTER | removes something the game draws |
TOOLTIP | adds lines to an item tooltip |
CHAT_FILTER | hides, replaces or compacts chat messages |
KEYBIND | binds a key |
SOUND | plays or mutes a sound |
WARNING | raises a title, toast or alert on a condition |
TIMER | counts down or up to a game event |
MENU | modifies or overlays a Hypixel menu |
PRICES | reads Bazaar, auction or NPC prices |
MAP | draws a 2D map or minimap |
PARTY | reads or writes party state or party chat |
AUTOMATION | does 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
| Hook | Called | Use it for |
|---|---|---|
| constructor | once, by the generated index | nothing but super(); the index needs an accessible no-argument constructor |
onInit() | once, after the config is loaded | addHud(…), Commands.registerSubcommand(…), change listeners |
onEnable() | each time the feature becomes active | resetting per-visit state |
onDisable() | each time it stops being active | tearing down what onEnable set up |
onTick() | every client tick while active | polling, when no event fits |
@Subscribe methods | for each matching event, while active | reacting to the game |
badge() | when the menu draws the card | a 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 fromCI'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 runClientIn 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.