Architecture
How Vantage is put together - the boot order, the packages and what each owns, the event bus, how the mod knows where you are, where data comes from, which thread does what, and the house rules the code keeps.
On this page
This page is a map of the mod's source for someone about to change it. It starts at the one entry point, walks the
boot order, then goes package by package: the event bus everything talks through, the state that says where the
player is, the data layer, the drawing layers and the threads. It ends with the rules the code holds itself to.
Paths are under src/main/java/dev/cherishev/vantage/ unless they say otherwise. As of 2.0.1;
the repository's own ARCHITECTURE.md is the maintainer's version of the same map.
The shape
Vantage is a client-only Fabric mod with two entry points, both named in fabric.mod.json: the client
entrypoint dev.cherishev.vantage.Vantage, and compat.ModMenuIntegration, which hands ModMenu the Vantage menu
as the mod's config screen when ModMenu is installed.
Below that the code splits in three:
| Part | What it is |
|---|---|
core | the framework: events, config, the feature base class, SkyBlock state, network and data, the HUD, the UI toolkit, commands, developer tools |
features | one package per gameplay area, each holding the features themselves; a feature is a class that extends core.feature.Feature |
mixin | the few places that hook Minecraft directly, grouped by area |
Minecraft 26.x ships unobfuscated, so there are no mappings: the code calls the real net.minecraft.* names. One
tree builds for 26.1.2 and 26.2; the handful of files that differ per version live in overlays beside
the main tree, which Multi-version explains.
Boot order
Vantage.onInitializeClient() boots every subsystem itself, in one readable sequence, rather than letting each
register itself. The order is the code's, from Vantage.java:
| Step | Call | What it starts |
|---|---|---|
| 1 | ConfigMigrations.register(…), then ConfigManager.get().load() | config schema migrations, then the active config profile |
| 2 | CoreHooks.init() | Fabric events turned into Vantage events; the scoreboard, tab list and location readers |
| 3 | WorldRenderer.init() | the world render hook and its pipelines |
| 4 | ApiManager.init() | the item repository, prices and the election, all asynchronous |
| 5 | DataManager.init() | the community datasets, asynchronous, with a shutdown hook for its watchdog |
| 6 | StatsBootstrap.init(), Uploader.hooks() | Vantage Stats' catalogue and recorder tick, and its sync triggers; nothing records without consent |
| 7 | HudManager.get().load() | the saved HUD layout |
| 8 | UiBootstrap.setHudEditorFactory(…) | wires the HUD editor screen into the UI layer |
| 9 | Features.registerAll() | every area's shared bootstrap, then every feature, constructed from the generated index |
| 10 | FeatureRegistry.INSTANCE.initAll() | each feature's options registered and onInit() run; HUD elements handed to the HUD manager |
| 11 | UiBootstrap.init() | the two Minecraft key mappings and the notifier |
| 12 | Commands.register() | /vantage and /vt |
| 13 | BugCommand.register() | /vantage bug |
| 14 | DevTools.init() | /vantage dev and its overlays |
| 15 | EventBus.INSTANCE.post(new Events.ModReady()) | everything is up |
The log brackets it with Vantage <version> booting and Vantage ready in <n> ms.
Three consequences of the order are worth knowing. The config is loaded before any feature exists, so a feature's
stored values are applied the moment it registers its options. The HUD layout is loaded before any element is
registered, so each element picks up its saved position as it arrives. And features run onInit() before the
command tree is built, so a subcommand registered there is part of the tree from the first join.
core, package by package
| Package | What it owns |
|---|---|
core.event | EventBus, @Subscribe, the cancellable base, and every event record in Events |
core.config | @Option, @Range, @Button, Named, the Color and Keybind types, ConfigManager and its implementation |
core.feature | Feature, @FeatureInfo, FeatureRegistry, and the taxonomy: FeatureCategory, FeatureGroup, FeatureTag |
core.skyblock | SkyblockState, CoreHooks, Island, the scoreboard and tab-list readers, TabWidgets, SkyblockText, SkyblockTime, and the item API under item/ |
core.ravengard | RavengardState, the same idea as SkyblockState for Hypixel's Ravengard |
core.api | the HTTP client, the NotEnoughUpdates item repository, prices, keyless Hypixel resources, Mojang, profiles through Vantage's service, and bug reports |
core.data | datasets fetched and cached on disk, LootTracker, and core.data.ledger, the grind ledger's recorder |
core.stats | Vantage Stats: the recording facade, the wire model, the on-disk store, the sync layer, the profile mirror and the per-area collectors |
core.hud | HudElement and its three ready-made shapes, HudGroup, the HUD manager, the editor, and the Director's gate |
core.ui | the Obsidian Glass toolkit: Theme, Draw, widgets, screens, animation, notifications, generated config pages |
core.render | world drawing: boxes, lines, beams, world text, waypoints, entity outlines |
core.pathfind | A* over collision-shape cells, written without Minecraft types and unit-tested, plus the adapter to the live world |
core.command | the /vantage tree, Deferred, CommandEvents |
core.devtools | /vantage dev: inspectors, overlays, the performance monitor, the UI and pathfinding smoke tests |
core.compat | the seam between Minecraft versions; the common part is GpuTimer, the overlays add Mc, Dyed, Mobs and Gpu |
core.util | formatting, scheduling, JSON, colour, maths, input and text helpers, and SelfTest, which /vantage dev selftest runs |
core.ravengard and core.compat exist in the tree but are missing from the package map in ARCHITECTURE.md.
features
features/ has one package per area, plus a few for systems big enough to be areas of their own:
audiovis, blackbox, chat, combat, containers, crimson, dungeons, events, farming, fishing,
foraging, general, hunting, inventory, ledger, mining, onboarding, profileviewer, qol,
ravengard, rift, slayers, stats, vet and visuals. The package a feature lives in is not what decides
where it shows up in the menu: its @FeatureInfo group does. Writing a feature covers that.
features/Features.java boots the feature layer in two phases. First every area's XFeatures.register() runs, for
that area's shared state, event wiring and read models. Then every feature in GeneratedFeatureIndex is
constructed and registered. A bootstrap that throws costs its area's shared state; a feature that throws costs that
one feature.
mixin
Mixins live in mixin.<area>: chat, containers, core, dungeons, farming, inventory, qol, render and
visuals. Their configs sit in src/main/resources/. vantage.mixins.json has the package dev.cherishev.vantage.mixin and
lists the core mixins along with the farming, inventory and render ones; vantage.chat, vantage.containers,
vantage.dungeons, vantage.qol and vantage.visuals each cover their own area.
Every config sets "required": false, and the core one sets defaultRequire to 0. A mixin whose target has moved
in a new Minecraft version therefore degrades to a feature that does nothing instead of a crash. That is quiet by
design, so python tools/mixin_targets.py checks every target before a release; Multi-version
explains it.
Events
Almost everything in the mod talks through one bus, EventBus.INSTANCE. Fabric callbacks and mixins post events;
features listen. The bus dispatches by the event's exact class, with no inheritance, so posting costs one list walk.
A feature listens by annotating a one-parameter method with @Subscribe. The method may be private.
@Subscribe
private void onSecond(Events.SecondTick event) {
// runs once a second, only while this feature is active
}The registry subscribes a feature's annotated methods when it becomes active and removes them when it stops being active, so a feature never checks its own switch. Code that is not a feature can subscribe a lambda directly:
EventBus.INSTANCE.subscribe(Events.ConfigReloaded.class, event -> refresh(), EventBus.PRIORITY_LOW);| Priority constant | Value | Runs |
|---|---|---|
PRIORITY_HIGHEST | -200 | first |
PRIORITY_HIGH | -100 | |
PRIORITY_NORMAL | 0 | the default |
PRIORITY_LOW | 100 | |
PRIORITY_LOWEST | 200 | last |
Some events are cancellable. Cancelling one stops it reaching later listeners, except those registered with
receiveCancelled = true, and post returns true so the poster can act on it: a cancelled ChatMessage is not
shown, a cancelled SlotClick is not sent, a cancelled KeyPress never reaches Minecraft's key mappings. A
listener that throws is logged and the next listener still runs.
What is posted
Every event is a small final class or record in core/event/Events.java:
| Family | Events |
|---|---|
| Ticks and lifecycle | ClientTick (20 a second while a world is loaded), SecondTick, WorldJoin, WorldLeave, ModReady, ConfigReloaded |
| SkyBlock state | SkyblockStateChange, IslandChange, AreaChange, ServerChange, ProfileChange, PurseChange, ScoreboardUpdate, TabListUpdate |
| The player | PlayerDeath (once per death, from whichever of three signals fires first), PlayerRespawn |
| Chat and text | ChatMessage, ActionBar, TitleShown, ChatSend |
| Rendering | HudRender, LevelRender |
| Screens and containers | ScreenOpen, ScreenClose, ContainerOpen, ContainerUpdate, SlotClick, SlotRenderPre, SlotRenderPost, ItemTooltip |
| Input | KeyPress |
| World | EntityAdded, EntityRemoved, BlockChange, SoundPlay, ParticleSpawn |
| Network | PacketReceive, PacketSend |
Unless its Javadoc says otherwise, an event is posted on the client thread. PacketReceive is the exception that
matters: it arrives on the network thread, so its handlers must be trivial and must not touch game state.
ChatMessage and ActionBar can also be rewritten with setReplacement(…). Every chat rewrite in the mod passes
through CoreHooks, which runs it through TextUtils.keepActions: if the original line could be clicked and the
rewrite cannot, the original's click action is put back on the whole line. Commands and
keybinds has more on that rule.
Where am I?
SkyblockState.get() answers the question every feature asks. Its getters are cheap enough to call every frame.
| Getter | Answers |
|---|---|
onHypixel(), inSkyblock(), inLobby() | which network, which game, and whether this is a lobby rather than a game |
island(), area() | the Island constant, and the area line under it ("Forge", "Village") |
serverId(), profileName() | the Hypixel server id, and the SkyBlock profile's name |
ironman(), stranded(), bingo() | the profile's mode |
purse(), bits(), motes(), copper() | the balances Hypixel shows you |
time() | the SkyBlock calendar |
scoreboardLines(), tabListEntries() | the raw lines, colour-stripped |
dungeon() | floor, class, timer, boss phase, party, secrets, score inputs |
The state is fed from three sources: the Hypixel Mod API's location packet first, the scoreboard as a fallback and
for the area, purse and time, and the tab list for the profile and server. When a value changes, the matching
event (IslandChange, PurseChange and the rest) is posted after the getter already returns the new value.
Island is the enum a feature's islands scope uses: HUB, PRIVATE_ISLAND, GARDEN, DUNGEON_HUB, DUNGEON,
DWARVEN_MINES, CRYSTAL_HOLLOWS, MINESHAFT, CRIMSON_ISLE, KUUDRA, THE_RIFT, THE_END, and the rest. Each
carries the mode value the Hypixel Mod API reports for it and the name the scoreboard prints, which doubles as its
display name. For anything the state does not expose, ScoreboardReader,
TabListReader and TabWidgets read the raw sidebar and tab list. RavengardState does the same job for
Ravengard and follows the same shape.
Data
Apart from the one Mojang handshake, which IdentityProof hands to Minecraft's own session service, nothing in the
mod opens its own network connection. Everything else goes through core.api.http.Http:
- one
HttpClient, with every request on a virtual thread and every call returning aCompletableFuture; - an ETag and Last-Modified disk cache under
config/vantage/cache, optionally served stale when the network is down, and marked stale so a caller cannot mistake it for fresh; - per-host rate limiting with backoff on 429, 5xx and network errors, honouring
Retry-After; - the
User-AgentVantage/<mod version> (Minecraft <game version>)on every request.
On top of it, core.api holds the sources: NeuRepo (the NotEnoughUpdates item repository, downloaded into
config/vantage/repo/), Prices (Bazaar and lowest-BIN prices), HypixelApi (keyless resources only),
MojangApi, and ProfileService, which reads SkyBlock profiles through Vantage's own service because the mod
holds no Hypixel API key. The service says what that service is.
core.data keeps the community datasets under config/vantage/data/<id>/: Skyblocker's dungeon room data, a
selection of SkyHanni's constants, and NotEnoughUpdates' constants. Each is parsed from its cached files first, so a
launch without a network serves what was fetched last time; a source that cannot be reached logs one warning and
its accessors return empty collections. Nothing in this layer runs on the render thread.
core.stats is Vantage Stats, opt-in and off by default. Its model and store are free of Minecraft types and
unit-tested; The Stats protocol describes what it sends.
Drawing
Three layers draw things, and each has its own page:
| Layer | Package | Draws | Page |
|---|---|---|---|
| Screens and widgets | core.ui | the menu, popups, every Vantage screen, in Obsidian Glass | The UI toolkit |
| The HUD | core.hud | movable elements, containers, the editor | HUD elements |
| The world | core.render | boxes, lines, beams, world text, waypoints | below |
WorldRenderer is frame-scoped: a feature submits shapes in world coordinates from an Events.LevelRender
handler, or from any client-thread code during the frame, and resubmits them every frame. They are collected and
drawn once per frame during Fabric's COLLECT_SUBMITS stage, which is also where LevelRender is posted. Each
primitive has a depth-tested and a through-walls variant.
Threads
The rule is the one Minecraft imposes: game state is touched only on the client thread. Anything that blocks - the network, the disk - runs on a virtual thread and hands its result back.
| You want to | Use |
|---|---|
| compute off-thread, then use the result on the client thread | Scheduler.async(work, onClient) |
| run something on the client thread from anywhere | Scheduler.runOnClient(…) or Http.onClientThread(…) |
| run something after the current tick, for example open a screen from a command | Deferred.nextTick(…) |
| run something after a number of ticks | Deferred.after(ticks, …) or Scheduler.schedule(…, delayTicks) |
Config saves are debounced by 500 ms and written by a single background thread, vantage-config-saver; HUD layout
saves are debounced by 600 ms. Both are written once more from a JVM shutdown hook.
House rules
These are the ground rules from ARCHITECTURE.md, put for someone outside the original team. They are why the code
looks the way it does, and a change that breaks one is likely to be sent back.
- Never guess a Minecraft or Fabric signature. Read Minecraft's own sources and Fabric API's before calling anything. Minecraft 26.x ships unobfuscated, so the names you read are the real ones.
- Read other mods for facts, never copy them. Skyblocker and SkyHanni are LGPL. They are fine to read for chat message formats, scoreboard and tab-list formats, item data keys, repository layouts and room data. Write your own implementation; Vantage's licence is MIT.
- Compile often.
./gradlew compileJava testbefore you call anything done, and no stub that throwsUnsupportedOperationException. - Java 25, and only Java. Records, sealed interfaces, pattern-matching
switch, text blocks, virtual threads for blocking work,java.net.http.HttpClient. No Kotlin. - One package per subsystem. Keep a change inside its package, and say so when it cannot be.
- Plain English strings. Text the player sees is an English literal in the code, not a translation key, unless it is a vanilla component. The language file holds only the two key mapping names and their category.
- Mixins are the last resort. Prefer a Fabric API event. A mixin goes in
mixin.<area>and is listed in that area's config; an access widener entry goes in both versions'vantage.accesswidener. - The client thread owns the game. Network and file work on virtual threads, posted back through
Minecraft.getInstance().execute(…)orScheduler. - One place touches the Minecraft session. The access token has exactly one call site,
core/stats/sync/IdentityProof.java, which hands it straight to Mojang'sjoinServerand keeps it nowhere. This is a release gate:
grep -rn "getAccessToken\|getSessionId" src/main/javaIt must print exactly one line. Releasing runs it beside the mixin check.
The files under core/event, core/config, core/feature and core/hud are treated as contracts: implement
against them, and when one has to change, make the smallest change that works and say so in the merge request.