Background
Back to News

DEVLOG #2: MODDING IN SHARD

July 19, 2026HytaleModding

Our last post ended with a promise; the next one would get into the tech behind Shard and why we made the choices we did. This is that post, and the subject is modding.

Hytale has always been shaped as much by what its community builds on top of it as by the game itself, so "can you extend it?" isn't a question we get to answer later. A server that can't be modded isn't really a Hytale server. This post is about how Shard approaches modding, and it's honestly one of the more involved problems we've taken on, for two separate reasons.

The first comes from a decision about how Shard itself is compiled, which quietly took away the mechanism most C# plugin systems rely on. The second is a commitment to the mods that already exist, the Java ones people are writing right now need a way onto Shard too. We'll spend most of this post on the first and close on the second.

The usual reminder; Shard is an early, independent community experiment, and everything here will change as we learn. None of it is a promise about the final shape of the system.

Heads up: this post gets deep into technical weeds. Jump to any section:

Why NativeAOT Makes Modding Difficult

Before looking at the technical challenges of building a modding system for NativeAOT, it is important to understand how .NET and Java applications traditionally support external code.

In a conventional managed environment, the application and its plugins run under the same runtime. For .NET, this runtime is CoreCLR; for Java, it is the JVM. The runtime loads the application, discovers external assemblies or JARs, resolves their types, and allows code from both sides to interact using the same object model.

This shared runtime is what makes traditional plugin systems relatively straightforward.

shard-coreclr-runtime.png

Because both sides live inside the same managed environment, the server can load a plugin assembly at runtime and interact with it through ordinary .NET constructs.

A plugin can implement an interface defined by the server:

public sealed class ExampleModule : BaseModule
{
    public override void Start()
    {
        Log.Info("Hello from the plugin!");
    }
}

From the runtime's perspective, BaseModule, Log, and the plugin instance are all managed objects. Method calls, interface dispatch, inheritance, strings, arrays, exceptions, and garbage-collected references are understood by the same runtime.

What Changes with NativeAOT?

NativeAOT fundamentally changes this architecture. Instead of shipping managed IL and relying on CoreCLR to load and compile it at runtime, NativeAOT compiles the application ahead of time into a native executable. The resulting server no longer depends on CoreCLR to execute its application code.

This provides important benefits for Shard: faster startup, lower runtime overhead, predictable deployment, and a fully native server binary. However, it also removes the mechanism that conventional .NET plugin systems rely on.

A NativeAOT executable cannot simply load an arbitrary managed plugin assembly and begin executing it. There is no shared CoreCLR instance available to load the assembly, JIT-compile its methods, and connect its managed objects to the server.

NativeAOT does not merely change how the server is compiled. It removes the shared managed runtime that traditional plugin systems depend on.

shard-server-plugins.png

How Shard Rebuilds the Plugin Boundary

shard-plugin-boundary-nativeaot.png

Shard solves this problem by compiling external plugins as native shared libraries and generating the communication layer between the server and each plugin.

Both the server and the plugin are still written in C#. Plugin developers continue to work with familiar interfaces, classes, properties, and methods from Shard.Modding.SDK. However, these managed abstractions do not cross the native boundary directly.

During compilation, Shard's source generator analyzes the public SDK contracts and produces the native glue required on both sides of the boundary. This generated communication layer is composed of several mechanisms, each replacing a capability that would normally be provided by the shared managed runtime.

Native exports expose the plugin entry points that the server discovers through the operating system's native library loader. Once loaded, function pointers allow methods to be invoked across the native boundary, while generated vtables represent interfaces and object capabilities without relying on a shared CLR type system.

On the other side of the boundary, generated proxies reconstruct the familiar SDK-facing API expected by plugin code. Calls made through these proxies are translated into native ABI operations and forwarded to the real implementation on the other side.

Values crossing the boundary require similar treatment. Explicit marshalling converts strings, arrays, GUIDs, primitive values, and other supported types into stable ABI representations. When an object cannot cross the boundary directly, opaque handles preserve its identity without exposing a managed object reference to an independently compiled binary.

The goal is not to make NativeAOT behave like CoreCLR. The goal is to preserve the developer-facing programming model while replacing the runtime mechanisms underneath it.

The Real Technical Challenge

The difficult part is not loading a native library. Operating systems already provide mechanisms for that.

The difficult part is preserving a high-level, object-oriented plugin API across a boundary where neither side can exchange ordinary managed objects.

Consider a simple property such as:

IModuleContext Context { get; } 

Under CoreCLR, this is an ordinary managed reference. With independently compiled NativeAOT binaries, the server cannot simply return its ModuleContext object to the plugin. The plugin has its own compiled code, its own native image, and no shared managed object model through which that reference can be interpreted.

