Skip to main content

ODIN Fleet Integration with Nakama

This guide explains how to wire the ODIN Fleet Manager into Nakama so Nakama can discover and update game server instances running on ODIN Fleet. It also includes a mock game server so you can see the full flow end-to-end. The mock server is only an example; you must adapt the same ideas to your real game server.

Golang is optional. You can implement the same pattern in any language by generating a client from the ODIN Fleet OpenAPI spec. You can also check our API reference documentation.

The full example repository is available on GitHub.

The goal is simple:

  1. Nakama lists ODIN servers and selects one for a match.
  2. The game server marks itself as occupied or idle via ODIN metadata.

Everything below is written in plain language and includes copy-paste examples.

Requirements

  • ODIN Fleet account with an App and running server instances.
  • ODIN API token with permission to read and update servers.
  • Nakama with Go runtime enabled.
  • Go 1.25+ (matches go.mod).
  • ODIN OpenAPI client generated into a Go package named odinfleet-nakama/api (or adjust imports to your module path).
  • Basic access to set environment variables for Nakama and your game server.

What you get in this document

This document is standalone. It includes all essential code snippets so you can implement the ODIN Fleet Manager integration without reading the repository.

By the end, you will have:

  • A Nakama module that registers the ODIN fleet manager.
  • A working ODIN-to-Nakama adapter implementation.
  • A mock game server that updates ODIN metadata (example only; adapt it to your real server).

What Nakama expects

Nakama talks to any fleet provider through the FleetManager interface. This guide implements that interface for ODIN.

The most important methods are:

  • List: Get a list of available game server instances.
  • Get: Get a specific instance by ID.
  • Update: Update metadata (for example, set instance_state).

Create and Join are not supported by ODIN right now, so they return errors.

ODIN metadata (key idea)

ODIN stores metadata as simple key/value pairs. This integration relies on these keys:

  • instance_state: idle or occupied
  • game_port: the public game port

If you use different names, you must provide your own converter instead of ServerToInstanceInfo.

Minimal metadata example

{
"instance_state": "idle",
"game_port": 7777
}

Step 1: Register the fleet manager in Nakama

This is the smallest possible Nakama module that registers the ODIN Fleet Manager. Save it as nakama_module.go inside your Nakama module. If your module path is not odinfleet-nakama, update the import path accordingly.

package main

import (
"context"
"database/sql"
"errors"
"os"
"strconv"

odinfleet "odinfleet-nakama"

"github.com/heroiclabs/nakama-common/runtime"
)

func InitModule(ctx context.Context, logger runtime.Logger, db *sql.DB, nk runtime.NakamaModule, initializer runtime.Initializer) error {
appIDRaw := os.Getenv("ODIN_APP_ID")
if appIDRaw == "" {
return errors.New("missing ODIN_APP_ID")
}
appID, err := strconv.Atoi(appIDRaw)
if err != nil {
return err
}
apiToken := os.Getenv("ODIN_API_TOKEN")
if apiToken == "" {
return errors.New("missing ODIN_API_TOKEN")
}

cfg := odinfleet.GetDefaultConfiguration(int32(appID), apiToken)
fm, err := odinfleet.CreateOdinFleetManager(logger, cfg, odinfleet.ServerToInstanceInfo)
if err != nil {
return err
}

if err := initializer.RegisterFleetManager(fm); err != nil {
return err
}
logger.Info("ODIN fleet manager registered")
return nil
}

Environment variables for Nakama

These must be set in the Nakama runtime environment:

  • ODIN_APP_ID
  • ODIN_API_TOKEN

What happens if these are missing

The module will fail to start and Nakama will log an error. Fix the environment variables and restart.

Step 2: Matchmaking flow (what happens at runtime)

When Nakama creates a match:

  1. Nakama uses List to fetch ODIN instances.
  2. It selects one instance and tells the game server to start.
  3. The game server updates ODIN metadata to occupied.
  4. When the match ends, the server updates metadata to idle.

Nakama never creates instances here. It only selects from what ODIN already runs.

What actually calls Update

Your game server should call the ODIN API to set instance_state:

  • On startup: set idle.
  • On match start: set occupied.
  • On match end: set back to idle.

