Skip to content

Client Mod Quickstart

This chapter walks through a minimum-viable Anomaly client mod — the smallest amount of code you can ship that actually exercises the Anomaly surface. Once it runs, you have a working iteration loop and you can bolt on any subsystem from the Client API Tour.

A MelonMod that:

  • Logs every role change (SCP spawn, escape, death → spectator, etc.).
  • Runs a recurring 1-second health tick while you’re in-game and logs your HP + Hume Shield.

No UI, no config, no networking. We’re validating that the mod loads, events fire, and the Scheduler does what it says.

using System;
using Anomaly.Client.Api.Events.Handlers;
using Anomaly.Client.Api.Features;
using Anomaly.Client.Api.Scheduling;
using MelonLoader;
[assembly: MelonInfo(typeof(MyMod.Core), "MyMod", "0.1.0", "YourName", null)]
[assembly: MelonGame("Northwood", "SCPSL")]
[assembly: MelonGameVersion("14.2.6")]
[assembly: MelonAdditionalDependencies("Anomaly")]
namespace MyMod;
public class Core : MelonMod
{
public override void OnInitializeMelon()
{
// Register event handlers during OnInitializeMelon. No replay-on-subscribe,
// so subscribing here ensures you see every fire from game start.
RoleEvents.Changed += args =>
MelonLogger.Msg($"Role change: {args.Player?.Role?.RoleType} on {args.Player?.ReferenceHub?.nicknameSync?.MyNick}");
// Wait until we're actually in a round before scheduling the tick.
ClientEvents.Ready += _ =>
{
Scheduler.Every(
TimeSpan.FromSeconds(1),
LogLocalHealth,
ownerId: "mymod");
};
}
private static void LogLocalHealth()
{
var lp = Client.LocalPlayer;
if (lp == null) return;
MelonLogger.Msg($"HP: {lp.Health}/{lp.MaxHealth}, HS: {lp.HumeShield}/{lp.MaxHumeShield}");
}
}

That’s it. Thirty lines, including whitespace and imports.

[assembly: MelonInfo(typeof(MyMod.Core), "MyMod", "0.1.0", "YourName", null)]
[assembly: MelonGame("Northwood", "SCPSL")]
[assembly: MelonGameVersion("14.2.6")]
[assembly: MelonAdditionalDependencies("Anomaly")]
  • MelonInfo names the mod and points MelonLoader at the entrypoint type.
  • MelonGame tells MelonLoader which game this mod applies to.
  • MelonGameVersion tells the Launcher which SCP:SL build this mod targets.
  • MelonAdditionalDependencies("Anomaly") forces Anomaly to initialise before us. Without this, RoleEvents.Changed += ... may run before Anomaly’s event system exists.

MelonLoader calls this once, early in game startup, before any scene is loaded. By the time Anomaly hands control to your OnInitializeMelon, every registry (commands, input, config, persistence, scheduler, events) is already initialised — you can subscribe to events, register commands, and allocate ClientConfig.For(...) freely.

What you should not do in OnInitializeMelon:

  • Touch Client.LocalPlayer — the local hub hasn’t been spawned yet.
  • Read Client.Players — it’s empty.
  • Instantiate UI — UiCanvas isn’t ready. Wait for ClientEvents.CanvasReady.

A member of the RoleEvents handler class, one of 14 under Anomaly.Client.Api.Events.Handlers. Each handler class exposes a set of C# events as static properties — subscribe with +=, unsubscribe with -=. For mods that don’t unload mid-session, unsubscribing is optional.

All Anomaly event args are typed. RoleChangedEventArgs gives you Player, OldRole, NewRole, and related fields — chase the types through your IDE’s go-to-definition.

Fires once per connected session, when your local player is fully spawned and the round state is loaded. This is the first point where Client.LocalPlayer is safe to read, Client.Players is populated, Client.Rooms and Client.Doors are ready, etc.

It does not fire on OnInitializeMelon. There is no “replay on subscribe” — if you subscribe after it’s already fired, you won’t get a late-dispatch call. Subscribe in OnInitializeMelon and you’re always fine.

Schedules a callback to run every TimeSpan. Under the hood, the Scheduler ticks on Time.unscaledDeltaTime (so a game pause doesn’t skip ticks). The ownerId is a string you use to group your scheduled tasks — the Scheduler clears all tasks with a given ownerId automatically when the client disconnects. If you want the tick to survive into the next session, re-register in the next ClientEvents.Ready.

The ownerId must start with your mod id ("mymod"). It’s conventionally the mod id itself when you only have one scheduler group, or "mymod.<feature>" when you have several.

The feature wrapper for your local hub. Client.LocalPlayer.Health, HumeShield, Role, Inventory, and so on are all read-only views. Writing a health value from a client mod has no server-side effect — it’s a purely local view.

  1. Build the project (dotnet build -c Release or your IDE’s Build command).
  2. Copy MyMod.dll to GameRoot/loadouts/<loadout>/local/Mods/.
  3. Launch that loadout from the Anomaly Launcher.
  4. Open the MelonLoader console (enable it in MelonLoader’s preferences if it’s not already visible).
  5. Connect to an Anomaly server and join a round.
  6. Watch the log. You should see [MyMod] Role change: lines on round transitions and [MyMod] HP: ... lines every second while you’re alive.
  • Nothing at all prints, not even the mod load message. MelonLoader didn’t load the DLL. Check that the file is in the active loadout’s local/Mods/, not local/UserLibs/, and that it is not named MyMod.dll.DISABLED.
  • “Could not resolve assembly Anomaly.Client.Api”. Your DLL was built against an Anomaly version not present at runtime. Check that the active loadout includes Anomaly.Client, either through trackAnomalyClient or a pinned catalog/local copy.
  • Events fire but nothing in your handler runs. Check for an exception in the MelonLoader log — Anomaly’s EventInvoker catches handler exceptions and logs them, so they won’t crash the game but they will silently drop the handler for that invocation.

Client API Tour for a guided walk through every other subsystem.