Horizon is a MIXIN wrapper for PaperMC servers and forks, expanding plugin capabilities to allow for further customization and enhancements. Horizon is a project that is intended to supersede a project by one of the core team members(Dueris), the project Eclipse. Eclipse was a plugin for Paper that allowed loading SpongePowered Mixins and access wideners and transformers. This project, of course, came with many issues and drawbacks that made Eclipse hard to work with most of the time. And so, Dueris archived the project and decided to create Horizon, which is the successor of Eclipse.
Horizon intends to fix the issues from Eclipse and create a more manageable, workable, and stable environment for plugins to work with, while incorporating plugin authors' ideas in a much more powerful and flexible manner.
Horizon acts more as a replacement wrapper for Paperclip(the launcher for Paper servers and forks). It boots the game in a very similar way, as it contains an iteration of the Paperclip launcher. Please read the sections below to learn more about how Horizon works and how to develop with it.
Horizon tries not to break much of anything; however, there are some things it's incompatible with.
- The legacy plugin loader. The legacy plugin loader(
LegacyPluginLoadingStrategy.java) is completely unsupported in Horizon. This is due to Horizon having a few internal mixin injections to theModernPluginLoadingStrategy.javafile, and just supporting the legacy format is not in the scope of development ATM, as people should be using the modern plugin loading strategy. To ensure you are using the modern strategy, please ensure your server does not contain the startup flag-Dpaper.useLegacyPluginLoading=true. - UniverseSpigot. Due to how Universe's loader is set up, Horizon is fundamentally incompatible with Universe. Do not ask CanvasMC or Universe for support; it will not work and is not planned to work.
- Spigot and Bukkit. Horizon strictly works only for Paper servers and forks, and is untested on Spigot and Bukkit, and you will not receive support for using Spigot or Bukkit with Horizon
Horizon is simple to install and get running. You can download Horizon from our website, https://canvasmc.io, and from
there it is as simple as dropping the downloaded JAR file
into the same directory as your server JAR. DO NOT REPLACE THE SERVER JAR!! Horizon works as an external wrapper
for the server JAR, so the server JAR needs to be present for Horizon
to function correctly. The default file Horizon will search for is server.jar, which is configurable wth the
horizon.yml configuration file:
pluginsDirectory: plugins
serverJar: server.jar
cacheLocation: cache/horizon
extraPlugins: [ ]
serverName: horizonThis horizon.yml file is intended for more extensive configuration of Horizon, allowing setting the server JAR name,
since not all servers will have their server JAR named server.jar, or you can then
have multiple server JARs and swap between the target Horizons they use.
- The
cacheLocationis simply for storing JIJ plugins and such, and is cleared on each boot of Horizon. We don't recommend changing it, but you can if there are conflicts or some issue arises, and you need to change the location. - The option
extraPluginsallows for adding additional plugins to the Horizon classpath to be loaded. Horizon also reads from the--add-pluginJVM argument that is passed to the server - The
serverNameoption is an optional override for the server mod name, as it gets overridden in Horizon automatically by its internal mixin inject - The
pluginsDirectoryoption should always point to your plugins directory for both Paper plugins and Horizon plugins; however, you can separate them if you need or want to.
Once all options are configured to your liking, you can boot the Horizon JAR as usual, and your server will run with Horizon as its bootstrapper!
Note
You can disable internal mixin injections by using flags like -DHorizon.disable.mixin.{mixin package location}
Developing with Horizon is generally simple. One tool you can use is the Gradle plugin, which is described in more detail below. To start with, developing a Horizon plugin, you need to add a few more things to your plugin YML. You need something like this:
horizon:
mixins:
- "mixins.test.json"
wideners:
- "wideners.at"
- "additional_wideners.at"
load-datapack-entry: trueHorizon reads from the plugin.yml or paper-plugin.yml from the plugin JAR entries to build the ObjectTree
representing your plugin configuration.
Each option in the horizon field is optional; the only required field is horizon for your plugin to be marked and
loaded by Horizon.
mixins- This is aString[]option that defines the SpongePowered mixin configuration files in your plugin artifact. Like if it weretest.mixins.json, the entry should be in the root of your resources, namedtest.mixins.jsonwideners- This is the same as themixinsfield, but defines Forge access transformers for your plugin. The team initially wanted to use Fabric wideners, but to keep consistency with Paper's build system and paperweight, we decided it would be best to use transformers instead. You can find detailed documentation regarding Forge access transformers hereload-datapack-entry- This is just abooleanoption,falseby default, that defines if your plugin should be loaded as a datapack entry too, similar to how the Fabric loader loads mods as datapacks too. While somewhat useless in more modern versions of the game due to Papers lifecycle API, this introduces a more direct way to load your plugin as a datapack, and also supports the/minecraft:reloadcommand to reload your plugin datapack assets
Note
More information on Mixins and ATs is available in the Mixins && ATs section below.
To check if your plugin is loaded as a Horizon plugin successfully, you can read the plugin data tree in the startup logs, which will look similar to this:
[23:23:34] [INFO]: Found 2 plugin(s):
- horizon local
- testplugin 1.0.0-SNAPSHOT
Note
You can enable debug logging for Horizon with -DHorizon.debug=true
Another way is by checking the /plugins command, which is replaced with the Horizon internal mixins to include Horizon
plugins.
You can learn about how to use our Gradle plugin to assist in Horizon plugin development below, along with JIJ. It is also recommended to familiarize yourself with the new classloader hierarchy in the New Classloading Tree section
Another capability Horizon plugins have is JIJ(JAR In JAR). JIJ is a feature that allows Horizon plugins to attach
Horizon plugins, Paper plugins, or external libraries.
All JIJ plugins will be loaded from the horizon.yml:cacheLocation configured location, which is fetchable via Horizons
API, which is documented below.
All Paper plugins will be loaded as usual, and their plugin data folders will remain in the same place. Horizon plugins
will function exactly the same, and will load
mixins like normal. External libraries will be appended to the game classpath with Ember, as if they were libraries
added by the server JAR, and will be accessible after Horizon launches the game.
In order to start developing plugins for Horizon, it is required that you use the horizon gradle plugin in your build
scripts together with the weaver-userdev plugin. The horizon plugin automatically applies your ATs to the server
JAR your plugin is going to be developed against, allowing you to compile against it and access the server's internals,
and the userdev plugin allows it to achieve all that.
Below is shown an example build.gradle.kts configuration structure to give you an idea on how to start developing!
plugins {
id("io.canvasmc.weaver.userdev") version "xxx"
id("io.canvasmc.horizon") version "xxx"
}
dependencies {
horizon.horizonApi("1.0.0") // <- required for accesing the Horizon API in dev.
paperweight.paperDevBundle("1.21.11-R0.1-SNAPSHOT")
}
horizon {
accessTransformerFiles.from(
file("src/main/resources/wideners.at"),
file("src/main/resources/additional_wideners.at"),
)
}In addition, using the shadow gradle plugin is unsupported and you should instead opt-in to JiJ'ing your
dependencies by using the appropriate configurations, just like this:
dependencies {
includeMixinPlugin("io.canvasmc:nice-horizon-plugin:1.0.0")
includePlugin("io.canvasmc:nice-plugin:1.0.0")
includeLibrary("io.canvasmc:nice-library:1.0.0")
}The above configurations define in what directory the dependency is going to be placed in the JAR file structure, their names are pretty intuitive in themselves, however each one is described in detail below anyway.
For a Horizon-based plugin dependency, the appropriate configuration is includeMixinPlugin, which puts it under
META-INF/jars/horizon.
For a normal plugin dependency, aka not-horizon, you should use the includePlugin configuration, which places it under
META-INF/jars/plugin.
And finally, for a library, the configuration to use is includeLibrary, which places it under META-INF/jars/libs.
All of those dependencies will be loaded at the server startup, for a more detailed overlook refer to the JIJ section.
For obvious reasons, the classloading hierarchy tree has changed. A diagram is shown below with a general visual example of what this looks like
Paper (and forks) is similar to this tree; however, Ember is replaced with a normal URLClassLoader. The DYNAMIC
classloader shown in the diagram
is a URLClassLoader instance that allows modification of the URLs added to it post-init. Horizon plugins, instead of
being loaded with every other
plugin in their own classloader, are linked to the Ember classloader. This does mean that Horizon plugins cannot
invoke code in non-Horizon plugins.
One way to get around this is by using an abstraction layer and a 2nd plugin. You could have a Paper plugin and a Horizon plugin loaded(using JIJ too for more compact file managing) and create interfaces in the Horizon plugin that the Paper plugin implements, of which the Paper plugin will then access code in other non-Horizon plugin JARs. By implementing a system like this, you could create a Horizon plugin that can communicate with other Paper plugins. Do note, though, that all Horizon plugins are visible to the Paper plugin classloaders.
Horizon uses a powerful data API called the ObjectTree API for parsing and managing hierarchical immutable data
structures. OT provides a type-safe way to work with multiple file formats including JSON, YAML, TOML, and
PROPERTIES
OT is an immutable, thread-safe API that represents file data as a tree structure. It provides mutli-format support, type-safe conversions, alias support, variable interpolation, custom serialization, multiple reader inputs, and strong error handling.
Basic reading of a file is like so:
// Read JSON data
ObjectTree config = ObjectTree.read()
.format(Format.JSON)
.from(new FileInputStream("config.json"))
.parse();
// Access values with automatic type conversion
String serverName = config.getValue("serverName").asString();
int port = config.getValue("port").asInt();
boolean enabled = config.getValue("enabled").asBoolean();
// Access nested structures
ObjectTree database = config.getTree("database");
String host = database.getValue("host").asString();
// Access arrays
ObjectArray plugins = config.getArray("plugins");
for (int i = 0; i < plugins.size(); i++) {
// Converts each entry in the ObjectArray to a String and prints it
System.out.println(plugins.get(i).asString());
}When reading from JAR entries:
// Parsing a plugin metadata for example
try (InputStream stream = jarFile.getInputStream(entry)) {
ObjectTree metadata = ObjectTree.read()
.format(Format.YAML)
.from(stream)
.parse();
String pluginName = metadata.getValue("name").asString();
String version = metadata.getValue("version").asString();
}OT provides methods for type conversion with automatic validation:
ObjectValue value = config.getValue("key");
// Built-in type conversions
// If unable to convert for any reason, it will throw a TypeConversionException
String str = value.asString();
int i = value.asInt();
long l = value.asLong();
double d = value.asDouble();
float f = value.asFloat();
boolean b = value.asBoolean();
BigDecimal bd = value.asBigDecimal();
BigInteger bi = value.asBigInteger();
// Optional variants (returns empty Optional on failure)
Optional<Integer> maybeInt = value.asIntOptional();
Optional<String> maybeStr = value.asStringOptional();Register custom converters for your own types:
ObjectTree config = ObjectTree.read()
.format(Format.JSON)
// Note: this is for example, both UUID and File are implemented by default
.registerConverter(File.class, obj -> new File(obj.toString()))
.registerConverter(UUID.class, obj -> UUID.fromString(obj.toString()))
.from(inputStream)
.parse();
File pluginDir = config.getValue("pluginsDirectory").as(File.class);
UUID serverId = config.getValue("serverId").as(UUID.class);For complex objects, register custom deserializers:
// Define your data class
record ServerConfig(String host, int port, boolean ssl) {
}
// Register deserializer
ObjectTree config = ObjectTree.read()
.format(Format.JSON)
.registerDeserializer(ServerConfig.class, tree ->
new ServerConfig(
tree.getValue("host").asString(),
tree.getValue("port").asInt(),
tree.getValueOptional("ssl")
.map(v -> v.asBoolean())
.orElse(false)
)
)
.from(inputStream)
.parse();
// Deserialize directly to your type
ServerConfig server = config.as(ServerConfig.class);Note
For converting an ObjectTree -> T, register a deserializer and use the ObjectTree#as method. For converting an
ObjectValue -> T, register a type converter and use ObjectValue#as method
Support multiple key names that map to the same value:
ObjectTree config = ObjectTree.read()
.format(Format.YAML)
.alias("host", "db_host", "databaseHost", "dbHost")
.alias("port", "db_port", "databasePort", "dbPort")
.from(inputStream)
.parse();
// This makes it so your YAML file you are parsing can use `db_host`, or `dbHost` in the
// actual YAML file, but when read it is remapped to `host`, to be fetched like below
String host = config.getValue("host").asString();Note
You cannot access values via nested keys, like "test.example", any attempts will throw a NoSuchElementException
OT supports variable substitution using ${variable} syntax:
ObjectTree config = ObjectTree.read()
.format(Format.YAML)
.withVariable("region", "us-west-2")
.from(inputStream)
.parse();
// YAML file with variables:
// server:
// endpoint: https://${region}.example.com
// dataDir: ${env.HOME}/horizon
String endpoint = config.getTree("server").getValue("endpoint").asString();
// Result: "https://us-west-2.example.com"
String dataDir = config.getTree("server").getValue("dataDir").asString();
// Result: "/home/user/horizon" (uses environment variable)Builtin variable sources:
- Environment variables:
${env.VARIABLE_NAME} - System properties:
${sys.property.name} - Custom variables: Added via
.withVariable()
Create and save data programmatically:
// Build an example data structure
ObjectTree config = ObjectTree.builder()
.put("serverName", "My Server")
.put("port", 25565)
.build();
// Write as JSON
try (FileWriter writer = new FileWriter("config.json")) {
ObjectTree.write(config)
.format(Format.JSON)
.to(writer);
}
// Or get as string
String yaml = ObjectTree.write(config)
.format(Format.YAML)
.toString();All formats are interchangeable, you can read data in one format and write it in another. There is also safer access with optionals:
// Get optional values
Optional<ObjectValue> maybeValue = config.getValueOptional("optional-key");
Optional<ObjectTree> maybeTree = config.getTreeOptional("optional-section");
Optional<ObjectArray> maybeArray = config.getArrayOptional("optional-list");
// Chain optionals for safe access
int port = config.getTreeOptional("server")
.flatMap(server -> server.getValueOptional("port"))
.map(v -> v.asInt())
.orElse(25565);For working with Mixins and ATs, it is HIGHLY recommended that you use IntelliJ IDEA with the Minecraft Development plugin. This plugin will help autofill entries and such that will be extremely useful for development.
Access Transformers (ATs) are used to widen member visibility and to add or remove the final modifier from classes,
methods, and fields. They allow plugin developers to access and modify members that would otherwise be inaccessible.
AT files are parsed line-by-line at startup and applied during class transformation at load time.
Note
Any text following a # character until the end of the line is treated as a comment and will be ignored by the
parser.
Each definition begins with an access modifier that defines the new visibility of the targeted member. Modifiers are listed below in decreasing order of visibility:
public— Accessible from all classes, regardless of packageprotected— Accessible within the same package and by subclassesdefault— Accessible only within the same packageprivate— Accessible only within the declaring class
In addition, the special suffixes +f and -f may be appended to any access modifier to respectively add or *
remove* the final modifier.
The final modifier prevents:
- Subclassing (for classes)
- Method overriding (for methods)
- Field reassignment (for fields)
Warning
Access Transformers apply only to the exact member they reference. Overriding methods are not implicitly transformed. If a transformed method has an override with more restrictive visibility, the JVM will reject the class and fail to load it. As a result, only the following categories of methods are generally safe to transform:
privatemethodsfinalmethods (or methods declared infinalclasses)staticmethods
Care should be taken when widening non-final instance methods, especially those that may be overridden.
To target a class, use the following syntax:
<access modifier> <fully qualified class name>
Inner classes are referenced by joining the outer and inner class names using $ as a separator:
com.example.OuterClass$InnerClass
To target a field:
<access modifier> <fully qualified class name> <field name>
Methods require a full JVM method descriptor, including parameter and return types:
<access modifier> <fully qualified class name> <method name>(<parameter types>)<return type>
ATs use standard JVM type descriptors as defined in more technical detail here - Java Virtual Machine Specification, SE 8, sections 4.3.2 and 4.3.3
B—byteC—char(Unicode character code point in UTF-16)D—doubleF—floatI—intJ—longS—shortZ—boolean
[— Indicates one dimension of an array- Example:
[[Srepresentsshort[][]
- Example:
L<class name>;— Reference type using internal JVM naming- Example:
Ljava/lang/String;representsjava.lang.String
- Example:
(<params>)<return>— Method descriptor format Example:(I)Z— Takes anint, returnsbooleanV— Void return type (only valid as a return type) Example:()V— No parameters, returns nothing
Horizon comes packaged with the latest Fabric-SpongePowered Mixin library and MixinExtras. Mixin is the core package and system included in Horizon, and is the main system plugins can use in Horizon to modify the server internals.
Mixins allow you to inject, redirect, overwrite, or otherwise modify bytecode of existing classes without directly patching or forking the upstream server codebase
To enable mixins for a plugin, declare one or more mixin configuration files in your plugin metadata:
horizon:
mixins:
- "mixins.example.json"Each entry must reference a mixin configuration file packaged in your plugin JAR resources
If the horizon.mixins block is present, Horizon will treat the plugin as mixin-capable and process its mixins during
bootstrap.
This is an example of a SpongePowered Mixin configuration file:
{
"required": true,
"package": "io.example.myplugin.mixin",
"mixins": [
"ExampleMixin",
"AnotherExampleMixin"
]
}-
package
- Base Java package containing all mixin classes declared in this configuration.
-
mixins
- A list of mixin class names (relative to package) that apply in the Horizon environment.
-
required
- If true, Horizon will fail fast if any mixin in this config fails to apply.
A basic mixin class looks like the following:
@Mixin(MinecraftServer.class)
public abstract class MinecraftServerMixin {
@Inject(
method = "runServer",
at = @At("HEAD")
)
private void horizon$onServerStart(CallbackInfo ci) {
// injected code here
}
}The Mixin above injects code into the MinecraftServer#runServer method at the HEAD of the method. Mixins can target
classes, methods, constructors, and <client>.
Horizon bundles MixinExtras with the server, allowing more enhanced and expanded capabilities with Mixin. It is
recommended to read over the Mixin and MixinExtras documentation pages linked below.
Mixins and ATs are very complementary, with ATs modifying the access and finality of fields, methods, and classes, and Mixins injecting and modifying code in the server.
Due to Horizon plugins and server internals being loaded by the Ember classloader, Mixins can target all server internals and some library internals. There are however, some restrictions to what places you can inject into. For example, you cannot inject into Horizon internals, for obvious reasons. The full list of excluded packages is below:
-
io.canvasmc.horizon.*- All Horizon internals, except
io.canvasmc.horizon.inject.*. This exception allows Horizon’s own internal mixin/injection logic to function while protecting the rest of the framework from external mutation.
- All Horizon internals, except
-
org.tinylog.*,org.slf4j.*,org.apache.logging.log4j.*- Logging libraries used by Horizon and NMS. Transforming logging frameworks can cause insane issues, and as such is prohibited.
-
org.spongepowered.asm.*,com.llamalad7.mixinextras.*- SpongePowered Mixin core implementation and MixinExtras. Transforming Mixin itself is unsafe and will almost certainly break class transformation.
Horizon plugins cannot transform classes in non-Horizon plugins due to classloader constraints. Horizon plugins CAN transform classes by other Horizon plugins, and packages not excluded by Horizon.
Note
There is a remap field in the injection annotations, this does nothing in Horizon.
Horizon includes API for Horizon plugins to interact with to better understand its environment and other plugins in the server. This part of the documentation is still a WIP, as there is more API being planned, and more API to document.
Horizon implements a way to view Paperclip version metadata, which contains Minecraft version info, pack info, etc. Currently, Horizon exposes the Paperclip version info and pack info, which can be obtained and used quite simply:
// Here is an example of how to fetch the Horizon instance and read server version information
// Note: there are *many* more methods, and it is best to look at the Javadocs for each class aswell
Horizon horizon = Horizon.INSTANCE;
FileJar paperclip = horizon.getPaperclipJar();
getLogger().info("Paperclip JAR: " + paperclip.ioFile().getName());
PaperclipVersion version = horizon.getPaperclipVersion();
getLogger().info("Minecraft Version: " + version.name());
getLogger().info("Protocol Version: " + version.protocol_version());
getLogger().info("Java Version: " + version.java_version());
getLogger().info("Is stable?: " + version.stable());
PaperclipVersion.PackVersion pack = version.pack_version();
getLogger().info("Pack Version(resources):" + pack.resource_major() + "." + pack.resource_minor());
getLogger().info("Pack Version(data):" + pack.data_major() + "." + pack.data_minor());There may be more things exposed in the future about the Paperclip data, but it is currenly not planned, as it isn't really something any plugin would need.
Horizon exposes the JVM Instrumentation that Horizon uses for its booting of the server. It isn't really recommended to touch the Instrumentation directly, but there are APIs available that do call methods in it, which are more safe to use and are more recommended for some operations, like appending a jar file to the classloader.
// Gets the MixinLaunch instance, which contains the classloader,
// and other more advanced API like class transformers
MixinLaunch launcher = MixinLaunch.getInstance();
// This contains data like the String[] args that will be passed to the server
// along with inital game connections, and the actual game jar
MixinLaunch.LaunchContext launchContext = launcher.getLaunchContext();
// This is the JVM Instrumentation that can be accessed for doing more advanced operations
// although, it is recommended to use any API provided by Horizon that functions
// similarly or as a replacement to the API that the Instrumentation provides, as it will
// function safer and better for the Horizon environment
java.lang.instrument.Instrumentation instrumentation = JvmAgent.INSTRUMENTATION;
// This is the Ember ClassLoader, which is the primary bytecode modification
// loader for the Horizon environment. Here you can append new jars to the classpath too!
EmberClassLoader classLoader = launcher.getClassLoader();
classLoader.tryAddToHorizonSystemLoader(Paths.get("test.jar"));The code above is a brief showcase of some things you can access and do with the Instrumentation and ClassLoader exposure. If you want more information on how Instrumentations work, it is linked below:
Horizon introduces a full plugin API that is exposed for all plugins to access. This allows for numerous things like viewing what other plugins are on the server, which can be used for dependency declaration(coming soon), incompatibilities, etc. This API also allows you to view nested data entries and more. All data in each plugin is immutable, and shouldn't be attempted to be modified.
// Get the Horizon instance
Horizon horizon = Horizon.INSTANCE;
// This includes *all* Horizon plugins, nested and unnested
PluginTree plugins = horizon.getPlugins();
for (HorizonPlugin plugin : plugins.getAll()) {
// The HorizonMetadata class acts as an object representation of the
// Horizon plugin yaml file. It contains the name, version, mixins,
// ats, api version, etc.
HorizonMetadata metadata = plugin.pluginMetadata();
getLogger().info("Hello " + metadata.name() + "!");
// The plugin identifier is the same as the 'name' entry in the metadata
String id = plugin.identifier();
// The HorizonPlugin also provides the FileJar instance that is tied
// to the plugin, and also a FileSystem via HorizonPlugin#fileSystem()
FileJar file = plugin.file();
getLogger().info("Path of \"" + id + "\": " + file.ioFile().toPath());
}The plugin API is a useful tool for when trying to get other plugins data, or even your own plugins data.
Horizon contains a ClassTransformer API, which is the primary API that drives Mixins and ATs transformation of ClassNodes. With this API, soon, plugins can register their own TransformationServices much like the Mixin and AT transformation services. This API hasn't been fully exposed yet, and is currently only limited to the Mixin and AT transformers, but in the near future, before the initial release, this API will be fully exposed and documented.