Step 3: Implement the fleet manager (core code)

This is the core ODIN Fleet Manager implementation. It is the adapter between Nakama and the ODIN Fleet API. Save this package as odinfleet inside your module and adjust the module path to match your go.mod.

package odinfleet

import (
"context"
"encoding/json"
"errors"
"fmt"
"odinfleet-nakama/api"
"strconv"

"github.com/heroiclabs/nakama-common/runtime"
)

type ServerToInstanceInfoConverter func(fm *OdinFleetManager, server *api.Server) (instanceInfo *runtime.InstanceInfo, err error)

type OdinFleetManager struct {
config *OdinFleetManagerConfig
serverToInstanceInfoConverterFunc ServerToInstanceInfoConverter
client *api.APIClient
nk runtime.NakamaModule
logger runtime.Logger
}

type OdinFleetManagerConfig struct {
AppId int32
ApiToken string
ClientCfg *api.Configuration
}

func GetDefaultConfiguration(appId int32, apiToken string) *OdinFleetManagerConfig {
cfg := api.NewConfiguration()
cfg.Host = "fleet.4players.io"
cfg.AddDefaultHeader("Authorization", fmt.Sprintf("Bearer %v", apiToken))

return &OdinFleetManagerConfig{
AppId: appId,
ClientCfg: cfg,
}
}

func CreateOdinFleetManager(logger runtime.Logger, config *OdinFleetManagerConfig, converterFunc ServerToInstanceInfoConverter) (fm *OdinFleetManager, err error) {
if config == nil {
return nil, errors.New("ODIN: Fleet manager config is missing")
}
if converterFunc == nil {
logger.Warn("ODIN: No implementation for ServerToInstanceInfoConverter given, will use default implementation")
converterFunc = ServerToInstanceInfo
}
client := api.NewAPIClient(config.ClientCfg)
return &OdinFleetManager{
serverToInstanceInfoConverterFunc: converterFunc,
client: client,
logger: logger,
config: config,
}, nil
}

func (fm OdinFleetManager) Init(nk runtime.NakamaModule, callbackHandler runtime.FmCallbackHandler) error {
fm.nk = nk
return nil
}

func (fm OdinFleetManager) Update(ctx context.Context, id string, playerCount int, metadata map[string]any) error {
serviceId, err := strconv.Atoi(id)
if err != nil {
return errors.New("ODIN: Invalid service id given")
}
request := fm.client.DockerAPI.DockerServicesMetadataUpdate(ctx, int32(serviceId)).
PatchMetadataRequest(api.PatchMetadataRequest{Metadata: metadata})
_, _, err = request.Execute()
return err
}

func (fm OdinFleetManager) Delete(ctx context.Context, id string) error {
return errors.New("ODIN: Deleting specific instance not yet implemented")
}

func (fm OdinFleetManager) Get(ctx context.Context, id string) (instance *runtime.InstanceInfo, err error) {
serviceId, err := strconv.Atoi(id)
if err != nil {
return nil, errors.New("ODIN: Invalid instance id given")
}
server, _, err := fm.client.DockerAPI.GetServerById(ctx, fm.config.AppId, int32(serviceId)).Execute()
if err != nil {
return nil, errors.New("ODIN: Could not fetch ODIN fleet instance")
}
return fm.serverToInstanceInfoConverterFunc(&fm, server)
}

func (fm OdinFleetManager) List(ctx context.Context, query string, limit int, previousCursor string) (
instances []*runtime.InstanceInfo,
nextCursor string,
err error,
) {
params := &GetServersParams{}
if err := json.Unmarshal([]byte(query), params); err != nil {
params = nil
}
servers, nextPage, err := fm.GetServers(ctx, params)
if err != nil {
return nil, "", err
}
instances = []*runtime.InstanceInfo{}
for _, s := range servers {
instance, err := fm.serverToInstanceInfoConverterFunc(&fm, &s)
if err != nil {
continue
}
instances = append(instances, instance)
}
if nextPage != 0 {
nextCursor = strconv.Itoa(int(nextPage))
}
return instances, nextCursor, nil
}

