@table-games-engine/nakama-adapter
v0.1.3
Published
Nakama runtime match handler for table-games-engine games.
Maintainers
Readme
@table-games-engine/nakama-adapter
Nakama runtime bindings for @table-games-engine/core: wraps the engine (and @table-games-engine/persistence) in a Nakama TypeScript-runtime match handler so players can connect over WebSocket and play your game.
Most game authors don't install this directly: it comes with @table-games-engine/game-kit, whose preview command and the Logigra store host your game for you. Install it to run your own Nakama server.
Install
npm install @table-games-engine/nakama-adapterWhat you get
createMatchHandler(opts)returns a NakamaMatchHandlerimplementingmatchInit,matchJoinAttempt,matchJoin,matchLeave,matchLoop,matchSignalandmatchTerminate.GameRegistryresolvesgameIdto{ manifest, handlers, setup }atmatchInit, so one runtime can host many games (registerPluginregisters aGamePlugin).IdempotencyCacheabsorbs client retries.- JSON wire format (
parseInbound,serializeOutbound,OPCODES), per-player filtered broadcasts (broadcastViews), action timeouts (checkTimeouts), reconnection-aware joins. - Persistence wiring: events are appended per transition and snapshots follow a
SnapshotPolicy. NakamaPlatformPluginhooks for analytics, audit and similar.
Wiring into Nakama
import { createMatchHandler, registerPlugin, GameRegistry, IdempotencyCache } from '@table-games-engine/nakama-adapter';
import { SqliteAdapter, everyNTransitions } from '@table-games-engine/persistence';
const registry = new GameRegistry();
registerPlugin(registry, myGamePlugin); // a GamePlugin; or registry.register({ gameId, manifest, handlers, setup }) by hand
const handler = createMatchHandler({
registry,
idempotencyCache: new IdempotencyCache(),
adapter: new SqliteAdapter('/data/engine.db'),
policy: everyNTransitions(50),
ownerNode: 'node-A',
leaseSeconds: 60,
timeout: { timeoutMs: 30_000 },
plugins: [],
});
function InitModule(_ctx: nkruntime.Context, logger: nkruntime.Logger, _nk: nkruntime.Nakama, initializer: nkruntime.Initializer): void {
initializer.registerMatch('tabletop_match', handler);
}Create a match from your own server code:
nk.matchCreate('tabletop_match', {
gameId: 'my-game',
playerIds: 'alice,bob', // comma-separated; passed to setup()
config: JSON.stringify({ /* game options */ }),
});The adapter reads no environment variables; plumb configuration into the options yourself.
Store options (0.1.2)
Match params (matchCreate; strings only):
| Param | Format | Meaning |
|---|---|---|
| gameId | string | registry key (= slug) |
| playerIds | comma-joined user ids | fixed seats (matchmade). Absent → open seats |
| players | decimal string | seat count for open seats |
| config | JSON string | preset config passed to setup |
| seed | string | setup seed. Absent → today's seed (preview only) |
| lobbyTimeoutMs | decimal string | per-match lobby deadline; overrides the option |
| meta | JSON string | opaque; returned to hooks as match.meta |
params.matchId is ignored (the match id is always Nakama's).
Handler options:
| Option | Behaviour |
|---|---|
| tickRate | returned from matchInit |
| reconnectGraceMs | a seated player whose last presence left keeps the seat this long, then is ejected (forfeit) |
| lobbyTimeoutMs | a match still in lobby at the deadline ends: no setup is played, hooks see nothing, clients get NOTICE lobby-expired, the loop returns null |
| turnTimeoutMs | a priority holder with no accepted input for this long is ejected (forfeit). The clock resets on every accepted input and starts when the match starts |
| maxGameErrors | ends the match as game_error once this many game-code errors are counted. Unset → never |
| requireLegalMove | an input whose canonical {type, data} differs from every entry of legalMoves(state, seat) is rejected (ILLEGAL_MOVE). Concede is exempt |
| endLingerMs | after match-ended the match keeps answering joins with the final view for this long, then the loop returns null. A post-end input acks MATCH_ENDED |
| hooks.onMatchStarted(env, match) | synchronous; once, when the match leaves lobby |
| hooks.onMatchEnded(env, match, endReason, result) | synchronous; once per match. endReason is completed, forfeit or game_error |
@table-games-engine/core now exports winResult for building a win GameResult.
Lifecycle:
lobby ──(all seats joined)──▶ active ──(result)──▶ ended ──(endLingerMs)──▶ freed (loop → null)
│ │
└─(lobbyTimeoutMs)──▶ freed └─(terminating flag: plays on until result or Nakama's deadline)terminating: after matchTerminate the match plays on, clients get the server-restart notice, grace and turn timeouts are suspended, and it ends normally or Nakama stops it at its deadline.
NOTICE(opcode 6):{ notice }to one or all clients, the adapter sendsserver-restartandlobby-expired(hosts deriveopponent-leftfrom presence events); send withsendNotice.match-ended(opcode 4) carries noresultwhen the game failed (game_error); client-sdk subscribers then receiveundefined.
Persistence and reconnection
With adapter set, matchInit stores snapshot v0 (and takes an ownership lease if ownerNode is set), each transition appends its events and may snapshot, and matchTerminate releases the lease without closing the adapter. When the same user rejoins with a new session, the handler swaps the presence, sends a fresh filtered state to that player only, and keeps the idempotency cache.
Plugins
import type { NakamaPlatformPlugin } from '@table-games-engine/nakama-adapter';
const audit: NakamaPlatformPlugin = {
name: 'audit',
async onMatchEnded(ctx, result) { /* ... */ },
};Hooks: onMatchCreated, onMatchStarted, onPlayerJoined, onPlayerLeft, onPlayerAction, onStateTransition, onMatchEnded. All are optional and async. They are fire-and-forget: the match loop never waits for them, and a failing plugin is logged but never crashes the match.
Licence
FSL-1.1-ALv2 (Functional Source License, converts to Apache-2.0 after two years). See LICENSE.md in the package.
