Skip to content

End-to-End Tutorial

This tutorial ties every preceding chapter into one worked example. We’ll build a small “ping” feature: a client-side command that sends a request to the server, a server-side handler that replies with a server timestamp, and a localized client-side display of the result. By the end you’ll have exercised shared messages, custom networking, localization, and the client command + event subsystems.

Player types: ping.server
Client ─ PingRequest ──► Server
│ (server-authoritative timestamp)
Client ◄── PingResponse ── Server
Player sees: "Server ping: 42 ms (pinged at 2026-04-22 23:11:05Z)"

Three moving parts:

  • MyMod.Shared — defines PingRequest and PingResponse messages.
  • MyMod.Client — MelonLoader mod; registers ping.server command, handles response, renders localized output.
  • MyMod.Server — LabAPI plugin; receives requests, enforces a cooldown, sends responses.

Create MyMod.Shared/PingMessages.cs. This project targets netstandard2.0 (or net48), does not reference Unity, Mirror, LabAPI, or MelonLoader, and is used by both sides.

using System.IO;
using Anomaly.Shared.Networking;
namespace MyMod.Shared;
public class PingRequest : IAnomalyMessage
{
public string MessageName => "mymod:ping.request";
public MessageChannel TransportChannel => MessageChannel.ReliableOrdered;
public long ClientSendTicks { get; set; }
public void Serialize(BinaryWriter w) => w.Write(ClientSendTicks);
public void Deserialize(BinaryReader r) => ClientSendTicks = r.ReadInt64();
}
public class PingResponse : IAnomalyMessage
{
public string MessageName => "mymod:ping.response";
public MessageChannel TransportChannel => MessageChannel.ReliableOrdered;
public long ClientSendTicks { get; set; }
public long ServerNowTicks { get; set; }
public void Serialize(BinaryWriter w)
{
w.Write(ClientSendTicks);
w.Write(ServerNowTicks);
}
public void Deserialize(BinaryReader r)
{
ClientSendTicks = r.ReadInt64();
ServerNowTicks = r.ReadInt64();
}
}

Both messages use ReliableOrdered — we don’t need Sequenced or Unreliable here because this is a one-shot command/response, not a high-frequency stream.

Create MyMod.Client/Core.cs:

using System;
using Anomaly.Client.Api.Commands;
using Anomaly.Client.Api.Events.Handlers;
using Anomaly.Client.Api.Localization;
using Anomaly.Client.Api.Networking;
using Anomaly.Shared.Networking;
using MelonLoader;
using MyMod.Shared;
[assembly: MelonInfo(typeof(MyMod.Client.Core), "MyMod", "0.1.0", "YourName", null)]
[assembly: MelonGame("Northwood", "SCPSL")]
[assembly: MelonAdditionalDependencies("Anomaly")]
namespace MyMod.Client;
public class Core : MelonMod
{
public override void OnInitializeMelon()
{
// 1. Register messages — both sides call Register so the registry has the factory.
AnomalyMessageRegistry.Register("mymod:ping.request", () => new PingRequest());
AnomalyMessageRegistry.Register("mymod:ping.response", () => new PingResponse());
// 2. Register localization so the reply renders in the active language.
// Loads <MelonLoader UserData>/i18n/mymod/.
Tr.RegisterMod("mymod");
// 3. Register the command.
CommandRegistry.Register("mymod", new PingCommand());
// 4. Listen for the server's reply.
AnomalyMessaging.MessageReceived += OnMessage;
}
private void OnMessage(IAnomalyMessage msg)
{
if (msg is not PingResponse resp) return;
var rttMs = (DateTime.UtcNow.Ticks - resp.ClientSendTicks) / TimeSpan.TicksPerMillisecond;
var serverNow = new DateTime(resp.ServerNowTicks, DateTimeKind.Utc);
var line = Tr.Get("mymod.ping.result", rttMs, serverNow.ToString("u"));
MelonLogger.Msg(line);
}
}
public class PingCommand : ICommand
{
public string Description => "Send a ping to the server.";
public string Usage => "ping.server";
public void Execute(string[] args, ICommandContext ctx)
{
AnomalyMessaging.Send(new PingRequest
{
ClientSendTicks = DateTime.UtcNow.Ticks
});
ctx.Reply(Tr.Get("mymod.ping.sent"));
}
}

