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:
- Nakama lists ODIN servers and selects one for a match.
- 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 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.
{
"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:
- Nakama uses
List to fetch ODIN instances.
- It selects one instance and tells the game server to start.
- The game server updates ODIN metadata to
occupied.
- 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)
- It sets
instance_state=idle in ODIN.
- It waits for HTTP requests.
- 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
-
Missing game_port
ServerToInstanceInfo expects a game_port metadata key.
- If not present, the conversion fails and Nakama cannot connect.
-
Wrong ODIN_SERVICE_ID
- Updates go to the wrong instance or fail.
-
Wrong ODIN_APP_ID
List returns nothing or errors.
-
Token without permissions
- API calls fail with authorization errors.
-
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
- Set
ODIN_APP_ID and ODIN_API_TOKEN in Nakama.
- Register the fleet manager (
examples/nakama_module.go).
- Ensure every real game server sets
game_port and instance_state in ODIN metadata (do not ship the mock server as-is).
- Run matchmaking in Nakama.
That is enough to use ODIN Fleet with Nakama.