Skip to main content

Nakama

Nakama is a popular open-source game backend that handles accounts, social features, storage, and real-time multiplayer.

Keep Nakama in charge of your players and their data, and let GameFlow host the dedicated game servers they play on.

How It Works​

A small TypeScript module in Nakama wraps the GameFlow API and exposes it as RPCs. When a player asks for a server, Nakama asks GameFlow to start one and hands the connection details back as soon as it's ready. The player then connects directly to the game server.

Nakama only brokers the start and never proxies game traffic. There's no polling either: when the RPC returns, the server is ready for players.

The Flow​

  1. The player signs in to Nakama.
  2. The client calls gameflow_start_server with the region to play in.
  3. Nakama asks GameFlow to start a server in that region.
  4. GameFlow responds once the server is ready, with its host and port.
  5. Nakama remembers the server for that player and returns the connection details.
  6. The client connects directly to the game server and plays.

RPCs​

The module adds four RPCs to Nakama. Each one requires an authenticated session.

RPCPayloadDescription
gameflow_regions{}Lists the regions you can start servers in.
gameflow_start_server{region}Starts a server and returns its host and port once it's ready.
gameflow_server{}Returns the player's server, including its state and player count.
gameflow_stop{}Stops the player's server.

Each player has one server at a time, and starting a new one stops the previous one. Players can only see or stop their own server, and your GameFlow API key never leaves Nakama.

IP or hostname

Each server comes with both an IP address and a hostname, so pick what suits your game. Native clients can connect straight to the IP over UDP or TCP. Browser games usually need TLS, so connect them to the hostname, such as 203-0-113-42.server.gameflow.gg, which has a valid certificate.

The Module​

The whole integration is a single file:

  1. Add gameflow.ts to your Nakama TypeScript project.
  2. List it in the files of your tsconfig.json.
  3. Register its RPCs in InitModule.
main.ts
function InitModule(
ctx: nkruntime.Context,
logger: nkruntime.Logger,
nk: nkruntime.Nakama,
initializer: nkruntime.Initializer,
) {
initializer.registerRpc('gameflow_regions', rpcGameflowRegions)
initializer.registerRpc('gameflow_start_server', rpcGameflowStartServer)
initializer.registerRpc('gameflow_server', rpcGameflowServer)
initializer.registerRpc('gameflow_stop', rpcGameflowStop)
}
View the full module, gameflow.ts
gameflow.ts
// Starts dedicated game servers on GameFlow for Nakama players.
// Reads GAMEFLOW_API_KEY and GAMEFLOW_GAME_ID from the Nakama runtime env.
// Each player's server is saved, so players can only manage their own.

const GAMEFLOW_API_URL = 'https://api.gameflow.gg'
const GAMEFLOW_COLLECTION = 'gameflow'
const SERVER_KEY = 'server'

const REQUEST_TIMEOUT_MS = 10000
const START_TIMEOUT_MS = 125000

const REGION_PATTERN = /^[A-Za-z0-9_-]{1,64}$/

interface GameFlowConfig {
apiKey: string
gameId: string
}

interface StoredServer {
serverName: string
region: string
startedAt: number
}

// Helpers

function rpcError(message: string, code: nkruntime.Codes): nkruntime.Error {
return { message, code } as nkruntime.Error
}

function requireUser(ctx: nkruntime.Context): string {
if (!ctx.userId) {
throw rpcError('Authentication required.', nkruntime.Codes.UNAUTHENTICATED)
}

return ctx.userId
}

function gameflowConfig(ctx: nkruntime.Context): GameFlowConfig {
const env = ctx.env || {}
const apiKey = env['GAMEFLOW_API_KEY']
const gameId = env['GAMEFLOW_GAME_ID']

if (!apiKey || !gameId) {
throw rpcError(
'GameFlow is not configured on this server.',
nkruntime.Codes.FAILED_PRECONDITION,
)
}

return { apiKey, gameId }
}

