Skip to content

Cross Mod Functionality

Snip edited this page Feb 8, 2026 · 28 revisions

There are some situations where you may want to use or extend functionality from another mod. This page will describe some of the common methods and recommendations based on your use case.

Table of contents

Dependency Management

Before dealing with other mods, it's helpful to understand how Everest handles dependency loading. Dependencies are defined in a mod's everest.yaml file. They can be specified as required or optional depending on the tags used. Required dependencies must be loaded before your mod will be loaded. Optional dependencies will be ignored if not enabled, but will be treated as required if they are enabled (more detailed info here).

In general, you want to limit the number of required dependencies your mod has to keep it lightweight and flexible.

Code Safety

An important thing to note about Celeste modding is that the usual convention of access modifiers 🔗 to mark code as accessible or extensible does not apply. Just because a method is marked as public does not mean that it is safe to be used outside of the mod -- in many cases, the access modifiers are holdovers from the original game, which was not designed to be referenced from other assemblies. In the same way, marking a method as private does not mean another mod cannot access it. Tools like reflection can be used to get around these restrictions.

Therefore, most implementations of cross-mod functionality are considered unsafe. Code defensively when possible and be prepared to make fixes if necessary. There is no guarantee that a method will always function the same way or even have the same signature, although for this reason it's recommended to avoid changing interfaces when possible. The exception is if the mod creator explicitly marks an interface as an API 🔗, which essentially is a "contract" that guarantees the signature and function will not change. However, it's up to the mod creator to honor that contract.

Techniques

Below are several different ways to implement cross-mod functionality, roughly in order from most to least recommended.

Direct Addition

It may seem obvious, but the easiest, safest way to implement any new feature involving another mod is to add it to that mod. If you just want to e.g. create an entity that is similar to another modded entity or slightly tweak an existing entity, try reaching out to the creator about adding it to their mod directly. Many older mods are maintained by the Communal Helper organization 🔗 and are open to contributions and requests.

ModInterop

ModInterop 🔗 is a MonoMod feature and the closest thing we have to an "official" API. One mod creates a set of methods to export, and then other mods can import them as delegates using MonoMod.Interop. If the dependency is disabled, the delegate will be null, otherwise you can invoke it to access the other mod's features without adding a direct dependency.

Of course, a mod must first create the API for others to use it. Consider reaching out to a mod creator about adding a ModInterop API if there are fields or methods that you need to access inside of your mod.

You can also consider it for your own mod! Just remember, an API is a contract. Modders who use your API will expect it to work until at least the next major version of your mod. It's also recommended to document your API, at minimum with the version each method was added.

Note

To use a ModInterop API, you should add a dependency for the mod with the version that the interface was added. Use an optional dependency if your mod can work without the imported API.

In the example below, GetDreamTunnelDashState was added in Communal Helper 1.13.3, so that would be the minimum version we use in our everest.yaml.


Below is an example on how to use ModInterop.
Let's say that we need to check for CommunalHelper's Dream Tunnel state.

CommunalHelper's Exports class 🔗 exports a function that can be called to retrieve the StDreamTunnel state number.

[ModExportName("CommunalHelper.DashStates")]
public static class DashStates
{
    public static int GetDreamTunnelDashState()
    {
        return DreamTunnelDash.StDreamTunnelDash;
    }
}

To use it, we need to import it first. Create a public class and annotate it with [ModImportName] that matches the [ModExportName] of CommunalHelper's DashStates class.
Then, create a public static field whose name is the same the exported method name, and whose type is a delegate type that matches the exported method signature.
In this case, int GetDreamTunnelState() can be assigned to a Func<int>, so we create an Func<int> GetDreamTunnelDashState field in the import class.

[ModImportName("CommunalHelper.DashStates")]
public static class CommunalHelperImports
{
    public static Func<int> GetDreamTunnelDashState;
}

Tip

Sometimes the signature types get a bit unwieldy. In those situations, you can create a delegate type yourself, and use it in the field, as long as it matches the signature of the exported method.
The benefit of doing so is that you get parameter names directly in your IDE.

[ModImportName("CommunalHelper.DashStates")]
public static class CommunalHelperImports
{   
    public delegate Component DreamTunnelInteractionDelegate(
        Action<Player> onPlayerEnter, Action<Player> onPlayerExit);

    public static DreamTunnelInteractionDelegate DreamTunnelInteraction;
    
    // would've been the following otherwise:
    // public static Func<Action<Player>, Action<Player>, Component> DreamTunnelInteraction;
}

Then, we need to tell ModInterop to actually do the import. We can do this with typeof(CommunalHelperImports).ModInterop(); - usually in your module's Load() method.

public override void Load()
{
    typeof(CommunalHelperImports).ModInterop();
}

Finally, we can use the import. You can invoke the method by simply invoking the field in your import class.
If your dependency is optional, the field may be null. Check if your dependency is loaded or if the field is not null first.