type GetServersParams struct {
Pages int32
PerPage int32
Metadata map[string]any
Status *string
Location *FleetLocation
}

type FleetLocation struct {
Continent *string
City *string
Country *string
}

func (fm OdinFleetManager) GetServers(ctx context.Context, params *GetServersParams) (
servers []api.Server,
nextPage int32,
err error,
) {
request := fm.generateGetServersRequest(ctx, params)
response, _, err := request.Execute()
if err != nil {
return nil, 0, err
}
if p := params; p != nil && p.Pages != 0 {
nextPage = p.Pages + 1
}
return response.Data, nextPage, nil
}

func (fm OdinFleetManager) generateGetServersRequest(ctx context.Context, params *GetServersParams) api.DockerAPIGetServersRequest {
request := fm.client.DockerAPI.GetServers(ctx, fm.config.AppId)
if params == nil {
return request
}
if perPage := params.PerPage; perPage > 0 {
request = request.PerPage(perPage)
}
if pages := params.Pages; pages > 0 {
request = request.Page(pages)
}
if m := params.Metadata; len(m) > 0 {
metadata := createQuery(m)
request = request.FilterMetadata(metadata)
}
if l := params.Location; l != nil {
if city := l.City; city != nil {
request = request.FilterLocationCity(*city)
}
if country := l.Country; country != nil {
request = request.FilterLocationCountry(*country)
}
if continent := l.Continent; continent != nil {
request = request.FilterLocationContinent(*continent)
}
}
if status := params.Status; status != nil {
request = request.FilterStatus(*status)
}
return request
}

func (fm OdinFleetManager) Create(ctx context.Context, maxPlayers int, userIds []string, latencies []runtime.FleetUserLatencies, metadata map[string]any, callback runtime.FmCreateCallbackFn) (err error) {
return errors.New("ODIN: Creating specific odin instance not supported yet")
}

func (fm OdinFleetManager) Join(ctx context.Context, id string, userIds []string, metadata map[string]string) (joinInfo *runtime.JoinInfo, err error) {
return nil, errors.New("ODIN: Join workflow not defined yet")
}

Step 4: Convert ODIN servers into Nakama instances

The default converter expects a game_port metadata key and adds ODIN port values into metadata.

package odinfleet

import (
"fmt"
"odinfleet-nakama/api"
"strconv"
"strings"
"time"

"github.com/heroiclabs/nakama-common/runtime"
)

func ServerToInstanceInfo(fm *OdinFleetManager, server *api.Server) (*runtime.InstanceInfo, error) {
id := strconv.Itoa(int(server.Id))
metadata := server.Metadata
for _, port := range server.Ports {
if port.PublishedPort.IsSet() == false {
continue
}
metadata[port.Name] = *port.PublishedPort.Get()
}
connectionInfo, err := nodeToConnectionInfo(server, "game_port")
if err != nil {
return nil, err
}
status := server.Status

var createdTime time.Time
if t := server.CreatedAt.Get(); t != nil {
createdTime = *t
} else {
createdTime = time.Now().UTC()
}

return &runtime.InstanceInfo{
Id: id,
ConnectionInfo: connectionInfo,
CreateTime: createdTime,
PlayerCount: 0,
Status: status,
Metadata: metadata,
}, nil
}

func nodeToConnectionInfo(server *api.Server, portName string) (connection *runtime.ConnectionInfo, err error) {
port, portExists := server.Metadata[portName]
if portExists == false {
return nil, fmt.Errorf("ODIN: No port with name %v was found", portName)
}
return &runtime.ConnectionInfo{
IpAddress: server.Node.Address,
Port: (int)(port.(int32)),
}, nil
}

func createQuery(metadata map[string]interface{}) string {
builder := strings.Builder{}
for k, v := range metadata {
builder.WriteString(fmt.Sprintf("%s=\"%v\"", k, v))
}
return builder.String()
}

Step 5: Run the mock game server (example only)

This is a minimal HTTP server that behaves like a real game server. It is a mock and only exists to demonstrate the flow. You must adapt these ideas to your real game server, its APIs, and its runtime.

  • On startup it marks itself as idle.
  • POST /match/start sets instance_state=occupied.
  • POST /match/finish sets instance_state=idle.
  • GET /health returns a simple ok response.