Shard therefore represents the object through a generated ABI contract. The plugin receives a proxy, while calls made through that proxy are forwarded to the real server-side object through generated native function pointers.

Our Approach To External Mods

C# Mods

We call plugins "Modules" in our codebase. A C# Module is an extension of the codebase.

Everything a Module can do flows through Shard.Modding.SDK, the one package a mod author references. We deliberately shaped it to mirror Hytale's own Java plugin system, so the concepts carry over: a mod is a class, it has a manifest, it hooks into a lifecycle, and it talks to the server through a small set of shared systems.

A Module extends BaseModule and declares a Manifest, much like a Hytale JavaPlugin does. The manifest carries an id, a version, and its dependencies, and the server uses those dependencies to resolve a load order, so a mod that builds on another always starts after it. From inside a Module you get a handful of typed entry points into the server: Services for resolving shared systems like the command registry, Events for the event bus, and Log for logging. Anything you register through your module is tracked and cleaned up automatically when the module unloads, so reloading a mod doesn't leave anything behind.

The lifecycle is a few stages you override as you need them. OnSetup runs first, while the server is still wiring itself together, and is where you register commands, subscribe to events, and declare anything other modules might depend on. OnStart runs once the server is up and ready. OnShutdown runs on the way down. There's also an async preload stage for the rare mod that needs to do slow work, such as loading data, before the server considers itself ready.

A finished mod ships as a single .dll. Because it's compiled ahead of time into a self-contained native library, everything the mod needs (its own code, any libraries it depends on, and its manifest) ends up bundled into that one file, much the way a Java fatjar packs a plugin and all of its dependencies into a single jar. You drop that one .dll into the mods folder and you're done: there's no archive to assemble and no folder layout to get right.

From there the server takes over. It reads the manifest embedded in each .dll, resolves a load order from the declared dependencies, and loads every mod in isolation alongside its own built-in systems, which are written against the exact same Module model. The server, in other words, is built out of the same kind of pieces it lets you write.

Below is a very basic example of a module:

public class ExampleModule : BaseModule
{

    protected override void OnSetup()
    {
        Log.Info($"Setting up {Manifest.Id} module.");

        var commandRegistry = Services.Resolve<ICommandRegistry>();
        commandRegistry.Register(new ExternalCommandTest());
    }

    protected override void OnStart()
    {
        Log.Info($"Starting {Manifest.Id} module.");
    }

    protected override void OnShutdown()
    {
        Log.Info($"Shutting down {Manifest.Id} module.");
    }
}

Example Command

public sealed class ExternalCommandTest() : CommandBase("ectest", "External command test.")
{
    public override void Execute(ICommandSender sender, IReadOnlyList<string> args)
    {
        sender.SendMessage("Hello from ExternalCommandTest!");
    }
}

A command extends CommandBase with a name and a description, and implements Execute. The two arguments it receives are the important part: an ICommandSender and the parsed arguments. The sender abstracts away who ran the command, whether that's a player in-game or the server console, so the same command works from either and can reply with sender.SendMessage(...). Registering it is the single call to the command registry shown above.

Because that registration goes through your module, the server knows which commands belong to which mod. When a module unloads, its commands and their aliases unregister with it, so reloading a mod never leaves a stale command behind. Commands can also nest: a container command groups sub-commands under one name, which is how a larger mod keeps a family of related commands tidy.

Example Event Listener

Events are how a mod reacts to things happening on the server without having to poll for them. A module subscribes to an event type during OnSetup, and the server calls back into the mod whenever that event is published.

protected override void OnSetup()
{
    Events.Subscribe<ServerReadyEvent>(OnServerReady);
}

private void OnServerReady(ServerReadyEvent e)
{
    Log.Info("The server is ready, and this mod is listening.");
}

Subscribe takes the event type as a generic parameter and the handler to run, and hands back a disposable you can use to unsubscribe. You rarely need to: like commands, subscriptions made through your module's Events bus are removed automatically when the module shuts down.

A couple of options are worth knowing. Handlers can declare a priority, so a mod that needs to run before or after others can order itself deterministically. And for events that can be cancelled, a handler can opt in to still receive an event that another handler already cancelled, which is useful for a mod that logs or overrides decisions rather than making them. There's also an async variant for handlers that need to do awaitable work.

The Other Half: The Mods That Already Exist

Everything up to here has been about mods written for Shard, in C#, against our SDK. But there's a whole ecosystem that already exists; people are writing Hytale mods today, in Java, against the Java server's API. Supporting those isn't something we're treating as a maybe. It's a hard requirement we've taken on, and it's the single largest problem left on our modding roadmap.