/** Calls the GameFlow API and returns the parsed JSON body. */
function gameflowCall(
cfg: GameFlowConfig,
nk: nkruntime.Nakama,
logger: nkruntime.Logger,
method: nkruntime.RequestMethod,
path: string,
body?: object,
timeoutMs: number = REQUEST_TIMEOUT_MS,
): any {
const headers = {
'x-api-key': cfg.apiKey,
'Content-Type': 'application/json',
Accept: 'application/json',
}

let res: nkruntime.HttpResponse

try {
res = nk.httpRequest(
GAMEFLOW_API_URL + path,
method,
headers,
body ? JSON.stringify(body) : undefined,
timeoutMs,
)
} catch (e) {
logger.error('GameFlow %s %s failed: %s', method, path, String(e))
throw rpcError('GameFlow is unreachable.', nkruntime.Codes.UNAVAILABLE)
}

if (res.code < 200 || res.code >= 300) {
// Log the details for operators, give clients only a generic message.
logger.error(
'GameFlow %s %s returned %d: %s',
method,
path,
res.code,
res.body,
)

if (res.code === 503) {
throw rpcError(
'No game servers available, try again shortly.',
nkruntime.Codes.UNAVAILABLE,
)
}

if (res.code === 401 || res.code === 403) {
throw rpcError(
'GameFlow credentials were rejected.',
nkruntime.Codes.INTERNAL,
)
}

if (res.code === 404) {
throw rpcError(
'GameFlow game or server not found.',
nkruntime.Codes.NOT_FOUND,
)
}

throw rpcError('GameFlow request failed.', nkruntime.Codes.INTERNAL)
}

if (!res.body) {
return {}
}

try {
return JSON.parse(res.body)
} catch (e) {
logger.error('GameFlow %s %s returned invalid JSON', method, path)
throw rpcError(
'GameFlow returned an invalid response.',
nkruntime.Codes.INTERNAL,
)
}
}

function serverPath(cfg: GameFlowConfig, stored: StoredServer): string {
const gameId = encodeURIComponent(cfg.gameId)
const serverName = encodeURIComponent(stored.serverName)
const region = encodeURIComponent(stored.region)

return `/v1/games/${gameId}/servers/${serverName}?region=${region}`
}

/** Returns the server saved for this player, or null if there is none. */
function findStoredServer(
nk: nkruntime.Nakama,
userId: string,
): StoredServer | null {
const objects = nk.storageRead([
{ collection: GAMEFLOW_COLLECTION, key: SERVER_KEY, userId },
])

if (objects.length === 0) {
return null
}

return objects[0].value as StoredServer
}

/** Returns the server saved for this player, or throws NOT_FOUND. */
function readStoredServer(nk: nkruntime.Nakama, userId: string): StoredServer {
const stored = findStoredServer(nk, userId)

if (!stored) {
throw rpcError('No server started.', nkruntime.Codes.NOT_FOUND)
}

return stored
}

/** Stops a player's server and forgets it, even if it already shut down. */
function stopStoredServer(
cfg: GameFlowConfig,
nk: nkruntime.Nakama,
logger: nkruntime.Logger,
userId: string,
stored: StoredServer,
): void {
try {
gameflowCall(cfg, nk, logger, 'delete', serverPath(cfg, stored))
} catch (e) {
const alreadyGone =
(e as nkruntime.Error).code === nkruntime.Codes.NOT_FOUND

if (!alreadyGone) {
throw e
}
}

nk.storageDelete([
{ collection: GAMEFLOW_COLLECTION, key: SERVER_KEY, userId },
])

logger.info('Player %s stopped GameFlow server %s', userId, stored.serverName)
}

// RPCs

/** gameflow_regions: every region a server can be started in. */
const rpcGameflowRegions: nkruntime.RpcFunction = function (ctx, logger, nk) {
requireUser(ctx)
const cfg = gameflowConfig(ctx)

const data = gameflowCall(cfg, nk, logger, 'get', '/v1/regions/available')

return JSON.stringify({ regions: data.regions || [] })
}