Save this as mock_game_server.go:

package main

import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"os"
"strconv"
"time"

"odinfleet-nakama/api"
)

const (
defaultListenAddr = ":8080"
defaultGamePort = 7777
)

type server struct {
client *api.APIClient
serviceID int32
gamePort int32
}

type startMatchRequest struct {
MatchID string `json:"match_id"`
GameMode string `json:"game_mode"`
MapGUID string `json:"map_guid"`
JoinMode string `json:"join_mode"`
UserIDs []string `json:"user_ids"`
}

type finishMatchRequest struct {
MatchID string `json:"match_id"`
Outcome string `json:"outcome"`
}

func main() {
cfg, serviceID, gamePort, listenAddr, err := loadConfigFromEnv()
if err != nil {
log.Fatal(err)
}

srv := &server{
client: api.NewAPIClient(cfg),
serviceID: serviceID,
gamePort: gamePort,
}

if err := srv.setReady(context.Background()); err != nil {
log.Fatalf("set ready: %v", err)
}

mux := http.NewServeMux()
mux.HandleFunc("/health", srv.handleHealth)
mux.HandleFunc("/match/start", srv.handleStartMatch)
mux.HandleFunc("/match/finish", srv.handleFinishMatch)

log.Printf("mock game server listening on %s", listenAddr)
if err := http.ListenAndServe(listenAddr, mux); err != nil {
log.Fatal(err)
}
}

func loadConfigFromEnv() (*api.Configuration, int32, int32, string, error) {
apiToken := os.Getenv("ODIN_API_TOKEN")
if apiToken == "" {
return nil, 0, 0, "", errors.New("missing ODIN_API_TOKEN")
}

serviceIDRaw := os.Getenv("ODIN_SERVICE_ID")
if serviceIDRaw == "" {
return nil, 0, 0, "", errors.New("missing ODIN_SERVICE_ID")
}
serviceID, err := strconv.Atoi(serviceIDRaw)
if err != nil {
return nil, 0, 0, "", fmt.Errorf("invalid ODIN_SERVICE_ID: %w", err)
}

gamePort := defaultGamePort
if portRaw := os.Getenv("ODIN_GAME_PORT"); portRaw != "" {
parsedPort, err := strconv.Atoi(portRaw)
if err != nil {
return nil, 0, 0, "", fmt.Errorf("invalid ODIN_GAME_PORT: %w", err)
}
gamePort = parsedPort
}

listenAddr := os.Getenv("MOCK_SERVER_ADDR")
if listenAddr == "" {
listenAddr = defaultListenAddr
}

cfg := api.NewConfiguration()
cfg.Host = "fleet.4players.io"
cfg.AddDefaultHeader("Authorization", fmt.Sprintf("Bearer %v", apiToken))

return cfg, int32(serviceID), int32(gamePort), listenAddr, nil
}

func (s *server) handleHealth(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"status": "ok",
"time": time.Now().UTC().Format(time.RFC3339),
})
}

func (s *server) handleStartMatch(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "method not allowed"})
return
}

var req startMatchRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid JSON body"})
return
}
if req.MatchID == "" {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "match_id is required"})
return
}

if req.JoinMode == "" {
req.JoinMode = "free"
}

metadata := map[string]any{
"instance_state": "occupied",
"match_id": req.MatchID,
"game_mode": req.GameMode,
"map_guid": req.MapGUID,
"join_mode": req.JoinMode,
"user_ids": req.UserIDs,
"game_port": s.gamePort,
}
if err := s.updateMetadata(r.Context(), metadata); err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()})
return
}

writeJSON(w, http.StatusOK, map[string]any{"status": "match started"})
}

func (s *server) handleFinishMatch(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
writeJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "method not allowed"})
return
}

var req finishMatchRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]any{"error": "invalid JSON body"})
return
}

metadata := map[string]any{
"instance_state": "idle",
"match_id": "",
"last_outcome": req.Outcome,
"user_ids": []string{},
"game_port": s.gamePort,
}
if err := s.updateMetadata(r.Context(), metadata); err != nil {
writeJSON(w, http.StatusBadGateway, map[string]any{"error": err.Error()})
return
}