Here's the shape of it. A Java mod is rarely self-contained. It's a small amount of code that calls into the Hytale server's API, the same world, entity, event, and command systems the Java server exposes. Those systems are written in Java on the official server; on Shard they're reimplemented in C#. So the interesting challenge isn't "run Java code," it's "present the Hytale API a mod expects, and back every call with Shard underneath."

The tempting shortcut is to translate the Java into .NET ahead of time and be done with it, and there's prior art for exactly that. We looked hard at it and decided against it. Hytale's server is built on a very modern Java, leaning on native interop, virtual threads, and language features a translator would have to reimplement and then keep reimplementing with every new Java release. It's a research project against a moving target, and at the end of it you still haven't solved the API problem. So we aren't going to reimplement a JVM. We're going to use a real one.

Our plan is to run Java mods on an actual JVM, in a separate process that sits behind the same plugin boundary as everything else. The rest of Shard doesn't know or care whether a mod is C# or Java; it only ever sees the boundary. Running the JVM in its own process buys two things that matter a great deal for code people download from the internet: if a mod misbehaves, crashes, or hangs, it takes down only itself and not the server, and it can be sandboxed properly at the operating-system level.

On the JVM side, we present the Hytale API the mods were compiled against as a thin layer that forwards each call back across the boundary into Shard's C# systems, the very same systems our C# mods use. A Java mod registers an event handler exactly the way it always has; when that event happens, Shard reaches across and runs it. Because Hytale mods are event-driven by nature, that crossing happens on discrete events rather than constantly, which is a large part of what makes the approach practical.

We want to be honest about the size of this. Faithfully standing up an entire server's API is close to a project of its own, and it has to keep pace with Hytale's API as that API changes. We'll aim to support the documented plugin surface, not every conceivable mod that reaches into internals. And in the spirit of the last post: we believe this is the right design and we've convinced ourselves the pieces fit, but there is real distance between that and dropping any jar into a folder and watching it run. It's R&D, and we'll show it working before we call it done.

The One Major Caveat

There's a tradeoff buried in all of this that we'd rather name now than have someone discover later. Running Java mods means running a full JVM right next to Shard, and a JVM is a heavy, demanding thing: it expects a real operating system, a filesystem, room to spawn processes, and a fair amount of memory and CPU to sit alongside the server it's serving.

Part of the appeal of a lean, natively compiled C# server is that it can, at least in principle, run in places the Java server never could. We aren't committing to any of this, but a small native binary at least lets us think about constrained hardware, and someday maybe even consoles or mobile devices. The catch is that those are exactly the platforms that won't host a full JVM. They tend to be locked down and memory-limited, and generally don't let an application bring along a second heavyweight runtime and execute arbitrary downloaded bytecode inside it.

So the two ambitions pull against each other. On a normal dedicated server, running a JVM beside Shard is no trouble at all, and Java mod support works exactly as described above. But if Shard ever did run somewhere a JVM can't follow, that's the one capability that couldn't come with it: you'd have the lean C# server and C# mods, while the Java compatibility layer would simply be absent on that platform.

We don't see this as a reason not to build it. The overwhelming majority of servers run on machines that host a JVM without blinking, and that's who this is for. But it's an honest limit of the approach, and the kind of thing we'd rather say out loud now than surprise anyone with later.

An Update On Shared Source

Back in our last post, we said opening the code up publicly and opening up to contributors were part of the same conversation, and that we'd cover both once we had more to say. We've got an update on one half of that.

We're opening up the source code of the Shard server under a Shared Source License Agreement. We have Hytale's code in our repository, and can't make it public due to that reason.

Note: Having access to Hytale's Shared Source does not automatically give you access to Shard's Source. It's just a requirement. You will need to complete the flow mentioned below to get access.

Here's how it works:

  1. Click here to start the flow
  2. Authenticate with your Hytale account to confirm that you have Hytale Shared Source Access.
  3. Authenticate with your GitHub account so we know who to invite, we only store your GitHub username.
  4. Accept our Shard Shared Source License Agreement
  5. You're in! Check your email or visit https://github.com/HytaleModding-Team to accept the invitation.

Contributions are still the thing we're holding back on. We want a solid foundation before we ask anyone to build on top of code that's still shifting under us on a weekly basis, and once we're there, we'll say so in a future devlog. Making the source available is a separate step from opening the gates to outside changes, and this post is us taking the first one.

What's Next

That's it for this post! We hope that this gives you a good overview of how we plan to approach Modding in Shard.