If importing a method failed (either the signature or the name does not match), then the field will also be null.

// If the dependency is required:
int dreamTunnelState = CommunalHelperImports.GetDreamTunnelDashState();

// If the dependency is optional: (falls back to -1 if it's disabled)
int dreamTunnelState = CommunalHelperImports.GetDreamTunnelDashState?.Invoke() ?? -1;

Tip

Snip's ModInteropImportGenerator 🔗 makes the importing process easier.
It's a Roslyn source generator that lets you use the familiar method declaration syntax to declare imports - simply copy and paste the method signature without the body and make it partial. That's it!
It also provides load-time validation; if something goes wrong (required dependency is disabled, called an import without importing it, import succeeded partially, etc), it will throw an exception with detailed information and instructions on what to check.

See its README for more details. Below is an example of an import class that makes use of it:

[GenerateImports("CommunalHelper.DashStates", RequiredDependency = true)]
public static partial class CommunalHelperImports
{
    public static partial int GetDreamTunnelDashState();
    public static partial Component DreamTunnelInteraction(
        Action<Player> onPlayerEnter, Action<Player> onPlayerExit);
}

To perform the import, you call CommunalHelperImports.Load() instead.

Here is a list of public ModInterop APIs (feel free to add or update your own):

Assembly Reference

The most straightforward way to use a mod interface is to reference the other mod directly:

// using namespace Celeste.Mod.CommunalHelper.DashStates;
bool inDreamTunnelState = player.StateMachine.State == DreamTunnelDash.StDreamTunnelDash;

This is the same example as before, except it references the original field directly. Direct references are limited to public interfaces (or protected from a derived class). If you want to use something private or internal, you will need to use reflection.

The code is simpler, but requires us to add an assembly reference for Communal Helper to the project. If your source is public, anyone who builds the project (including any autobuild workflow) will need the added dependency as well. Distributing a dependency directly is discouraged (and, depending on the license, illegal) unless you use a tool like mono-cil-strip 🔗 to remove the source code but still allow building against the DLL. If the interface is changed in a future update, you will have to update your code and the stripped DLL.

Warning

If your code references a dependency that isn't loaded, the game will hard crash.
You can avoid this by making it a required dependency, or adding a check for optional dependencies to see if they are loaded before you reference them.

A common implementation of this check looks like this:

// MyModule.Load()
// communalHelperLoaded -> public static bool 
EverestModuleMetadata communalHelper = new() {
  Name = "CommunalHelper",
  Version = new Version(1, 13 ,3)
};

communalHelperLoaded = Everest.Loader.DependencyLoaded(communalHelper);

// MyModule.Entity
if (communalHelperLoaded) {
    FunctionThatReferencesCommunalHelper();
}

Checking the status of the dependency in MyModule.Load() lets us use communalHelperLoaded as a wrapper for any references we make, since optional dependencies will load before our mod if enabled. Note that we can't reference Communal Helper directly in this if-statement -- methods are compiled fully as they are entered, so we can't invoke any method that contains a reference to another assembly until we pass the loaded check.

Reflection

Reflection 🔗 is a tool that lets you dynamically create and use types, methods, etc. at runtime. ModInterop and DynamicData use reflection internally.

You can use reflection to access things marked as internal or private, or even avoid direct assembly references entirely. However, non-public interfaces and implementation details have a high risk of changing, although reflection also allows you to add safeguards if an interface has changed. For example, if you use reflection to search for a method and it no longer exists, it will return null, which you can check for before calling the method.

Here is an example:

// MyModule.Load()
// communalHelper -> EverestModuleMetadata from previous section
// dreamTunnelDashState -> public static FieldInfo
if (Everest.Loader.TryGetDependency(communalHelper, out EverestModule communalModule) {
  Assembly communalAssembly = communalModule.GetType().Assembly;
  Type dreamTunnelDash = communalAssembly.GetType("Celeste.Mod.CommunalHelper.DashStates.DreamTunnelDash");
  dreamTunnelDashState = dreamTunnelDash.GetField("StDreamTunnelDash", BindingFlags.Public | BindingFlags.Static);
}

// MyMod.Entity
bool inDreamTunnelState = player.StateMachine.State == MyModule.dreamTunnelDashState?.GetValue(null) ?? -1;

As you can see, this lets us access a field without any reference to the assembly at all, in a similar fashion to ModInterop. However, the code becomes more complex, more fragile, and less readable.

Hooks

It's also possible to add a manual IL hook to another mod using reflection, similar to the method described here 🔗. However, changing the behavior of another mod like this is generally discouraged. Users who install a mod usually want it to behave as described, so any external changes should be minimal and well-documented. It's also even more fragile than invoking a method with reflection, since it relies on both the signature and the IL remaining relatively stable.

Clone this wiki locally