writeJSON(w, http.StatusOK, map[string]any{"status": "match finished"})
}

func (s *server) setReady(ctx context.Context) error {
metadata := map[string]any{
"instance_state": "idle",
"game_port": s.gamePort,
}
return s.updateMetadata(ctx, metadata)
}

func (s *server) updateMetadata(ctx context.Context, metadata map[string]any) error {
req := s.client.DockerAPI.DockerServicesMetadataUpdate(ctx, s.serviceID).
PatchMetadataRequest(api.PatchMetadataRequest{Metadata: metadata})
_, _, err := req.Execute()
return err
}

func writeJSON(w http.ResponseWriter, status int, payload map[string]any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(payload)
}

Environment variables

  • ODIN_API_TOKEN (required)
  • ODIN_SERVICE_ID (required) - ODIN service/instance id
  • ODIN_GAME_PORT (optional, default 7777)
  • MOCK_SERVER_ADDR (optional, default :8080)

What happens when you run it (example behavior)

  1. It sets instance_state=idle in ODIN.
  2. It waits for HTTP requests.
  3. It updates metadata when you call /match/start or /match/finish.

Start the server

ODIN_API_TOKEN=... \
ODIN_SERVICE_ID=1234 \
ODIN_GAME_PORT=7777 \
MOCK_SERVER_ADDR=:8080 \
go run ./mock_game_server.go

Start a match

curl -X POST http://localhost:8080/match/start \
-H "Content-Type: application/json" \
-d '{
"match_id": "match-123",
"game_mode": "DEATH_MATCH",
"map_guid": "map-01",
"join_mode": "free",
"user_ids": ["user-1", "user-2"]
}'

Finish a match

curl -X POST http://localhost:8080/match/finish \
-H "Content-Type: application/json" \
-d '{
"match_id": "match-123",
"outcome": "team-blue-wins"
}'

Health check

curl http://localhost:8080/health

How List filtering works

The List method accepts a JSON string in query. It is parsed as GetServersParams.

Example:

{
"Metadata": {
"instance_state": "idle"
},
"Status": "running",
"Location": {
"Continent": "europe",
"City": "limburg"
}
}

PerPage and Pages also work for pagination.

Basic filter example (copy/paste)

{
"Metadata": {
"instance_state": "idle"
}
}

Common mistakes

  1. Missing game_port

    • ServerToInstanceInfo expects a game_port metadata key.
    • If not present, the conversion fails and Nakama cannot connect.
  2. Wrong ODIN_SERVICE_ID

    • Updates go to the wrong instance or fail.
  3. Wrong ODIN_APP_ID

    • List returns nothing or errors.
  4. Token without permissions

    • API calls fail with authorization errors.
  5. Metadata not updated by the server

    • Nakama will keep picking servers that look idle.
    • Fix: make sure your game server updates instance_state on match start/end.

What is not implemented

These features are not available because the ODIN API does not provide them:

  • Create (create new instances)
  • Join (reserve slots on a running instance)

They are intentionally not implemented and return errors.

Why Create is not available

This implementation expects there to always be a fixed amount of idling servers for increased predictability.

This can easily be changed by automatically creating a new deployment in the Create method.

Why Join is not available

Join expects a provider-side reservation endpoint. ODIN does not expose that, so there is nothing to call and no standard reservation token to return.

The normal ODIN + Nakama flow does not rely on provider reservations:

  • Nakama selects a server via List.
  • The game server updates instance_state and metadata via ODIN.
  • Clients connect directly to the server.

If you ever need stricter reservations, you can add them at the application level without going through ODIN Fleet itself.

Minimal checklist

  1. Set ODIN_APP_ID and ODIN_API_TOKEN in Nakama.
  2. Register the fleet manager (examples/nakama_module.go).
  3. Ensure every real game server sets game_port and instance_state in ODIN metadata (do not ship the mock server as-is).
  4. Run matchmaking in Nakama.

That is enough to use ODIN Fleet with Nakama.