Skip to main content

Writing a Server Game for Entangle - Contract & Operations

The rules a custom C# game (IEntangleRoom plugin) 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)

  1. No mutable static state. Rooms of your game share the assembly - a static field is shared across all rooms (and survives room restarts). Keep all state on the instance.
  2. 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.
  3. No blocking I/O / no long work in callbacks. OnTick has a soft budget (GameTickSoftBudgetMs, default 10 ms). Database calls, file I/O, HTTP - keep them out of the tick path.
  4. Exceptions are yours to prevent. The server catches everything you throw (the node survives), but each throw is a strike (below).
  5. 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 receives OnRoomEventReceived(senderId, eventData) and reads eventData.EventId / eventData.Data. Note: SendEvent rejects an event with no data - send at least one byte.
  • Game -> client(s): build a RoomEventData { EventId, DeliveryMethod, Data } and call ctx.BroadcastRoomEvent(evt) (or SendRoomEventToPlayer). On the client, an IEntangleRoomListener gets OnEventReceived(sender, evt) - read evt.EventCode and evt.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 (tag lets 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 OnTick over the soft budget, is one strike. GameBreakerStrikeLimit (5) strikes within GameBreakerWindowSeconds (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 with RoomResponseCode.GameNotAllowed until 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 logs main loop stuck for Ns inside game 'X' (room Y) (after GameWatchdogSeconds, 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 Games config - a bare DLL dropped in Games/ 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

SymptomMeaningAction
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 frozenRestart the server; disable game X (config Enabled: false) or fix it before re-enabling
CreateRoom returns GameNotAllowedGame not in the enabled config list, or breaker-trippedAdd/enable the Games entry, or reset the breaker
Game won't load, log says requires server >= XManifest minServerVersion above this serverUpgrade the server or rebuild the game against it
Reloaded game shows draining vX: N roomsOld version still finishing its live roomsNothing - 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