Plugins are self-contained jars that any user can drop into their client, no need to fork or rebuild anything. This guide covers setting up a plugin project, building your first plugin, and working with each of the SDK's extension points.
Getting Started
Plugins are built with Gradle + Fabric Loom similar to a Fabric mod. Loom remaps your Minecraft references so they resolve inside the running client. Plugins are loaded by the client at runtime, so you can ship a plugin jar without any dependencies, and users can drop it into their client without needing to install anything.
The Hackware Plugin SDK does not support Mixins due to the sandboxed architecture of the plugin loader.
Prerequisites
Before you can start developing plugins, make sure you have the following installed:
- Gradle or the Gradle wrapper (
./gradlew) if your project already has one - Java 21 or later, and set
JAVA_HOMEto point to it - Minecraft along with a genuine Hackware client installation
You'll also need the Hackware SDK, which provides the stubs for the plugin API, along with its API reference.
Hackware SDKHackware.SDK.jarProject Setup
Scaffold a new Gradle project with the following command:
gradle init --type basic --dsl kotlinThen adjust the settings.gradle.kts and build.gradle.kts files to include the Fabric Maven repository, the Loom plugin, and the Hackware SDK as a compile-only dependency:
pluginManagement {
repositories {
maven {
name = "Fabric"
url = uri("https://maven.fabricmc.net/")
}
mavenCentral()
gradlePluginPortal()
}
}plugins {
id("net.fabricmc.fabric-loom-remap") version "1.15-SNAPSHOT"
}
dependencies {
minecraft("com.mojang:minecraft:1.21.11")
mappings(loom.officialMojangMappings())
modImplementation("net.fabricmc:fabric-loader:0.18.4")
// The SDK is compile-only, never bundle it.
compileOnly(files("libs/Hackware.SDK.jar"))
}Make sure you add the SDK as a compile-only dependency, not a mod implementation. The client already has the SDK, and bundling it will cause classpath conflicts.
Plugin Manifest
Create the plugin manifest in the src/main/resources directory of your project.
Prefer JSON? hackware.plugin.json works too, with the same fields. If a jar somehow carries more than one manifest, the first of .json, .yml, .yaml wins.
# The plugin manifest is a simple YAML file that describes your plugin to the client.
apiVersion: 1
# Plugin metadata, used for display in the plugin list.
id: myplugin
name: MyPlugin
version: 1.0.0
# The fully-qualified name of your plugin's main class, which implements `me.hackware.api.plugin.Plugin`. The client will instantiate this class when loading your plugin.
main: com.example.MyPlugin
# Optional metadata for the plugin list.
authors:
- Your Name
description: A description of your plugin.| Field | Type | Description |
|---|---|---|
apiVersion | integer | The plugin API version your plugin was built against. The client will reject plugins built against a different API version. |
id | string | A unique identifier for your plugin, used to namespace your plugin. |
name | string | The display name of your plugin, shown in the plugin list. |
version | string | The version of your plugin, shown in the plugin list. |
main | string | The fully-qualified name of your plugin's main class, which implements me.hackware.api.plugin.Plugin. |
authors | list of strings | Optional list of authors, shown in the plugin list. |
description | string | Optional description of your plugin, shown in the plugin list. |
Main Class
The class named by main is your plugin's entry point. The client instantiates it when your jar loads and hands it a PluginContext it can use to register features.
The onEnable method should be for registration only. The PluginContext is only valid during onEnable, and will throw if used afterwards. Use it to register your features, then discard it. The client will keep track of your registrations and roll them back if your plugin is unloaded or disabled.
package com.example;
import me.hackware.api.plugin.Plugin;
import me.hackware.api.plugin.PluginContext;
public final class MyPlugin implements Plugin {
@Override
public void onEnable(PluginContext ctx) {
}
}Building
Build your plugin with the Gradle build task:
./gradlew buildShip the jar Loom leaves in build/libs. The build task runs Loom's remap step, which translates your Minecraft references from the Mojang mappings you compiled against to what the running client actually uses.
Do not ship the output of the plain jar task. A non-remapped jar loads fine but dies the moment it touches Minecraft, with NoClassDefFoundError: net/minecraft/.... If you see that error, you shipped the wrong jar.
Drop the compiled jar into hackware/plugins/ inside your Minecraft directory. Every plugin in this folder is loaded at startup and when the client reloads.
While testing, you never need to restart the client:
.reloadunloads every plugin and loads whatever is inhackware/plugins/, picking up a rebuilt jar..reload <id>does the same for just one plugin. Module settings, keybinds, and enabled state survive the round trip..pluginslists what is loaded,.plugins <id>shows one plugin in detail, version, authors, API version, jar file, and modules.
A plugin that throws during load or inside onEnable is disabled rather than crashing the client. Everything it registered is rolled back, the stack trace goes to the log, and a one-line notice appears in chat. The same applies to rejected manifests, duplicate ids, and apiVersion mismatches, so a bad jar never takes the client down.
Extension Points
The Hackware Plugin SDK provides several extension points for adding features to the client. Each extension point is represented by an interface that your plugin can implement and register with the PluginContext. The following sections cover each extension point in detail, with examples of how to use them.
Commands
Commands are chat-triggered actions, invoked with the command prefix (default .). They are handled entirely client-side, a message starting with the prefix is intercepted before it reaches the server. Registered modules automatically get their own .<module> command for toggling and changing settings, so commands are for everything else: one-shot actions, lookups, and anything that takes arguments.
Defining a Command
To define a command, create a class that extends me.hackware.api.command.Command. The constructor takes the command name, a description, and a usage string. The execute method receives the arguments after the command name, split on whitespace.
package com.example;
import me.hackware.api.command.Command;
public final class GreetCommand extends Command {
public GreetCommand() {
super("greet", "Greets a player in chat.", "<player>");
}
@Override
public void execute(String[] args) {
if (args.length < 1) {
usage();
return;
}
info("Hello, &f" + args[0] + "&7!");
}
}The base class provides chat feedback helpers, all rendered client-side: info prints behind an info icon, warn and error behind a warning icon, and usage() prints the usage string from the constructor. Messages support &-style color codes, including hex colors with &#RRGGBB.
Tab Completion
Override getSuggestions to add tab completion for your command's arguments. It receives the current (possibly partial) arguments and returns the matching completions.
@Override
public List<String> getSuggestions(String[] args) {
if (args.length <= 1) {
String partial = args.length == 1 ? args[0].toLowerCase() : "";
return Stream.of("Steve", "Alex")
.filter(name -> name.toLowerCase().startsWith(partial))
.toList();
}
return List.of();
}Registering a Command
To register a command, call PluginContext.registerCommand during onEnable in your plugin's main class. The command is available in chat immediately, .greet Steve in this example.
package com.example;
import me.hackware.api.plugin.Plugin;
import me.hackware.api.plugin.PluginContext;
import com.example.GreetCommand;
public final class MyPlugin implements Plugin {
@Override
public void onEnable(PluginContext ctx) {
// Register the GreetCommand with the PluginContext
ctx.registerCommand(new GreetCommand());
}
}HUD Elements
HUD elements are the draggable overlays rendered in-game, the module list, ping, and potion timers are all HUD elements. Plugins can add their own element types, which users place, move, resize, and configure in the HUD editor exactly like the built-ins.
Defining an Element
To define an element, create a class that extends me.hackware.api.hud.HUDElement and implement renderContent and updateSize. For a single line of text, extend TextHUDElement instead, it handles sizing and rendering and only asks for buildText and getColor. The constructor takes an element id and a default position; the client stores positions as a screen anchor plus offset, so elements keep their place across resolution changes.
package com.example;
import me.hackware.api.hud.TextHUDElement;
public final class StreakHud extends TextHUDElement {
public StreakHud(String id, int x, int y) {
super(id, x, y);
}
@Override
protected String buildText() {
// Called every frame. &-style color codes are parsed.
return "Streak: &a" + ExampleModule.getStreak();
}
@Override
protected int getColor() {
// The HUD module's global text color setting
return defaultTextColor();
}
}Registering an Element
To register an element type, call PluginContext.registerHudElement during onEnable in your plugin's main class.
The built-in elements register themselves from a static TYPE initializer, which works because the client force-loads their classes at startup. The loader does not scan plugin jars for element classes, so a static initializer in your jar never runs on its own, always register through the PluginContext.
package com.example;
import me.hackware.api.plugin.Plugin;
import me.hackware.api.plugin.PluginContext;
import com.example.StreakHud;
import java.util.Set;
public final class MyPlugin implements Plugin {
@Override
public void onEnable(PluginContext ctx) {
ctx.registerHudElement(
"MYPLUGIN_STREAK", // type name
"myplugin-streak", // id prefix
"streak", // menu label
true, // persist
StreakHud.class,
StreakHud::new, // factory
Set.of(), Set.of() // aliases
);
}
}| Parameter | Description |
|---|---|
typeName | The canonical key instances are saved under in the HUD config. Prefix it with your plugin id so it cannot collide with a built-in or another plugin. |
idPrefix | Prefix used to mint fresh element ids when the user adds one from the HUD editor. |
menuLabel | The label shown in the HUD editor's "New" menu. Pass null to hide the type from the menu. |
persist | When true, user-placed instances are saved in the HUD config and recreated through your factory at launch. Plugin elements almost always want true, false is for singletons the client re-creates itself. |
elementClass | Your element class. Also decides which section of the "New" menu the type appears in: text elements, list elements, or components, derived from the base class, never declared. |
factory | Builds an instance for a given (id, x, y). Called both when the user adds an element and when the saved HUD layout is restored. |
typeAliases, idPrefixAliases | Alternate spellings accepted when deserializing, for migrating a renamed type. Set.of() for a new element. |
Registering a type doesn't put anything on screen, it adds an entry to the HUD editor's "New" menu, and the user places instances from there. With persist set, those instances (position, settings, everything) survive restarts as long as your plugin is loaded. If the plugin is unloaded, the type is unregistered and its elements disappear with it.
If your element is just formatted text, extend TemplateTextHud instead of TextHUDElement, its content is a user-editable template that runs the same scripting language as Scripting Providers, the built-in ping element is nothing more than the template Ping: {client.ping}ms.
Modules
Modules are the primary extension point for adding features to the client. A module is a self-contained feature that can be enabled or disabled by the user. Modules can have settings, keybinds, and categories, and can be registered with the PluginContext during onEnable.
Defining a Module
To define a module, create a class that extends me.hackware.api.module.Module.
package com.example;
import me.hackware.api.module.Category;
import me.hackware.api.module.Module;
public final class ExampleModule extends Module {
public ExampleModule() {
super("ExampleModule", "An example module.", Category.COMBAT);
}
@Override
public void onEnable() {
// Called when the module is enabled
}
@Override
public void onDisable() {
// Called when the module is disabled
}
}Registering a Module
To register a module, call PluginContext.registerModule during onEnable in your plugin's main class.
package com.example;
import me.hackware.api.plugin.Plugin;
import me.hackware.api.plugin.PluginContext;
import com.example.ExampleModule;
public final class MyPlugin implements Plugin {
@Override
public void onEnable(PluginContext ctx) {
// Register the ExampleModule with the PluginContext
ctx.registerModule(new ExampleModule());
}
}Custom Categories
Modules can be organized into categories, which are used to group modules in the client UI. The Hackware Plugin SDK allows you to add modules to existing categories or create your own custom categories. To create a custom category, extend me.hackware.api.module.Category and register it with the PluginContext.
package com.example;
import me.hackware.api.module.Category;
import me.hackware.api.plugin.Plugin;
import me.hackware.api.plugin.PluginContext;
import com.example.ExampleModule;
public final class MyPlugin implements Plugin {
@Override
public void onEnable(PluginContext ctx) {
// Create a custom category for your plugin's modules
Category category = ctx.registerCategory("myplugin", "Category", 0x00ff88ff);
ctx.registerModule(new ExampleModule(category));
}
}Then adjust the constructor of your module to accept a Category parameter and pass it to the superclass constructor.
package com.example;
import me.hackware.api.module.Category;
import me.hackware.api.module.Module;
public final class ExampleModule extends Module {
public ExampleModule(Category category) {
super("ExampleModule", "An example module.", category);
}
}Subscribing to Events
Modules can subscribe directly to the event bus by using @SubscribeEvent on methods that take a single parameter of the event type. The Hackware Plugin SDK uses a custom event bus that is separate from Minecraft's, so you can safely subscribe to events without interfering with the game's internal event system.
package com.example;
import me.hackware.api.event.impl.PacketEvent;
import me.hackware.api.event.impl.TickStartEvent;
import me.hackware.api.event.SubscribeEvent;
import me.hackware.api.module.Category;
import me.hackware.api.module.Module;
public final class ExampleModule extends Module {
public ExampleModule() {
super("ExampleModule", "An example module.", Category.COMBAT);
}
@SubscribeEvent
private void onPacket(PacketEvent event) {
// Called when a packet is received
}
@SubscribeEvent
private void onTick(TickStartEvent event) {
// Called at the top of every client tick
}
}Adding Settings
Modules can have settings that users can configure in the client UI. The Hackware Plugin SDK provides a variety of setting types, including booleans, numbers, strings, and enums.
package com.example;
import me.hackware.api.event.impl.TickStartEvent;
import me.hackware.api.event.SubscribeEvent;
import me.hackware.api.module.Category;
import me.hackware.api.module.Module;
import me.hackware.api.setting.impl.BooleanSetting;
import me.hackware.api.setting.impl.EnumSetting;
import me.hackware.api.setting.impl.NumberSetting;
public final class ExampleModule extends Module {
public enum FreecamMode { CREATIVE, LINEAR }
private final EnumSetting<FreecamMode> mode = addSettings(new EnumSetting<>("Mode", "Camera movement behavior.", FreecamMode.CREATIVE));
private final NumberSetting speed = addSettings(new NumberSetting("Speed", "Camera movement speed.", 1.0).range(0.1, 10.0).step(0.1));
private final BooleanSetting noclip = addSettings(new BooleanSetting("Noclip", "Allows the camera to pass through blocks.", false));
public ExampleModule() {
super("ExampleModule", "An example module.", Category.COMBAT);
}
@SubscribeEvent
private void onTick(TickStartEvent event) {
// Get the current values of the settings
FreecamMode currentMode = mode.get();
double currentSpeed = speed.get();
boolean isNoclipEnabled = noclip.get();
}
}Scripting Providers
The client's HUD text elements run a small template language, {player.health}, {round(player.speed, 1)}, resolved live as the HUD renders. A scripting provider lets your plugin add its own variables and functions to that language, so users can build HUD elements around your plugin's data. More information on the template language is available in the Scripting Guide.
Defining a Provider
A provider is a plain class annotated with @Namespace, whose @Property methods become template members registered under the namespace prefix. The method name is the member name, and the signature decides the kind:
- A method with no parameters is a variable:
{myplugin.streak} - A method taking a
List<String>is a function:{myplugin.pad(7, 3)}, called with the already-resolved arguments
Return the value as a string, or null when it is unavailable.
package com.example;
import me.hackware.api.scripting.ArgumentDescription;
import me.hackware.api.scripting.Namespace;
import me.hackware.api.scripting.Property;
import me.hackware.api.scripting.PropertyDescription;
import java.util.List;
@Namespace("myplugin")
public final class MyProvider {
@Property
@PropertyDescription("Your current kill streak.")
String streak() {
return Integer.toString(ExampleModule.getStreak());
}
@Property
@PropertyDescription("Pad a number with leading zeros.")
@ArgumentDescription(name = "num", value = "The number to pad.")
@ArgumentDescription(name = "width", value = "The minimum number of digits.")
String pad(List<String> args) {
try {
int width = Integer.parseInt(args.get(1));
return String.format("%0" + width + "d", Long.parseLong(args.get(0)));
} catch (Exception e) {
return null;
}
}
}The @PropertyDescription and @ArgumentDescription texts aren't just documentation, the template editor's IntelliSense shows them as the signature and per-argument help while the user types.
Use your plugin id as the namespace. A bare @Namespace registers members at the top level next to the built-in functions, where a name collision is much more likely. Namespaces also nest: a @Namespace-annotated inner class composes its prefix onto the outer one, the way the built-in player.position.x is structured.
Registering a Provider
To register a provider, call PluginContext.registerScriptProvider during onEnable in your plugin's main class. Its members are immediately available in every HUD template, and they disappear again when your plugin is unloaded.
package com.example;
import me.hackware.api.plugin.Plugin;
import me.hackware.api.plugin.PluginContext;
import com.example.MyProvider;
public final class MyPlugin implements Plugin {
@Override
public void onEnable(PluginContext ctx) {
// Register the MyProvider with the PluginContext
ctx.registerScriptProvider(new MyProvider());
}
}