Writing a Server Game for Entangle - Contract & Operations
The rules a custom C# game (
IEntangleRoomplugin) must follow, what the server does when a game misbehaves, and the ops runbook. The TypeScript scripting runtime is compiled out by default, so this page covers the C# path only.
What a game is
A class implementing Entangle.IEntangleRoom, shipped as a DLL in Games/{Name}/ with a manifest.json
(kind: "game", runtime: "clr", entryPoint.assembly/entryPoint.type, optional minServerVersion).
Each room running your game gets its own instance (OnInitialize -> OnTick per frame -> OnTerminate);
all instances of one game share one loaded assembly. Rooms select a game by name at CreateRoom
(RoomExecutionMode.Authoritative or Hybrid).
Execution model - what threads call you
Every callback (OnInitialize, OnTick, OnPlayerJoined, OnPlayerLeft, OnRoomStateChanged,
OnRoomEventReceived, OnTerminate) runs on the server's main thread, strictly serialized - you will never
be called concurrently, and you never need locks for your own room instance's state. OnTick runs at ~33 Hz
with the frame's fixed deltaTime.
The rules (breaking them has consequences, listed below)
- No mutable static state. Rooms of your game share the assembly - a
staticfield is shared across all rooms (and survives room restarts). Keep all state on the instance. - No threads, no Tasks, no timers. Everything you do must happen inside your callbacks. Spawned threads race the server's single-threaded room state and void the safety model.
- No blocking I/O / no long work in callbacks.
OnTickhas a soft budget (GameTickSoftBudgetMs, default 10 ms). Database calls, file I/O, HTTP - keep them out of the tick path. - Exceptions are yours to prevent. The server catches everything you throw (the node survives), but each throw is a strike (below).
- Wire types pin your assembly. A
[MemoryPackable]type defined in your DLL registers into a process-global formatter table with no unregister - your game still works, but it becomes load-once (hot-reload can't fully reclaim it until a server restart). Prefer carrying data as bytes inside the existing payload/entity-state channels. Never register MemoryPack union formatters (reserved for Entangle internals).
Talking to your game from the client (raw-bytes events)
Because of rule 5, the supported channel between a client and a custom game is room events carrying raw
bytes - no shared C# wire types. Both sides agree on an EventId (a byte) and on what the Data bytes mean
(your own layout, UTF-8, JSON - your call).
- Client -> game:
EN_RoomEvent.Create(eventId, delivery)->evt.SetRawData(bytes)->Room.SendEvent(evt). Your game receivesOnRoomEventReceived(senderId, eventData)and readseventData.EventId/eventData.Data. Note:SendEventrejects an event with no data - send at least one byte. - Game -> client(s): build a
RoomEventData { EventId, DeliveryMethod, Data }and callctx.BroadcastRoomEvent(evt)(orSendRoomEventToPlayer). On the client, anIEntangleRoomListenergetsOnEventReceived(sender, evt)- readevt.EventCodeandevt.GetRawData()(copy the array if you keep it beyond the callback; the event wrapper is pooled). - Late joiners:
SetRawData(bytes, length, isCached: true, tag)caches the event in the room so players who join later receive it (taglets a newer cached event replace an older one).
TestGame/ and the sample's TestGameDriver.cs are a minimal working example of this round-trip.
What the server does when a game misbehaves
- Strikes & the circuit-breaker. Every exception from any callback, and every
OnTickover the soft budget, is one strike.GameBreakerStrikeLimit(5) strikes withinGameBreakerWindowSeconds(60) trips the breaker: your game's rooms are closed through the normal room-deletion flow (players get the standard room-closed path), and new rooms are rejected withRoomResponseCode.GameNotAlloweduntil an admin resets the breaker. Strike history and a Reset button live in the admin games panel (POST /api/games/{name}/reset-breaker). - A true infinite loop freezes the node. This is an accepted design trade (no forced thread kill in .NET):
the server does NOT try to survive a hung callback. The watchdog wraps every game callback (
OnInitialize,OnTick,OnPlayerJoined,OnPlayerLeft,OnRoomStateChanged,OnRoomEventReceived,OnTerminate) and logsmain loop stuck for Ns inside game 'X' (room Y)(afterGameWatchdogSeconds, default 10) so ops knows exactly which game to blame - no matter which callback froze; recovery is a server restart. - Allowlist. A game is only creatable if it has an enabled entry in the server's
Gamesconfig - a bare DLL dropped inGames/is not loadable by clients. - Version floor. If your manifest sets
minServerVersion, a server below it refuses to load you with a clear error (instead of a cryptic runtime crash).
Updating your game (hot-reload)
Upload a new version via the admin panel and hit Reload: new rooms bind the new version immediately; live rooms finish on the old version ("draining", visible in the games panel); the old assembly unloads when its last room ends. No server restart - unless your DLL defines wire types (rule 5), which keeps the old assembly pinned in memory (functionally still swapped).
Ops runbook
| Symptom | Meaning | Action |
|---|---|---|
Games panel shows tripped (breaker) | Game exceeded the strike limit (exceptions / slow ticks - reason shown) | Investigate the strike reasons in the log, then fix or accept, then Reset in the panel (safe; nothing leaked) |
Log shows WATCHDOG: main loop stuck ... game 'X' | Game X's OnTick is hung; the node is frozen | Restart the server; disable game X (config Enabled: false) or fix it before re-enabling |
CreateRoom returns GameNotAllowed | Game not in the enabled config list, or breaker-tripped | Add/enable the Games entry, or reset the breaker |
Game won't load, log says requires server >= X | Manifest minServerVersion above this server | Upgrade the server or rebuild the game against it |
Reloaded game shows draining vX: N rooms | Old version still finishing its live rooms | Nothing - it unloads when the last room ends |
Config quick-reference (ServerConfig.yaml, all optional)
GameTickSoftBudgetMs: 10 # OnTick soft budget; over = strike; 0 disables duration strikes
GameBreakerStrikeLimit: 5 # strikes within the window that trip the breaker; 0 disables the breaker
GameBreakerWindowSeconds: 60
GameWatchdogSeconds: 10 # freeze-culprit log threshold; 0 disables
Games:
- { Name: MyGame, Enabled: true } # the allowlist: only enabled entries are creatable