



Backend-agnostic and auto-initialising: the library auto-selects Steam or Offline at runtime, auto-instantiates the poller so you do not need to call Initialize() or PollReceive().
Q: Do I need to create or initialize the networking service?
AS OF VERSION 1.0.1:
- Automatic initialization & poller. Library auto-creates service and runs receive poller; mod authors only consume Net.Service.
- ModId helpers. ModId.FromGuid(string) and ModId.FromString(string) (document whichever you implemented).
- Wire-efficient keys. API accepts human-friendly strings but sends small 32-bit keys on the wire (local map + fallback).
- All old functions are still usable and can be used, though it is recommended to swap over to new system.
Backend
INetworkingService provides one surface mods use. Swap Steam vs Offline without code changes.
Lobby key sync (host -> clients)
Host sets small authoritative strings via SetLobbyData. Clients read with GetLobbyData and LobbyDataChanged event.
RPC discovery & invocation
Discover methods marked [CustomRPC] with RegisterNetworkObject and call RPC, RPCTarget, or RPCToHost.
Message serialization
Message class supports: byte, int, uint, long, ulong, float, bool, string, byte[], Vector3, Quaternion, CSteamID.
Reliable vs Unreliable
ReliableType enum supports Reliable, Unreliable, UnreliableNoDelay semantics.
Security (optional)
Optional HMAC signing, per-mod signer hooks, sequence numbers, replay protection.
Framing & priority
Per-message flags, msg-id, sequence, fragment metadata, priority queues.
Offline shim for CI
Full in-process simulator to run tests without Steam.
Incoming validation hook
IncomingValidator lets consumers drop or accept messages before handler invocation.
Poll-based receive loop
PollReceive() is required for Steam adapter; Offline shim is immediate.
ModId
- static uint FromGuid(string guid)
NetworkingServiceFactory
- static INetworkingService CreateDefaultService() // auto-chosen by runtime
INetworkingService
> Service is created & initialized automatically by the library, mods should access it via `Net.Service` and must not call Initialize() except for test harnesses.
- Initialize()
- Shutdown()
- CreateLobby(maxPlayers)
- JoinLobby(lobbySteamId64)
- LeaveLobby()
- RegisterLobbyDataKey(string key)
- SetLobbyData(string key, object value)
- T GetLobbyData<T>(string key)
- RegisterPlayerDataKey(string key)
- SetPlayerData(string key, object value)
- T GetPlayerData<T>(ulong steam64, string key)
- IDisposable RegisterNetworkObject(object instance, uint modId, int mask = 0)
- void DeregisterNetworkObject(object instance, uint modId, int mask = 0)
- void RPC(uint modId, string methodName, ReliableType reliable, params object[] parameters)
- void RPCTarget(uint modId, string methodName, ulong targetSteamId64, ReliableType reliable, params object[] parameters)
- void RPCToHost(uint modId, string methodName, ReliableType reliable, params object[] parameters)
- void PollReceive()
- Events: LobbyCreated, LobbyEntered, LobbyLeft, PlayerEntered(ulong), PlayerLeft(ulong),
LobbyDataChanged(string[] keys), PlayerDataChanged(ulong steam64, string[] keys)
- Func<Message, ulong, bool>? IncomingValidator { get; set; }
All examples assume the networking service is already present (Steam or Offline). They show the smallest usable code for each feature.
using BepInEx;
using BepInEx.Logging;
using NetworkingLibrary.Services;
using NetworkingLibrary;
[BepInDependency("off_grid.NetworkingLibrary")]
[BepInPlugin("com.example.peaktest", "PEAKTest", "1.0.0")]
public class Plugin : BaseUnityPlugin
{
static readonly uint MOD = ModId.FromGuid("com.example.peaktest");
const string LOBBY_KEY_PACK_COUNT = "peak_test.pack_count";
static ManualLogSource Log => Instance.Logger!;
public static Plugin Instance { get; private set; } = null!;
IDisposable? registrationToken;
void Awake()
{
Instance = this;
Log.LogInfo("PEAKTest Awake");
var svc = Net.Service;
if (svc == null)
{
Log.LogError("Networking service not available.");
return;
}
svc.RegisterLobbyDataKey(LOBBY_KEY_PACK_COUNT);
registrationToken = svc.RegisterNetworkObject(this, MOD);
svc.LobbyEntered += OnLobbyEntered;
if (svc.InLobby) OnLobbyEntered();
}
void OnDestroy()
{
registrationToken?.Dispose();
}
void OnLobbyEntered()
{
var svc = Net.Service;
if (svc == null) return;
bool amHost = svc.HostSteamId64 != 0 && svc.HostSteamId64 == GetLocalSteam64();
if (amHost)
{
var packages = new[] { "pivo1", "pivo2" };
svc.SetLobbyData(LOBBY_KEY_PACK_COUNT, packages.Length);
string payload = string.Join("|", packages);
svc.RPC(MOD, nameof(HandlePackagesRpc), ReliableType.Reliable, payload);
}
else
{
svc.RPCToHost(MOD, nameof(RequestPackagesRpc), ReliableType.Reliable);
}
}
[CustomRPC]
void HandlePackagesRpc(string joined)
{
var list = string.IsNullOrEmpty(joined) ? Array.Empty<string>() : joined.Split('|');
Log.LogInfo($"Got {list.Length} packages: {string.Join(',', list)}");
}
[CustomRPC]
void RequestPackagesRpc()
{
var svc = Net.Service;
if (svc == null) return;
if (svc.HostSteamId64 != GetLocalSteam64()) return; // only host handles
var packages = new[] { "pivo1", "pivo2" };
string payload = string.Join("|", packages);
svc.RPC(MOD, nameof(HandlePackagesRpc), ReliableType.Reliable, payload);
}
static ulong GetLocalSteam64()
{
try { return Steamworks.SteamUser.GetSteamID().m_SteamID; } catch { return 0UL; }
}
}
using System;
using System.Linq;
using BepInEx;
using BepInEx.Logging;
using UnityEngine;
using NetworkingLibrary.Services;
using NetworkingLibrary.Modules;
using NetworkingLibrary;
using Steamworks;
namespace PEAKTest
{
[BepInDependency("off_grid.NetworkingLibrary")]
[BepInPlugin(MyPluginInfo.PLUGIN_GUID, MyPluginInfo.PLUGIN_NAME, MyPluginInfo.PLUGIN_VERSION)]
public class Plugin : BaseUnityPlugin
{
readonly static uint MOD_ID = ModId.FromGuid(MyPluginInfo.PLUGIN_GUID); // Use this when you do not want to compute bytes.
const string LOBBY_KEY_PACK_COUNT = "peak_test.pack_count";
const string PLAYER_KEY_STATUS = "peak_test.player_status";
static ManualLogSource Log => Instance.Logger;
public static Plugin Instance { get; private set; } = null!;
IDisposable? registrationToken;
void Awake()
{
Instance = this;
Log.LogInfo($"{MyPluginInfo.PLUGIN_NAME} Awake");
var svc = Net.Service;
if (svc == null)
{
Log.LogError("Networking service not found. Ensure PEAKNetworkingLibrary is installed and loaded.");
return;
}
// Register keys
svc.RegisterLobbyDataKey(LOBBY_KEY_PACK_COUNT);
svc.RegisterPlayerDataKey(PLAYER_KEY_STATUS);
// Register RPC handlers by reflecting this instance's [CustomRPC] methods.
registrationToken = svc.RegisterNetworkObject(this, MOD_ID);
// Subscribe to a few events, none of these are required.
svc.LobbyEntered += OnLobbyEntered;
svc.LobbyCreated += () => Log.LogInfo("LobbyCreated event");
svc.PlayerEntered += id => Log.LogInfo($"PlayerEntered: {id}");
svc.PlayerLeft += id => Log.LogInfo($"PlayerLeft: {id}");
svc.LobbyDataChanged += keys => Log.LogInfo("LobbyDataChanged: " + string.Join(",", keys));
svc.PlayerDataChanged += (steam, keys) => Log.LogInfo($"PlayerDataChanged: {steam} -> {string.Join(',', keys)}");
// If already in a lobby at load time, run quick checks
if (svc.InLobby) OnLobbyEntered();
}
void OnDestroy()
{
var svc = Net.Service;
if (svc != null)
{
svc.LobbyEntered -= OnLobbyEntered;
svc.LobbyCreated -= () => { }; //
}
registrationToken?.Dispose();
Log.LogInfo($"{MyPluginInfo.PLUGIN_NAME} destroyed");
}
// Called when we enter a lobby (host or client).
void OnLobbyEntered()
{
var svc = Net.Service;
if (svc == null) return;
Log.LogInfo($"OnLobbyEntered: InLobby={svc.InLobby}, HostSteamId64={svc.HostSteamId64}");
// Host will announce a small package list via RPC to all clients.
// Non-host clients will request the package list from host (RPCToHost).
ulong localSteam64 = GetLocalSteam64();
bool amHost = localSteam64 != 0 && svc.HostSteamId64 == localSteam64;
if (amHost)
{
Log.LogInfo("We are host. Announcing packages to clients.");
// pretend-loaded packages, fill with your actual data.
string[] loaded = new[] { "pivo1", "pivo2" };
// set a lobby value
svc.SetLobbyData(LOBBY_KEY_PACK_COUNT, loaded.Length);
// set player data (host status)
svc.SetPlayerData(PLAYER_KEY_STATUS, "host_ready");
// send package list as a single joined string
string payload = string.Join("|", loaded);
svc.RPC(MOD_ID, nameof(HandlePackagesRpc), ReliableType.Reliable, payload);
Log.LogInfo($"Host RPC broadcast sent with {loaded.Length} packages.");
}
else
{
Log.LogInfo("We are client. Requesting package list from host.");
// ask host to send packages to everyone (host will handle RequestPackagesRpc)
svc.RPCToHost(MOD_ID, nameof(RequestPackagesRpc), ReliableType.Reliable);
}
}
// Host broadcasts packages with this RPC. Clients receive here.
// Signature shows a single string parameter.
[CustomRPC]
void HandlePackagesRpc(string joined)
{
try
{
var list = string.IsNullOrEmpty(joined) ? Array.Empty<string>() : joined.Split('|');
Log.LogInfo($"HandlePackagesRpc: received {list.Length} packages: {string.Join(", ", list)}");
// Set player key to indicate we received packages, not required.
var svc = Net.Service;
svc?.SetPlayerData(PLAYER_KEY_STATUS, "packages_received");
}
catch (Exception ex)
{
Log.LogError($"HandlePackagesRpc exception: {ex}");
}
}
// Clients call this (RPCToHost) to request the host's package list.
// Host will respond by performing an RPC broadcast (see OnLobbyEntered path).
[CustomRPC]
void RequestPackagesRpc()
{
try
{
var svc = Net.Service;
if (svc == null) return;
// Only the host should act on this. We do a host check.
ulong local = GetLocalSteam64();
if (local == 0 || svc.HostSteamId64 != local)
{
Log.LogInfo("RequestPackagesRpc called on non-host.");
return;
}
Log.LogInfo("Host handling RequestPackagesRpc; responding with package list.");
// Example data
var loaded = new[] { "pivo1", "pivo2" };
string payload = string.Join("|", loaded);
svc.RPC(MOD_ID, nameof(HandlePackagesRpc), ReliableType.Reliable, payload);
}
catch (Exception ex)
{
Log.LogError($"RequestPackagesRpc exception: {ex}");
}
}
//
static ulong GetLocalSteam64()
{
try
{
return SteamUser.GetSteamID().m_SteamID;
}
catch
{
return 0UL;
}
}
}
}
Host sets one string key. Clients read it on change.
Host: broadcast package list
// host only
void BroadcastLoadedPackages(INetworkingService svc, IEnumerable<string> packages)
{
const string KEY = "PEAK_PACKAGES_V1";
svc.RegisterLobbyDataKey(KEY); // safe to call on all peers
// serialize: escape '|' then join and compress+base64 option
string joined = string.Join("|", packages.Select(p => p.Replace("|","||")));
string payload = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(joined));
svc.SetLobbyData(KEY, payload); // host-only action
}
Client: receive update
void SubscribeToPackageUpdates(INetworkingService svc)
{
const string KEY = "PEAK_PACKAGES_V1";
svc.RegisterLobbyDataKey(KEY); // register the key used by host
svc.LobbyDataChanged += keys =>
{
if (!keys.Contains(KEY)) return;
var payload = svc.GetLobbyData<string>(KEY);
if (string.IsNullOrEmpty(payload)) return;
var joined = System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(payload));
var packages = joined.Length == 0 ? Array.Empty<string>() :
joined.Split('|').Select(s => s.Replace("||","|")).ToArray();
// now packages[] contains the host list
};
}
Notes:
Use RPC when you need immediate notification or structured params.
Register and broadcast
const uint MOD = 0xDEADBEEF;
// on plugin init
IDisposable token = svc.RegisterNetworkObject(this, MOD);
// broadcast
svc.RPC(MOD, "NotifyPackages", ReliableType.Reliable, "GAME_PACKAGES_READY");
In same class: RPC handler
[CustomRPC]
void NotifyPackages(string tag)
{
// runs on all peers (including host by loopback)
Logger.LogInfo($"NotifyPackages received: {tag}");
}
Notes:
PollReceive() each frame for Steam adapter.RegisterNetworkObject discovers methods tagged [CustomRPC].svc.RPCTarget(MOD, "PrivateMessage", targetSteamId64, ReliableType.Reliable, "hello");
Handler:
[CustomRPC]
void PrivateMessage(string text) { Debug.Log(text); }
svc.IncomingValidator = (msg, fromSteam64) =>
{
// drop messages with a specific method name
if (msg.MethodName == "DropMe") return false;
return true;
};
// Use this for unit tests or local dev when Steam not available.
INetworkingService svc = new OfflineNetworkingService();
svc.Initialize();
// Offline shim delivers messages immediately. PollReceive() is a no-op.
uint MOD = ModId.FromGuid("<your-mod-guid>"); it produces a stable 32-bit id from your mod GUID so you do not hand-pick hex values.
Lobby data: cheap for small text. Keep per-key payload < ~2ā4 KB.
RPC: use for immediate messages and structured params. Use Unreliable for high-rate telemetry.
String keys are readable in code but cost bytes on the wire. The library maps strings to a stable 32-bit hash locally and sends the 32-bit value, the full string is sent only when the peer does not have the mapping. If your payloads are large, compress or use chunked RPCs.
Compress large payloads (gzip) before base64. Or use chunked RPC transfer.
HMAC/signing is optional. Enable when you need tamper detection.
SetSharedSecret, RegisterModPublicKey, RegisterModSigner are privileged/global. Misuse affects all mods.[LEGACY] Always call PollReceive() in Update() when using Steam adapter.
Register lobby/player keys before calling Get/Set to avoid warnings.
Use RegisterNetworkObject and keep the returned IDisposable for safe deregistration.
For very large lists prefer chunked RPC or a request-on-join RPC rather than putting everything in lobby metadata.
If the library or poller stops, check BepInEx logs for an uncaught exception in NetworkingPoller or signer delegate. The library will now disable a failing signer delegate and log a warning.
If you see duplicate / colliding RPCs, verify you used ModId.FromGuid() and that no two mods share the same GUID.
If RPC handlers never run for a disposed object, ensure you keep the returned IDisposable registration token and Dispose() it in OnDestroy, the library also prunes destroyed Unity objects periodically.
