Skip to content

Modules

Minh edited this page Aug 16, 2026 · 2 revisions

Alright, let's get started. We'll show examples for most of what you'll need. Some finer details will be made clear by your IDE, and Thorn's own modules can also serve as examples.

0. Setting up Thorn for development

  1. Assuming you already know how to write ULTRAKILL mods and can set up a project folder. If you don't, take a look at the Ultramodding wiki
  2. Grab the latest release of Thorn from Thunderstore or GitHub
  3. From the zip package, extract plugins/ThornClient.dll and plugins/ThornClient.xml
  4. Reference the .dll in your mod project. The .xml should sit in the same folder for documentation.
  5. If you have a BaseUnityPlugin for your mod, declare Thorn as a dependency to make sure it (and any Module) loads before your plugin: [BepInDependency("com.github.end-4.thornClient")]

1. The Module class + minimal example

This is the base class for configurable entries. You subclass it, ship it in your mod's assembly and it'll be detected+loaded automatically, much like BepInEx plugins. It's not a full replacement though, since it's not a MonoBehaviour and doesn't have all those usual lifecycle methods.

As an example, here's a module that constantly drains V1's health. The code is also available at the Thorn examples repo. Copy it to your project and start messing with it!

using System;
using ThornClient.Core;
using ThornClient.Core.ConfigurableElements;
using ThornClient.Managers;
using ThornClient.System;
using UnityEngine;

namespace ThornExamples;

// We subclass a Module...
public class Bleeding : Module {
    // Replace this with your own icon...
    // AssetManager in this case is from ThornClient.Managers
    public override Sprite Icon => AssetManager.Get<Sprite>(ClickGUI.BundleKey, "cube");

    // Tags for searching in the menu.
    // Use related keywords that are not already present in the module name
    public override string[] Tags => ["hurt", "hp"];

    // Getter for cheat reason. A non-empty string means it's cheaty. You should declare any module that alters the mechanics as cheaty.
    // It's necessary to call `CheatManager.UpdateCheatiness();` when this becomes true
    //   - In this case, it's when the module gets enabled, which we will see below...
    public override string CheatReason => IsEnabled ? "Enables non-standard gameplay" : "";

    // We declare settings to allow customization of the bleeding
    // Setting is from the ThornClient.Core namespace
    public Setting<int> DamagePerTick;
    public Setting<float> DamageTickInterval;

    // This is the constructor, passing the GUID, name, and description to the base Module class.
    // The GUID should be unique. it's recommended to use a yourPluginName.yourModuleName
    //   syntax, but as long as you are absolutely sure it's unique, it's fine.
    public Bleeding() : base("thornExamples.bleeding", "Bleeding", "Makes you constantly bleed",
        ModuleCategory.Gameplay) {
        // Instantiate the settings. In order, the fields are:
        // - GUID: The identifier that's unique within your module. This name is used in the config file.
        // - Name: The user-friendly name, displayed on the config menu
        // - Description: The long text describing what the setting does. This shows up when hovering the config menu entry
        // - Default value
        DamagePerTick = CreateSetting(
            "dmgPerTick", "Damage per tick", "How much to bleed each tick", 5
        );
        DamageTickInterval = CreateSetting(
            "dmgTickInterval", "Damage tick interval", "Duration between damage ticks", 2f
        );
    }

    // This method runs once when the module is enabled. Here you should add any setup or event subscribing...
    // If it's enabled from a previous session, this will also trigger on game launch.
    protected override void OnEnable() {
        CheatManager.UpdateCheatiness(); // As we discussed earlier...
        Console.WriteLine("Bleeding module ENABLED");
    }

    // This runs once when the module is disabled. Unsubscribe events here...
    protected override void OnDisable() {
        Console.WriteLine("Bleeding module DISABLED");
    }

    // Just for convenience
    private static NewMovement? nm => NewMovement.Instance;
    private static StatsManager? sman => StatsManager.Instance;

    // Here's the plan: we constantly poll in the update loop. If the time since last
    //   damage tick is greater than the interval, we damage. The below variable is to
    //   track that time since last damage tick
    private float _cumulatedTime = 0;

    // This is run every frame, similar to MonoBehaviour.Update()
    public override void OnUpdate() {
        // Null check
        if (nm == null || sman == null) return;

        // Skip damaging if the run hasn't started
        if (!sman.timer) return;

        // Keep track of the time since last damage tick
        _cumulatedTime += Time.deltaTime;

        // If it's been long enough, we damage
        if (_cumulatedTime >= DamageTickInterval.Value) {
            TickDamage();
            _cumulatedTime %= DamageTickInterval.Value;
        }
    }

    private void TickDamage() {
        if (nm == null) return;
        nm.GetHurt(DamagePerTick.Value, false, 1);
    }
}

For reference, you can also take a look at Thorn's own modules in the ThornClient/Modules folder of its repo.

Module for configuration

A few tips to keep it nice and accessible by other classes of your mod:

  • Declare Settings as static
  • Have a static Instance field that stores a reference of your module, and assign it in the constructor with Instance = this;