/** gameflow_start_server: starts a server and returns how to connect. */
const rpcGameflowStartServer: nkruntime.RpcFunction = function (
ctx,
logger,
nk,
payload,
) {
const userId = requireUser(ctx)
const cfg = gameflowConfig(ctx)

// Validate the requested region
let input: { region?: unknown }

try {
input = JSON.parse(payload || '{}')
} catch (e) {
throw rpcError('Payload must be JSON.', nkruntime.Codes.INVALID_ARGUMENT)
}

const region = input.region

if (typeof region !== 'string' || !REGION_PATTERN.test(region)) {
throw rpcError(
'A valid region is required.',
nkruntime.Codes.INVALID_ARGUMENT,
)
}

// Each player has one server at a time, so stop the previous one
const previous = findStoredServer(nk, userId)

if (previous) {
stopStoredServer(cfg, nk, logger, userId, previous)
}

// Start the new server
const path = `/v1/games/${encodeURIComponent(cfg.gameId)}/servers`
const body = {
region,
payload: JSON.stringify({ source: 'nakama', players: [userId] }),
}

const data = gameflowCall(
cfg,
nk,
logger,
'post',
path,
body,
START_TIMEOUT_MS,
)
const server = data.server

if (!server || !server.name || !server.host || !server.port) {
logger.error(
'GameFlow start response is missing server details: %s',
JSON.stringify(data),
)
throw rpcError(
'GameFlow returned an invalid response.',
nkruntime.Codes.INTERNAL,
)
}

// Remember the server for this player
const stored: StoredServer = {
serverName: server.name,
region,
startedAt: Date.now(),
}

nk.storageWrite([
{
collection: GAMEFLOW_COLLECTION,
key: SERVER_KEY,
userId,
value: stored,
permissionRead: 1, // Owner can read
permissionWrite: 0, // Only the server writes
},
])

logger.info(
'Player %s started GameFlow server %s in %s',
userId,
server.name,
region,
)

return JSON.stringify({
serverName: server.name,
region,
state: server.state,
host: server.host,
port: server.port,
ports: server.ports || [],
})
}

/** gameflow_server: live details of the caller's server. */
const rpcGameflowServer: nkruntime.RpcFunction = function (ctx, logger, nk) {
const userId = requireUser(ctx)
const cfg = gameflowConfig(ctx)
const stored = readStoredServer(nk, userId)

const data = gameflowCall(cfg, nk, logger, 'get', serverPath(cfg, stored))

return JSON.stringify({
serverName: stored.serverName,
region: stored.region,
state: data.state,
host: data.host,
port: data.port,
ports: data.ports || [],
playerCount: data.playerCount,
playerCapacity: data.playerCapacity,
})
}

/** gameflow_stop: stops the caller's server and forgets it. */
const rpcGameflowStop: nkruntime.RpcFunction = function (ctx, logger, nk) {
const userId = requireUser(ctx)
const cfg = gameflowConfig(ctx)
const stored = readStoredServer(nk, userId)

stopStoredServer(cfg, nk, logger, userId, stored)

return JSON.stringify({ stopped: stored.serverName })
}

Every server starts with the player's Nakama user ID in its launch payload, so your game server knows who to expect.

Configuration​

Set two values in Nakama's runtime environment, for example in a local.yml config file:

KeyDescription
GAMEFLOW_API_KEYYour API key, from Settings → API.
GAMEFLOW_GAME_IDThe GameFlow game your servers belong to.
local.yml
runtime:
env:
- "GAMEFLOW_API_KEY=your-api-key"
- "GAMEFLOW_GAME_ID=your-game-id"
caution

local.yml holds your API key. Keep it out of version control.

Timeouts​

gameflow_start_server responds as soon as the server is ready, and never takes longer than 120 seconds. Give every hop along the way a little more room than that:

WhereSetting
Nakama--socket.write_timeout_ms 130000
Game client130 seconds, for gameflow_start_server only
Proxy or load balancerAbove 120 seconds (most default to 30 to 60)

Calling From the Client​

Using nakama-js, give the start call its own client with a longer timeout:

client.ts
import { Client } from '@heroiclabs/nakama-js'

const START_TIMEOUT_MS = 130000

const startClient = new Client(
serverKey,
host,
port,
useSSL,
START_TIMEOUT_MS,
)

const { payload } = await startClient.rpc(
session,
'gameflow_start_server',
{ region: 'us-east' },
)

const server = payload as { host: string; port: number }

connectToGameServer(server.host, server.port)

When the match ends, call gameflow_stop, or let your game server shut itself down once it's empty.

Next Steps​