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.
The feature
Section titled “The feature”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— definesPingRequestandPingResponsemessages.MyMod.Client— MelonLoader mod; registersping.servercommand, handles response, renders localized output.MyMod.Server— LabAPI plugin; receives requests, enforces a cooldown, sends responses.
Step 1 — Define the shared messages
Section titled “Step 1 — Define the shared messages”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.
Step 2 — Client side
Section titled “Step 2 — Client side”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).
Step 3 — Server side
Section titled “Step 3 — Server side”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).
Step 4 — Build and deploy
Section titled “Step 4 — Build and deploy”-
Build all three projects. They produce three DLLs:
MyMod.Client.dll(targetsnet6.0).MyMod.Server.dll(targetsnet48).MyMod.Shared.dll(targetsnetstandard2.0ornet48).
-
Install the client side. Copy
MyMod.Client.dllandMyMod.Shared.dllintoGameRoot/loadouts/<loadout>/local/Mods/. Put your translation files underGameRoot/loadouts/<loadout>/local/UserData/i18n/mymod/. -
Install the server side. Copy
MyMod.Server.dll,MyMod.Shared.dll, and make sureAnomaly.Server.dll+Anomaly.Shared.dllare already present. All into the LabAPI plugins directory. -
Restart the server. Watch the log for
[MyMod] Plugin loaded. -
Start the modded client from the Anomaly Launcher and connect to your server.
-
Type
mymod.ping.serverinto the SCP:SL console. You should seePinging server...immediately, followed byServer ping: 24 ms (server time: 2026-04-22 23:11:05Z)a frame or two later.
What this exercised
Section titled “What this exercised”- Shared messages — a third assembly used by both sides.
- Message registration —
AnomalyMessageRegistry.Registeron both client and server. - Sending and receiving —
AnomalyMessaging.Sendclient-to-server,AnomalyMessaging.SendToClientserver-to-specific-client. - Commands —
CommandRegistry.Registerwith anICommandimplementation. - Localization —
Tr.RegisterMod+Tr.Getwith positional placeholders. - Server-authoritative state — the server picks the timestamp, not the client.
- Per-player server-side state — a dictionary keyed by
@anomalyID.
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.
What comes next
Section titled “What comes next”You’ve finished the Mod Developer track. Good references for ongoing work:
- Reference → Networking Compatibility for connection compatibility behavior.
- Reference → API and Namespace Index for public API areas.
- Reference → Runtime Paths and User Data when you need to understand where config / persistence / cache live.