A minimal, buildable third-party mod demonstrating the ONI Together API: custom network
packets, session info, and an OxySync NetworkBehaviour.
The controller only reacts while in a multiplayer session (SessionInfoAPI.InSession).
| Key | Action |
|---|---|
F5 |
Spawn a Hatch prefab via KNetInstantiate (host only) |
F6 |
Spawn an IronOre resource via KNetInstantiate (host only) |
F7 |
Look up the last spawned NetId via NetworkIdentityRegistryAPI |
F8 |
Remove the OxySync demo behaviour (if spawned) |
F9 |
Send a hello packet to all other peers (PacketExamples.SendToAllOtherPeers) |
F10 |
Dump session info (ids + host cursor/colour) |
F11 |
Spawn the OxySync demo behaviour |
F12 |
Increment the OxySync counter via a [Command] (requires F11 first) |
ExamplePingPacketand the otherPacketExamplesoverloads are reference code and are not bound to keys. Call them from your own code to try them.
ExampleSyncBehaviour exercises the main OxySync features:
- SyncVars:
_temperature(unreliable),_counter(reliable + change hook),_label(hooked string), and_progress/PingPong(epsilon-gated, written by the host every frame for a continuously-replicating value). - Commands (client → host):
CmdIncrementCounter,CmdReset(RequiresHost), andCmdSetTemperature/CmdSetLabel.RequestIncrement()/RequestLabel(...)run locally on the host or send a Command otherwise. - ClientRpcs (host → clients):
RpcOnCounterIncremented, andRpcAnnounce(IncludeHost = true). - TargetRpcs (host → one client):
TargetSendMessage. - Role gating:
[Server]ServerOnlyLog()and[Client]ClientOnlyLog().
Because the host writes _progress every frame, the entity shows as continuously "Syncing" in the
main mod's dev tool. That is intentional for the demo; make the value change rarely (or not at all)
if you want an idle, at-rest entity.
src/World/EntityExamples.cs wraps the two host-only SpawnUtilsAPI.KNetInstantiate overloads and
the NetworkIdentityRegistryAPI lookups.
Only the host may spawn; both methods return null on a client (or when ONI Together is absent) and
replicate the spawn to every client automatically.
// Prefab overload - any prefab from the game database.
GameObject hatch = EntityExamples.SpawnPrefab(Assets.GetPrefab("Hatch"), position);
// Element/resource overload - mass, temperature and disease data are preserved.
GameObject ore = EntityExamples.SpawnResource(SimHashes.IronOre, position, 100f, 293.15f);Position:
SpawnPrefabsnaps the position to the prefab's cell and scene layer. Cursor/session positions (e.g. fromSessionInfoAPI.TryGetPlayerCursorPos) sit on the UI plane, and spawning a prefab at that z places it at the wrong depth (often invisible).SpawnResourcealready snaps internally.
To query an entity later you need its NetId. Shared.Helpers.NetIdentityHelper exposes three
helpers for that:
| Method | Behaviour |
|---|---|
AddNetId(go) |
Adds a network identity if the object lacks one, registers it if it has no id, and returns the NetId. |
GetNetId(go) |
Read-only: returns the object's existing NetId, or 0 if it has none. Never creates/registers. |
AddOrGetNetId(go, preferredId = 0) |
Combines the two; a non-zero preferredId overrides the assigned id. |
KNetInstantiate already assigns an identity, so read it back with GetNetId:
GameObject go = EntityExamples.SpawnResource(SimHashes.IronOre, position, 100f, 293.15f);
int netId = NetIdentityHelper.GetNetId(go);
if (NetworkIdentityRegistryAPI.TryGet(netId, out GameObject found))
Debug.Log($"Found {found.name}");
if (NetworkIdentityRegistryAPI.TryGetComponent<PrimaryElement>(netId, out var primaryElement))
Debug.Log($"{primaryElement.ElementID}: {primaryElement.Mass}kg");For a GameObject you created yourself, call AddNetId first; GetNetId alone will return 0
because no identity exists yet:
var go = new GameObject("MySyncedThing");
int netId = NetIdentityHelper.AddNetId(go); // assign + register
// ... later ...
int again = NetIdentityHelper.GetNetId(go); // same id, no side effects
// Or honour a preferred/deterministic id:
int preferred = NetIdentityHelper.AddOrGetNetId(go, 12345);EntityExamples.EnsureNetId / EntityExamples.ReadNetId wrap these calls with logging.
EntityExamples.SpawnResourceAndInspect(...) performs that whole spawn → NetId → lookup loop in one
call. The F5/F6/F7 hotkeys run the prefab spawn, the element spawn, and the registry lookup
respectively.
Requirements:
- .NET SDK (this project was built with 8.0)
- Oxygen Not Included installed
- A self-contained (ILRepacked)
ONI_Together_API.dll, produced by building the API in Release:dotnet build ONI_Together_API/ONI_Together_API.csproj -c Release
Paths are configured in Directory.Build.props. To override them on your machine, copy
Directory.Build.props.user.template to Directory.Build.props.user and set:
GameLibsFolder— yourOxygenNotIncluded_Data/ManagedfolderModFolder— your ONI dev mods folderApiDllPath- path to your copy of theONI_Together_API.dll
Then:
dotnet build ExampleMod.csproj
The build generates mod.yaml / mod_info.yaml and deploys ExampleMod.dll, its pdb, and
ONI_Together_API.dll into <ModFolder>/ExampleMod_dev.
Types come from two places, which can be confusing because the API DLL embeds Shared:
| Type | Namespace | Assembly |
|---|---|---|
MP_Mod_Info, SessionInfoAPI, PacketSenderAPI, PacketRegistryAPI |
ONI_Together_API[.Networking] |
ONI_Together_API |
IPacket |
ONI_Together.Networking.Packets.Architecture |
ONI_Together_API (merged Shared) |
PacketSendMode |
ONI_Together.Networking |
ONI_Together_API (merged Shared) |
NetworkBehaviour |
Shared.OxySync |
ONI_Together_API (merged Shared) |
| OxySync attributes | Shared.OxySync.Attributes |
ONI_Together_API (merged Shared) |
NetIdentityHelper |
Shared.Helpers |
ONI_Together_API (merged Shared) |
API packets are duck-typed by the main mod and wrapped in ModApiPacket<T>. Therefore:
- Your packet must implement
ONI_Together.Networking.Packets.Architecture.IPacket. - It must have a public parameterless constructor — the main mod instantiates it with
Activator.CreateInstance<T>().
public class MyPacket : IPacket
{
public int Value;
public MyPacket() { } // required
public void Serialize(BinaryWriter w) => w.Write(Value);
public void Deserialize(BinaryReader r) => Value = r.ReadInt32();
public void OnDispatched() { /* handle on receive */ }
}Register all packets once, after all mods are loaded (the main mod type must exist first):
public override void OnAllModsLoaded(Harmony harmony, IReadOnlyList<Mod> mods)
{
if (MP_Mod_Info.MultiplayerModPresent)
PacketRegistryAPI.AutoRegisterAll(Assembly.GetExecutingAssembly());
}PacketSendMode (from ONI_Together.Networking) selects the delivery profile:
Reliable— guaranteed, ordered (state changes, chat).ReliableImmediate— reliable, flushed immediately (latency-sensitive events).Unreliable— best-effort (high-frequency position/effects).
[SyncVar] / [Command] / [ClientRpc] / [TargetRpc] behaviour is provided by
Shared.OxySync.NetworkBehaviour, which is ILRepacked into this mod's copy of ONI_Together_API.dll.
Because the main mod does not reference the API, those types have a different runtime identity than
the main mod's own copy; the main mod discovers and bridges each consuming mod's API copy at load.
This works as long as ONI Together is present. If ONI Together is absent, OxySync calls are inert.
NetworkIdentity belongs to the main mod and is not part of the API surface — don't add it
directly. Use Shared.Helpers.NetIdentityHelper.AddNetId(gameObject) to assign a NetId,
GetNetId(gameObject) to read one, or AddOrGetNetId(gameObject, preferredId) for both.
- The examples use
[Command]/[ClientRpc]argument types supported byRpcSerializer(primitives,string,Vector2/3,Color, arrays/lists of those). Unsupported types throw at send time. F11/F12require ONI Together and an active session to do anything meaningful.- Dev tools (the OxySync inspector) only exist in a Debug build of the main mod.