For developers
API Guide
Eymistaken's HUD was engineered with extensibility at its core. Use the EymistakenHudPlugin API to inject custom modules seamlessly into the HUD rendering and ticking engines — no core modification required.
01
Modular Architecture
The entire HUD system is orchestrated by HudModuleManager. On initialization it registers the 7 built-in modules. Via the eymistaken_hud Fabric entrypoint, third-party addons can register additional modules that are loaded and managed alongside the core ones automatically, and subscribe to HUD events.
Each module extends HudModule, which handles render position injection, rainbow color cycling, and the settings contract behind both the editor's context menu and your module's own settings screen page. On each client tick, SimpleCPSClient calls HudModuleManager#tickAll(Minecraft); during HUD rendering, it calls renderAll(GuiGraphicsExtractor, float). The render pipeline runs through the client-side HUD registry — modules receive a GuiGraphicsExtractor context and implement extractRenderState(GuiGraphicsExtractor, float) to draw their content. Automatic stacking is handled for TOP_LEFT, TOP_RIGHT, BOTTOM_LEFT, and BOTTOM_RIGHT; CENTER modules are centered rather than stacked, so multiple centered modules can overlap unless your addon offsets them.
02
Adding the Dependency
Use JitPack to compile your addon mod against Eymistaken's HUD 26.3. Add the following to your build.gradle:
repositories {
maven { url 'https://jitpack.io' }
}
dependencies {
modImplementation 'com.github.Eymistaken:Eymistaken-s-HUD:main-SNAPSHOT'
}After reloading your Gradle project, the current 26.3 API classes — including HudModule, IHudElement, HudModuleSetting, and all six setting types — will be available in your development environment. To pin to a fixed build instead, put a release version in place of main-SNAPSHOT — without the leading v, which JitPack strips from the tag — and the latest release page will tell you which one that is.
03
Creating a Custom Module
Every HUD element must extend HudModule and implement its abstract methods. Below is the minimum required implementation:
import com.eymistaken.simplecps.api.HudModule;
import com.eymistaken.simplecps.SimpleCPSConfig;
import net.minecraft.client.gui.GuiGraphicsExtractor;
public class MyCustomModule extends HudModule {
@Override
public String getName() {
return "My Custom Tracker";
}
@Override
public SimpleCPSConfig.Position getPositionType() {
return SimpleCPSConfig.Position.TOP_LEFT; // Default corner alignment
}
@Override
public int getXOffset() { return 0; }
@Override
public int getYOffset() { return 0; }
@Override
public boolean isEnabled() {
return true; // Tie to your own config
}
@Override
public int getWidth() {
return 100; // Required for stacking layout
}
@Override
public int getHeight() {
return 20;
}
@Override
public void extractRenderState(GuiGraphicsExtractor context, float tickDelta) {
float currentScale = getScale() / 100f;
context.pose().pushMatrix();
context.pose().translate((float) this.x, (float) this.y);
context.pose().scale(currentScale, currentScale);
// After translating, drawing should be done relative to 0,0 (the local origin).
// drawText() applies the player's chosen HUD font, the Text Outline setting
// and the current fade alpha for you.
drawText(context, "Hello World", 0, 0, 0xFFFFFFFF);
context.pose().popMatrix();
}
@Override
public void tick(net.minecraft.client.Minecraft client) {
// Optional: runs every client tick (timers, click tracking, etc.)
super.tick(client);
}
}Important: Always return accurate values from getWidth() and getHeight(). Returning 0 removes your module from the stacking layout and makes it invisible.
Unique Name Requirement: The value returned by getName() must be strictly unique. It is the key for your module's layout flags and its saved state, so a collision would silently corrupt both. registerModule() refuses a name that is already taken and logs an error instead of registering the module. Prefix yours with your mod id — for example "myaddon:tracker". Call manager.isNameTaken(String) beforehand if you want to check yourself.
Inherited Variables Note: When a module extends the HudModule class, the variables this.client (Minecraft.getInstance()), this.x, and this.y are automatically inherited. Developers do not need to define these manually.
Three inherited helpers make your module respect the player's global appearance settings. Use them instead of drawing text directly:
drawText(context, text, x, y, color)— draws in the active HUD font, honours the Text Outline setting and the current fade alphatextWidth(text)— measures a string in the active HUD font (raw font widths are wrong once a custom font is selected)col(argb)— applies the current fade alpha to any color, for anything you draw yourself
Modules opt into the smooth fade-in / fade-out that plays when they appear or disappear by overriding supportsFade(). It defaults to false, in which case your module pops in and out as before. Only override it to true if every color you draw goes through col() or drawText() — otherwise your module will stay fully opaque while the rest fades.
@Override
public boolean supportsFade() {
return true;
}04
Advanced: Sub-Elements (Complex Modules)
If a module consists of multiple independently draggable parts (such as the ArmorModule), developers can override the getSubElements() method inside HudModule. This method should return a list of objects that implement the IHudElement interface. This allows the HUD Editor to treat these sub-parts as independent draggable elements instead of dragging the entire main module.
@Override
public List<IHudElement> getSubElements() {
// Return a list of independent sub-elements (e.g., Helmet, Chestplate, etc.)
// If empty, the entire module is treated as a single draggable element.
return mySubElementsList;
}05
HUD Editor Integration (Drag & Drop)
Override the optional setter methods to allow players to move, scale, and reset your module in the HUD Editor. Important: save all values to your own mod's config — never to SimpleCPSConfig.
// Inside your MyCustomModule class:
@Override
public void setPositionType(SimpleCPSConfig.Position pos) {
MyAddonConfig.anchor = pos; // Save to YOUR config
}
@Override
public void setXOffset(int x) { MyAddonConfig.xOffset = x; }
@Override
public void setYOffset(int y) { MyAddonConfig.yOffset = y; }
@Override
public void setScale(int scale) { MyAddonConfig.scale = scale; }
@Override
public int getScale() { return MyAddonConfig.scale; }
@Override
public void resetToDefaults() {
// Called by "Reset Position" in the context menu
MyAddonConfig.anchor = SimpleCPSConfig.Position.TOP_LEFT;
MyAddonConfig.xOffset = 0;
MyAddonConfig.yOffset = 0;
MyAddonConfig.scale = 100;
}If you skip these overrides, your module will still render correctly — but players will not be able to move or scale it via the HUD Editor.
Whenever the HUD Editor finishes moving a module, its onPositionUpdated() method is triggered. Developers can override this method to act as a listener, allowing them to automatically save the module's new position to their own config files.
@Override
public void onPositionUpdated() {
// Called after a drag is released, after an arrow-key nudge,
// and after the align / distribute tools move the module
MyAddonConfig.save();
}Grid and smooth snapping: both are applied by the editor before it calls your setters, so there is nothing to implement. Your setXOffset() / setYOffset() are called once per frame with the position the module should be at right now, including while a snap animation is playing — write them straight through and let onPositionUpdated() decide when to persist.
07
The Settings Screen
The right-click menu is not the only place your module can be configured. The moment you register it, your module also gets its own page in the mod's settings screen — listed in the sidebar under PLUGINS, opened from Mod Menu or from the HUD Editor. You do not have to build it; it is generated from what your module already exposes.
Declare no tabs of your own and two are assembled for you:
- POSITION — anchor picker, X / Y offsets and scale, driven by the same editor setters you already implement. Nothing extra to write.
- SETTINGS — your settings, from the two lists below. Skipped entirely if you expose none.
Which brings us to the split. getContextMenuSettings() stays exactly as it was and remains the only source for the editor's right-click menu — but everything it returns now appears on your settings page as well. That menu should stay short, so anything that belongs in the full screen and nowhere else goes in getSettingsScreenSettings():
import com.eymistaken.simplecps.api.*;
import java.util.List;
// Inside your MyCustomModule class:
@Override
public List<HudModuleSetting> getSettingsScreenSettings() {
List<HudModuleSetting> settings = new java.util.ArrayList<>();
settings.add(new ColorSetting("Background Color",
() -> MyAddonConfig.bgColor,
color -> { MyAddonConfig.bgColor = color; MyAddonConfig.save(); }
));
settings.add(new SliderSetting("Background Opacity",
0, 255, 128,
() -> MyAddonConfig.bgOpacity,
val -> { MyAddonConfig.bgOpacity = val; MyAddonConfig.save(); }
));
return settings;
}With no tabs declared, the page lists the context-menu settings first, then these — concatenated in order, using the same six setting types. It defaults to an empty list, so addons written before it existed keep working and simply show their context-menu settings on both surfaces.
Nothing is de-duplicated. Put each setting in one list or the other — a setting named in both is shown twice, because the two lists are joined as-is. There is no matching by label, and therefore no hidden rule about what you may call things.
Two things the generated page cannot do for you. The per-row reset arrow only works for SliderSetting, since that is the one type that carries a defaultValue — for the rest it is drawn inert. And the ENABLED / DISABLED badge is read-only for addon modules, because HudModule has no setter for it. Expose a BooleanSetting if you want players to switch your module on and off from here, and an ActionSetting for a reset button that covers everything at once.
One flat list stops being readable once it has grown past a handful of rows. When it does, override getSettingsTabs() and split the page yourself. A tab is a plain record — SettingsTab(String id, String name, List<HudModuleSetting> settings) — with three parts:
id— a stable key. The screen remembers which tab you were on by it, so keep it constant between calls.name— the label on the tab strip. Upper-cased for you, to match the built-in pages, and it falls back to theidwhen left blank.settings— the rows on that tab, in order, from the same six setting types.
import com.eymistaken.simplecps.api.*;
import java.util.Arrays;
import java.util.List;
// Inside your MyCustomModule class:
@Override
public List<SettingsTab> getSettingsTabs() {
return List.of(
new SettingsTab("display", "DISPLAY", List.of(
new ColorSetting("Text Color",
() -> MyAddonConfig.textColor,
color -> { MyAddonConfig.textColor = color; MyAddonConfig.save(); }
),
new SliderSetting("Scale %",
50, 300, 100,
() -> MyAddonConfig.scale,
val -> { MyAddonConfig.scale = val; MyAddonConfig.save(); }
)
)),
new SettingsTab("behavior", "BEHAVIOR", List.of(
new CycleSetting("Display Mode",
Arrays.asList("Compact", "Full", "Minimal"),
() -> MyAddonConfig.displayModeIndex,
idx -> { MyAddonConfig.displayModeIndex = idx; MyAddonConfig.save(); }
)
))
);
}Declaring tabs does not throw the two flat lists away, it only moves them. The context-menu settings lead the first tab you declared — the contract that everything on the editor's right-click menu also appears on the page still holds — and getSettingsScreenSettings() is no longer concatenated onto the end; it becomes a trailing SETTINGS tab of its own.
Four things the page decides for you. POSITION is always added, so do not declare placement rows yourself. A tab whose settings are all empty or of an unrecognized type is dropped rather than drawn blank. Two tabs sharing an id are renumbered rather than one going missing. And the tab strip does not scroll — tabs past the panel's width are simply not drawn, so keep to a handful of short names.
Players reach your page either from the sidebar, or straight from the editor: right-clicking your module now offers Open Settings at the bottom of its menu, which jumps directly to it. Right-clicking one of your sub-elements lands on the same page — sub-elements do not get pages of their own.
08
Live Preview
Settings that change how something looks are miserable to tune blind. Pick a color, close the menu, squint at the HUD, come back. Do that fifteen times and the round trip has cost more than the decisions did.
The top of your page's right-hand column can carry a PREVIEW card: your module, drawn live at the size it will really be, on a checkerboard that shows transparency for what it is instead of flattering every light color against a dark panel. Edits land in it the same frame. A focus button in its header expands the card over the rest of the screen when a module is too big to judge in a sidebar.
Nothing appears until you opt in. getPreview() returns null by default, and a null preview means no card at all rather than an empty one — the INFO, PRESETS and SHARE cards simply move up to fill the space. There is no blank state to design around.
A preview is three methods. Two report size in screen pixels — the same units as getWidth(), scale already applied — and one draws:
package com.eymistaken.simplecps.api;
public interface HudPreview {
// Size in screen pixels, the same units as HudModule.getWidth().
int width();
int height();
// Draw with the top-left corner at (0, 0).
void render(GuiGraphicsExtractor ctx, float tickDelta);
// A preview that is simply the module drawing itself.
static HudPreview ofModule(HudModule module) { ... }
}You will rarely implement it by hand. Most previews are the module rendering exactly as it always does, with stand-in values for data the game is not currently producing — no clicks have been counted, no server is connected, and since the settings screen opens from the main menu too, there may be no player or world at all. HudPreview.ofModule wires the module's own render path up for you, and isPreviewing() is where you substitute the data:
import com.eymistaken.simplecps.api.*;
import java.util.List;
// Inside your MyCustomModule class:
@Override
public HudPreview getPreview() {
return HudPreview.ofModule(this);
}
// Stand-in rows, so the card has something to show with no world loaded.
private static final List<String> PREVIEW_ROWS =
List.of("Water Breathing 1:27", "Strength II 3:34");
private List<String> rows() {
if (isPreviewing()) return PREVIEW_ROWS;
return client.player == null ? List.of() : liveRows();
}
@Override
public void extractRenderState(GuiGraphicsExtractor ctx, float tickDelta) {
int y = 0;
for (String row : rows()) {
drawText(ctx, row, 0, y, MyAddonConfig.textColor);
y += client.font.lineHeight + 1;
}
}
@Override
public int getWidth() {
// The same helper, so the card is sized for what it actually draws.
int w = 0;
for (String row : rows()) w = Math.max(w, textWidth(row));
return w;
}The detail that matters is rows() feeding the render path and the measurement. isPreviewing() is true for the whole pass, sizing included, precisely so the two can agree — report one size and draw another and the card is built for something it never shows.
The card owns the geometry, not you. It measures you, sizes itself, centers you, applies the transform that puts one of your pixels on one real screen pixel, and clips you to the box — so render draws from the origin and never translates or scales itself. The card grows downward to fit and stops before it would push the panels below it off screen; it never widens, and a module wider than the column is clipped rather than shrunk. That is deliberate: a preview scaled to fit would answer the wrong question about how much room your module takes. Focus mode is the way to see the rest of it.
Never step animation state the live HUD reads. With the settings screen open in-game your module is drawn twice a frame — once on the real HUD, once in the preview — so any per-frame counter advanced inside your render path runs at double speed for as long as the menu is up. Keep a preview-local copy, or leave the counter alone while isPreviewing(). The built-in keystrokes module holds separate squish and ripple maps for exactly this reason.
When the preview should not be the module at all — a legend, a labeled comparison, a fixed illustration — implement the interface directly and draw whatever you like:
@Override
public HudPreview getPreview() {
return new HudPreview() {
@Override public int width() { return 96; }
@Override public int height() { return 28; }
@Override
public void render(GuiGraphicsExtractor ctx, float tickDelta) {
ctx.fill(0, 0, 96, 28, 0x40FFFFFF);
drawText(ctx, "Water Breathing 1:27", 2, 2, MyAddonConfig.textColor);
drawText(ctx, "Strength II 3:34", 2, 14, MyAddonConfig.textColor);
}
};
}getPreview() is called once a frame while your page is open, so keep it cheap and prefer handing back a cached instance to building one per call. Anything that throws — the getter, either size, or the drawing — costs you the preview for that frame and nothing else; the screen carries on. And like every other hook here it is additive and defaults to nothing, so an addon compiled against an older build of the HUD keeps running untouched, simply without a card.
09
Plugin State (Presets, Share Codes & Server Configs)
Your settings live in your own config file, and that keeps working exactly as before. What it cannot do on its own is travel: when a player saves a preset, copies a share code or lets a server config load, only Eymistaken's HUD config moves — your module's settings stay behind, and the layout the player carefully built comes back half wrong.
Override saveState() and loadState() to opt in. Whatever you return is stored under your getName() and carried through all four persistence paths for free: simplecps.json, named presets, EYMHUD1- share codes, and per-server configs.
import com.google.gson.JsonElement;
import com.google.gson.JsonObject;
// Inside your MyCustomModule class:
@Override
public JsonElement saveState() {
JsonObject state = new JsonObject();
state.addProperty("enabled", MyAddonConfig.enabled);
state.addProperty("anchor", MyAddonConfig.anchor.name());
state.addProperty("xOffset", MyAddonConfig.xOffset);
state.addProperty("yOffset", MyAddonConfig.yOffset);
state.addProperty("scale", MyAddonConfig.scale);
state.addProperty("textColor", MyAddonConfig.textColor);
return state;
}
@Override
public void loadState(JsonElement state) {
if (!state.isJsonObject()) return;
JsonObject obj = state.getAsJsonObject();
// Check has() on every key: the blob may come from an older or newer
// build of your addon, or from a player who never touched this setting.
if (obj.has("enabled")) MyAddonConfig.enabled = obj.get("enabled").getAsBoolean();
if (obj.has("xOffset")) MyAddonConfig.xOffset = obj.get("xOffset").getAsInt();
if (obj.has("yOffset")) MyAddonConfig.yOffset = obj.get("yOffset").getAsInt();
if (obj.has("scale")) MyAddonConfig.scale = obj.get("scale").getAsInt();
if (obj.has("textColor")) MyAddonConfig.textColor = obj.get("textColor").getAsInt();
MyAddonConfig.save();
}Both default to a no-op, so plugins written before this existed keep compiling and running unchanged.
- Return
nullfromsaveState()to store nothing — that is what every built-in module does, since their settings are already fields onSimpleCPSConfig loadState()is never called withnull. When no blob is stored for your module the call is skipped entirely, so a config written by someone who does not have your addon leaves your settings untouched instead of resetting them- It runs once at registration and again on every preset, share code or server config that gets applied — so it can fire at any point after startup, not just during init
- Do not put JSON nulls anywhere in the tree you return. The serializer drops them at every depth, so they will not survive a round trip — omit the key instead
saveState()runs on every config save. Keep it cheap
Your blob survives players who do not have your addon. If someone exports a preset containing your module and hands it to a player who has not installed it, the state rides along inert and lights up the day they do install it. The reverse holds too: importing a config from a player who lacks your module will not wipe your settings.
Both methods are wrapped in a try/catch by HudModuleManager. A throw is logged and skipped, and your module keeps its current settings — it will not take down the save or the import. Do not rely on that as error handling, but you do not have to defend against every malformed value either.
10
Events
Third-party mods can react to things happening inside the HUD through EymistakenHudEventBus — a static singleton you subscribe to from registerEvents(), which is called once at client init, right after registerHudModules().
import com.eymistaken.simplecps.HudModuleManager;
import com.eymistaken.simplecps.api.EymistakenHudEventBus;
import com.eymistaken.simplecps.api.EymistakenHudPlugin;
import com.eymistaken.simplecps.api.event.ComboStreakEvent;
public class MyPluginInit implements EymistakenHudPlugin {
@Override
public void registerHudModules(HudModuleManager manager) {
manager.registerModule(new MyCustomModule());
}
@Override
public void registerEvents(EymistakenHudEventBus bus) {
bus.subscribe(ComboStreakEvent.class, event -> {
if (event.level() >= 2) {
playHitSound(event.target());
}
});
}
}Available events live under com.eymistaken.simplecps.api.event:
ComboStreakEvent(int combo, int level, int previousLevel, Entity target)— fired when a landed hit pushes the combo into a higher heatmap tier, so once per tier rather than once per hit
Levels come from ComboHeatmap and follow the player's chosen heatmap mode, so level() means the same thing to you as it does to the on-screen color. The event fires whether or not heatmap coloring is switched on — that setting decides how the counter looks, not whether the streak happened.
import com.eymistaken.simplecps.api.ComboHeatmap;
// Thresholds behind the levels, if you want to display them yourself
ComboHeatmap heat = ComboHeatmap.of(SimpleCPSConfig.instance.comboHeatmapMode);
int tier1 = heat.tier1(); // EASY 3 | MEDIUM 5 | HARD 10
int tier2 = heat.tier2(); // EASY 5 | MEDIUM 8 | HARD 40
int tier3 = heat.tier3(); // EASY 7 | MEDIUM 12 | HARD 60
int level = heat.levelFor(17); // 0 (cold) through 3 (top tier)Handlers run on the client thread, inline with whatever caused the event. Keep them short — anything slow will stutter the game. A handler that throws is logged and skipped; it never takes the rest of the mod down with it.
Dispatch is by exact class. Subscribing to a supertype does not receive its subclasses. Subscribing is safe at any point after client init, not just from registerEvents().
11
Registering the Plugin
Declare an entrypoint implementing EymistakenHudPlugin in your fabric.mod.json. Your modules will be passed to HudModuleManager#registerModule() and managed alongside the built-in ones from that point forward — no core modification required.
Implementation class:
import com.eymistaken.simplecps.api.EymistakenHudPlugin;
import com.eymistaken.simplecps.HudModuleManager;
public class MyPluginInit implements EymistakenHudPlugin {
@Override
public void registerHudModules(HudModuleManager manager) {
manager.registerModule(new MyCustomModule());
}
// Optional — see the Events section. Default no-op, so plugins written
// before it existed keep compiling and running unchanged.
@Override
public void registerEvents(EymistakenHudEventBus bus) { }
}fabric.mod.json:
"entrypoints": {
"eymistaken_hud": [
"com.myname.myaddon.MyPluginInit"
]
}The eymistaken_hud entrypoint key is processed by SimpleCPSClient during initialization. Every registered module is automatically included in the tickAll() and renderAll() cycles of HudModuleManager, and becomes fully editable in the in-game HUD Editor.
Ordering: plugins are discovered after the config has already been read, so registerModule() replays your stored state into your module the moment it arrives — you do not have to wait for a reload or defer your own loading.