An example is Crossover's configuration module, CrossoverConfig.cs.

2. The details

The above example will almost certainly be insufficient for real-world use cases, so we strongly recommend you go through this section.

The Setting

(Unlike PluginConfigurator,) the data and UI are separate in Thorn. We declare Settings and optionally attach UI hints, and the menu will generate the config UI accordingly.

Currently, it's not possible to add your own UI, but this is planned in the future.

As seen above, you can add a setting to a Module with CreateSetting(guid, name, description, defaultValue).

Supported data types

While Setting<T> allows any type at compile time, but not all will work. Types that are supported by default are: ( Thorn's own data types will be elaborated below)

  • bool
  • int and float
  • string
  • Enum types: you can declare enum MyEnumType then Setting<MyEnumType>
  • Keybind (in ThornClient.Core.DataTypes)
  • EnemyList (in ThornClient.Core.DataTypes)
  • Color (in UnityEngine)

Custom data types

You can add other data types, but note that:

  • The data type must be serializable to JSON by Newtonsoft's JSON library. Either it has to be supported natively by the library, or you have to annotate your custom data type with [JsonConverter(typeof(TheJsonConverterForYourCustomDataType))]
  • Custom UI is not supported for now, but you can edit the module's config file (and changes will reload live)

Thorn's data types

Keybind

  • Contains a Key and a Modifier, both are KeyCodes
  • The former is mandatory. If you wish to have it default to nothing, specify KeyCode.None. The latter is optional.
  • Construction example for Ctrl+A:
    var bind = new Keybind(KeyCode.A, KeyCode.LeftCtrl);
  • Subscribing to bind presses:
    var bindSetting = CreateSetting("settingId", "Select all", $"Keybind to switch to select all items", bind);
    bindSetting.OnPress += SomeAction;
    
    // Somewhere accessible
    private void SomeAction() {
      // Do stuff here
    }

EnemyList

  • It's a set of EnemyTypes. Good for filtering out which enemies get special treatment, for example always have a tracer toward Mindflayers...
  • Construction example:
    new EnemyList([EnemyType.Filth, EnemyType.Gabriel])

Reacting to changes

You can subscribe to changes via OnChanged (parameter-less) or OnValueChanged (has a single parameter being the new value). Here's a little example; in reality you should subscribe in OnEnable and unsubscribe in OnDisable.

BorderColor = CreateSetting("borderCol", "Border color", "Color for border", Color.red)
BorderColor.OnChanged += UpdateColor;
BorderColor.OnValueChanged += UpdateColorValue;

void UpdateColor() { /* Do stuff */ }
void UpdateColorValue(Color newValue) { /* Do stuff */ }

Organizing the config menu

A linear list of settings can become extremely hard to read. It's recommended to use headers and groups if you have many settings.

Items will appear in the order they're declared. See ConfigOrganization.cs for an example.

Header

CreateHeader("headerId", "H1 heading", "Short description. Can be empty.");

Subheader

CreateHeader("headerId", "H2 heading", "", headerType: HeaderType.H2);

Group

  1. To create a group:
var myGroup = CreateGroup("groupId", "Group name", "Hover description");
  1. To put settings/headers in a group, specify a group at the end of their Create-methods:
CreateSetting("settingGuid", "Setting name", "Description", myGroup);
  1. It's possible to nest groups:
var myNestedGroup = CreateGroup("anotherGroupId", "Nested group name", "Hover description 2", myGroup);

3. Making it nice

This section provides some subtle recommendations. Feel free to skip if not interested.

Using UI hints

Slider for float setting

Setting<float> Opacity;[hudmodule.md](hudmodule.md)
Opacity = CreateSetting("opacity", "Opacity", "How opaque the item is", 0.6);
Opacity.Hints = new InterfaceHints { Range = Tuple.Create(0f, 1f) };

Hiding settings

Setting<string> LastModVersion;
LastModVersion = CreateSetting(
    "lastVersion", "Last version",
    "Last version the mod was loaded", "0.0.0"
);[hudmodule.md](hudmodule.md)
// Note that this is not reactive, only assignable once.
// If some settings are ideally shown only when a certain bool option is enabled, you could put them in a group.
LastModVersion.Hints = new InterfaceHints {
    Hidden = true
};

Other hints

There's more in the Interfacehints class, but you get the idea. Your IDE will help you figure out the rest.

Module icon

To fit in with the NocturnalOS theme and to aid accessibility, it's recommended to use a simple monochrome icon.

Tips for consistency & readability

  • Use sharp lines instead of curves. For circles, draw an octagon.
  • For a 96px icon, have lines of 6px-8px width
  • When overlaying object B on top of object A, subtract A by a larger B to create a gap so B is readable.

Suggested steps to create an icon

  1. "What's the representative object of my mod/mod icon?" - Don't be greedy! Pick the one simplest item.
  2. Open Figma and make a simple polygon trace of that main object
  3. Simplify if it's not simple enough. Some ways to simplify are:
    • Remove decorative background noise
    • Reduce the count of items in a series (such as using 3 lines instead of 4 for a "document" icon)