Translation file en.yaml under UserData/i18n/mymod/:

"mymod.ping.sent": "Pinging server..."
"mymod.ping.result": "Server ping: {0} ms (server time: {1})"

Optionally ship fr.yaml, de.yaml, etc. The system falls back to en.yaml for missing keys and handles regional locale variants automatically (see Assets and Localization -> Locale resolution).

Create MyMod.Server/Plugin.cs:

using System;
using System.Collections.Generic;
using LabApi.Loader.Features.Plugins;
using Anomaly.Server.Networking;
using Anomaly.Shared.Networking;
using MyMod.Shared;
namespace MyMod.Server;
public class Plugin : Plugin<Config>
{
public override string Name => "MyMod";
public override string Description => "Server companion for MyMod.";
public override string Author => "YourName";
public override Version Version => new(0, 1, 0);
public override Version RequiredApiVersion => new(1, 1, 5);
private readonly Dictionary<string, DateTime> _lastPingAt = new();
public override void Enable()
{
AnomalyMessageRegistry.Register("mymod:ping.request", () => new PingRequest());
AnomalyMessageRegistry.Register("mymod:ping.response", () => new PingResponse());
AnomalyMessaging.MessageReceived += OnMessage;
}
public override void Disable()
{
AnomalyMessaging.MessageReceived -= OnMessage;
}
private void OnMessage(ReferenceHub sender, IAnomalyMessage msg)
{
if (msg is not PingRequest req) return;
var userId = sender.authManager.UserId;
var cooldownSeconds = Config.CooldownSeconds;
if (_lastPingAt.TryGetValue(userId, out var last)
&& DateTime.UtcNow - last < TimeSpan.FromSeconds(cooldownSeconds))
{
// Ignore — within cooldown.
return;
}
_lastPingAt[userId] = DateTime.UtcNow;
AnomalyMessaging.SendToClient(sender, new PingResponse
{
ClientSendTicks = req.ClientSendTicks,
ServerNowTicks = DateTime.UtcNow.Ticks
});
}
}
public class Config
{
public int CooldownSeconds { get; set; } = 5;
}

The server echoes the client’s ClientSendTicks back unchanged so the client can compute RTT, and includes its own ServerNowTicks so the client learns the server’s authoritative timestamp.

The cooldown dictionary is keyed by the @anomaly ID. Because IDs persist across sessions, this cooldown also persists for as long as the server stays up (it doesn’t across restarts — add persistence if you need that).

  1. Build all three projects. They produce three DLLs:

    • MyMod.Client.dll (targets net6.0).
    • MyMod.Server.dll (targets net48).
    • MyMod.Shared.dll (targets netstandard2.0 or net48).
  2. Install the client side. Copy MyMod.Client.dll and MyMod.Shared.dll into GameRoot/loadouts/<loadout>/local/Mods/. Put your translation files under GameRoot/loadouts/<loadout>/local/UserData/i18n/mymod/.

  3. Install the server side. Copy MyMod.Server.dll, MyMod.Shared.dll, and make sure Anomaly.Server.dll + Anomaly.Shared.dll are already present. All into the LabAPI plugins directory.

  4. Restart the server. Watch the log for [MyMod] Plugin loaded.

  5. Start the modded client from the Anomaly Launcher and connect to your server.

  6. Type mymod.ping.server into the SCP:SL console. You should see Pinging server... immediately, followed by Server ping: 24 ms (server time: 2026-04-22 23:11:05Z) a frame or two later.

  • Shared messages — a third assembly used by both sides.
  • Message registrationAnomalyMessageRegistry.Register on both client and server.
  • Sending and receivingAnomalyMessaging.Send client-to-server, AnomalyMessaging.SendToClient server-to-specific-client.
  • CommandsCommandRegistry.Register with an ICommand implementation.
  • LocalizationTr.RegisterMod + Tr.Get with positional placeholders.
  • Server-authoritative state — the server picks the timestamp, not the client.
  • Per-player server-side state — a dictionary keyed by @anomaly ID.

From here you can extend the mod in whatever direction your real feature points: swap the ping for your actual business logic, add more message types, subscribe to more events, or drop the server companion entirely if it turns out you don’t need one.

You’ve finished the Mod Developer track. Good references for ongoing work: