Skip to content

Instantly share code, notes, and snippets.

@ultimateprogramer
Forked from MysteryPoo/MyNakama.gd
Created April 12, 2025 07:48
Show Gist options
  • Save ultimateprogramer/500242c623316491c0c447bae9392f12 to your computer and use it in GitHub Desktop.
Save ultimateprogramer/500242c623316491c0c447bae9392f12 to your computer and use it in GitHub Desktop.
Initial prototype for attaching dockerized dedicated Game Servers to Nakama matches
----------------------------------------------------------------------------
-- * "THE BEER-WARE LICENSE" (Revision 42):
-- * MysteryPoo wrote this file. As long as you retain this notice you
-- * can do whatever you want with this stuff. If we meet some day, and you think
-- * this stuff is worth it, you can buy me a beer in return. MysteryPoo
------------------------------------------------------------------------------
-- This module contains the API to interact with the Docker Engine API facilitating the
-- dynamic creation of Game Server containers within a restricted port range
-- Obviously needs work
local nk = require("nakama")
local M = {}
-- Setting up some constants (Preferably I'd like to inject these through an external config at runtime, somehow
local DOCKER_HOSTNAME = "host.docker.internal"
local DOCKER_PORT = 2375
local nakamaIp = "nakama"
local nakamaPort = 7350
local minPort = 40100
local maxPort = 40199
-- Helper function to encode a full URL string based on parameters
-- returns a URL endpoint
local function GetUrl(path)
return string.format("http://%s:%d/%s", DOCKER_HOSTNAME, DOCKER_PORT, path)
end
-- TODO : At the moment, only RequestGameServer needs to be part of the public API,
-- whereas the rest of the functions are utility and could be changed to local,
-- however I will leave them as such until I know for sure I won't need them exposed
-- Create, start, and retrieve the public/external port of a Game Server container
-- returns the port that the clients should connect to
function M.RequestGameServer(matchid, password)
local port = M.GetFreePort()
if port == -1 then
error("No servers available.")
else
local success, containerId = pcall(M.CreateContainer, port, matchid, password)
if (not success) then
nk.logger_error(string.format("Failure to create container: %q", containerId))
error(containerId)
else
local success, actualPort = pcall(M.StartContainer, containerId)
if (not success) then
nk.logger_error(string.format("Failure to start container: %q", actualPort))
error(actualPort)
else
return actualPort
end
end
end
end
-- Helper function to derive a free port within a given range based on actual running containers
-- Returns an unused port number
function M.GetFreePort()
local runningGameServers = M.GetServerPool()
for port = minPort, maxPort, 1 do
local available = true
for _, container in ipairs(runningGameServers) do
if container["Port"] == port then
available = false
break
end
end
if available then
return port
end
end
return -1
end
-- Helper function to get a list of actual running containers of a specific image (hardcoded atm)
-- returns a list of containers and their ports
-- TODO : Perhaps simplify this to return a list of used external ports. This is more valuable than all the extra junk
function M.GetServerPool()
local gameServerContainers = {}
local path = "containers/json"
local url = GetUrl(path)
local method = "GET"
local headers = {
["Accept"] = "application/json"
}
local success, code, _, body = pcall(nk.http_request, url, method, headers, nil)
if (not success) then
nk.logger_error(string.format("Failed request %q", code))
error(code)
elseif (code >= 400) then
nk.logger_error(string.format("Failed request %q %q", code, body))
error(body)
else
local containerList = nk.json_decode(body)
for containerIndex in pairs(containerList) do
local container = containerList[containerIndex]
if container["Image"] == "victordavion/ddags:latest" then
local ports = container["Ports"]
local port = ports[1]
table.insert(gameServerContainers, {
["Port"] = port["PublicPort"],
["Container"] = container
})
end
end
end
return gameServerContainers
end
-- Create a container of a specific (hardcoded) image exposing the port on both tcp/udp
-- returns the container id
function M.CreateContainer(port, matchid, password)
local path = "containers/create"
local url = GetUrl(path)
local method = "POST"
local content = nk.json_encode({
["Image"] = "victordavion/ddags:latest",
["Env"] = {
string.format("AUTHIP=%s", nakamaIp),
string.format("AUTHPORT=%d", nakamaPort),
string.format("EXTPORT=%d", port),
string.format("MATCHID=%s", matchid),
"SERVER=true",
"NOMATCHMAKING=0",
string.format("PASSWORD=%s", password)
},
["HostConfig"] = {
["AutoRemove"] = true,
["NetworkMode"] = "roachnet",
["PortBindings"] = {
["9000/tcp"] = {
{
["HostIp"] = "0.0.0.0",
["HostPort"] = string.format("%d", port)
}
},
["9000/udp"] = {
{
["HostIp"] = "0.0.0.0",
["HostPort"] = string.format("%d", port)
}
}
}
}
})
local headers = {
["Content-Type"] = "application/json",
["Content-Length"] = string.format("%d", string.len(content))
}
local success, code, _, body = pcall(nk.http_request, url, method, headers, content)
if (not success) then
nk.logger_error(string.format("Failed request %q", code))
error(code)
elseif (code >= 400) then
nk.logger_error(string.format("Failed request %q %q", code, body))
error(body)
else
local containerId = nk.json_decode(body)["Id"]
return containerId
end
end
-- Starts a given container id
-- returns The external port that the container was assigned
function M.StartContainer(id)
local path = string.format("containers/%s/start", id)
local url = GetUrl(path)
local method = "POST"
local headers = {
["Content-Type"] = "application/json",
["Content-Length"] = "0"
}
local success, code, _, body = pcall(nk.http_request, url, method, headers, nil)
if (not success) then
nk.logger_error(string.format("Failed request %q", code))
error(code)
elseif (code >= 400) then
nk.logger_error(string.format("Failed request %q %q", code, body))
error(body)
else
return M.GetContainerPort(id)
end
end
-- Gets the external port for clients to connect to after the container has started
-- returns A port
-- TODO : This may be overly complex, as prior to a refactor (from which this was ported from),
-- the ports were dynamically assigned by Docker. Instead, with the current api, the ports are
-- decided upon prior to creating the container.
function M.GetContainerPort(id)
local path = string.format("containers/%s/json", id)
local url = GetUrl(path)
local method = "GET"
local headers = {
["Accept"] = "application/json"
}
local success, code, _, body = pcall(nk.http_request, url, method, headers, nil)
if (not success) then
nk.logger_error(string.format("Failed request %q", code))
error(code)
elseif (code >= 400) then
nk.logger_error(string.format("Failed request %q %q", code, body))
error(body)
else
local container = nk.json_decode(body)
local port = container["NetworkSettings"]["Ports"]["9000/tcp"][1]["HostPort"]
return port
end
end
return M
-- This module merely encapsulates the match creation process for creating an authoritative match.
local nk = require("nakama")
local function create_match(context, payload)
nk.logger_info("RPC called: create_match_rpc")
local modulename = "dda"
local setupstate = { initialstate = payload }
local matchid = nk.match_create(modulename, setupstate)
return nk.json_encode({
["MatchId"] = matchid
})
end
nk.register_rpc(create_match, "create_match_rpc")
local nk = require("nakama")
local containerapi = require("containerapi")
local M = {}
-- No enums in LUA; Values must match Client enum
local OP_HOST = 0
local OP_STARTGAME = 1
local OP_LOBBYMESSAGE = 2
local OP_REGISTER_AS_SERVER = 3
local OP_END_MATCH = 4
-- Helper function to start the server and get the port it'll be listening
-- match_id : Retrieved from context, to be passed into as ENV to Game Server
-- password : Retrieved from state, to be passed into as ENV to Game Server
-- return : The port the server is listening
local function request_gameserver(match_id, password)
local success, port = pcall(containerapi.RequestGameServer, match_id, password)
if (not success) then
error(port)
else
return port
end
end
-- Processes the OP_STARTGAME message
local function start_game(message, context, state)
local response = ""
if state.host == message.sender.user_id and false == state.server_requested then
state.server_requested = true
local success, port = pcall(request_gameserver, context.match_id, state.server_password)
if (not success) then
nk.logger_error(port)
response = "Unable to create a server..."
--dispatcher.broadcast_message(OP_LOBBYMESSAGE, "Unable to create a server...")
state.server_requested = false
else
state.server_port = port
response = "Server requested..."
--dispatcher.broadcast_message(OP_LOBBYMESSAGE, "Server requested...")
end
else
nk.logger_warn("Non-host sent a host-only message")
response = "Invalid request."
end
return response
end
function M.match_init(context, setupstate)
local gamestate = {
presences = {},
host = nil, -- a user_id
server_requested = false, -- Flag to prevent spawning multiple Game Server containers
server = nil, -- a user_id the Game Server uses
server_password = nk.uuid_v4(),
server_port = 0
}
local tickrate = 1 -- per sec
local label = "dda"
nk.logger_info(string.format("Match: %s : Initializing...", context.match_id))
return gamestate, tickrate, label
end
function M.match_join_attempt(context, dispatcher, tick, state, presence, metadata)
local acceptuser = true
if (state.host == nil) then
state.host = presence.user_id
end
return state, acceptuser
end
function M.match_join(context, dispatcher, tick, state, presences)
for _, presence in ipairs(presences) do
state.presences[presence.session_id] = presence
end
return state
end
function M.match_leave(context, dispatcher, tick, state, presences)
for _, presence in ipairs(presences) do
state.presences[presence.session_id] = nil
end
return state
end
function M.match_loop(context, dispatcher, tick, state, messages)
dispatcher.broadcast_message(OP_HOST, state.host)
for _, message in ipairs(messages) do
nk.logger_info(string.format("Received OpCode: %d, Data: %s from %s", message.op_code, message.data, message.sender.username))
if message.op_code == OP_STARTGAME then
local success, response = pcall(start_game, message, context, state)
if (not success) then
nk.logger_error(response)
else
dispatcher.broadcast_message(OP_LOBBYMESSAGE, response)
end
elseif message.op_code == OP_REGISTER_AS_SERVER then
if state.server_requested and state.server_password == message.data then
state.server = message.sender.user_id
dispatcher.broadcast_message(OP_STARTGAME, nk.json_encode({
["Port"] = state.server_port
}))
else
nk.logger_warn("A server registration request resulted in a failure.")
state.server_requested = false
end
elseif message.op_code == OP_END_MATCH then
if message.sender.user_id == state.server then
dispatcher.broadcast_message(OP_END_MATCH, "")
nk.logger_info(string.format("Ending match: %s ; By Game Server request.", context.match_id))
state = nil
else
nk.logger_error("A non-server is attempting to end the match.")
end
end
end
return state
end
function M.match_terminate(context, dispatcher, tick, state, grace_seconds)
local message = "Server shutting down in " .. grace_seconds .. " seconds"
dispatcher.broadcast_message(OP_LOBBYMESSAGE, message)
return nil
end
return M
# This is a very messy prototype Autoload to test Nakama with a Dedicated Server
# This file won't do much of anything on its own, but I'm using it to drive
# a dedicated server for a game already made
extends Node
enum OP {
HOST,
STARTGAME,
LOBBYMESSAGE,
REGISTER_AS_SERVER,
END_MATCH
}
const NAKAMA_KEY := "defaultkey"
const NAKAMA_HOSTNAME := "localhost"
const NAKAMA_PORT := 7350
const NAKAMA_METHOD := "http"
onready var client : NakamaClient = Nakama.create_client(NAKAMA_KEY, NAKAMA_HOSTNAME, NAKAMA_PORT, NAKAMA_METHOD)
onready var socket : NakamaSocket = Nakama.create_socket_from(client)
var session : NakamaSession
var myName : String
var myMatchId : String
var myAccount : NakamaAPI.ApiAccount
var connected_opponents = {}
signal authenticated(error)
signal accountInfo
func _ready():
socket.connect("received_match_presence", self, "_on_match_presence")
socket.connect("connected", self, "_on_socket_connected")
socket.connect("closed", self, "_on_socket_closed")
socket.connect("received_error", self, "_on_socket_error")
var isServer = false
var isMatchmaking = false
# Refer to the containerapi.lua module as to how this ENV appears
if OS.has_environment("SERVER"):
if OS.get_environment("SERVER") == "true":
isServer = true
if OS.has_environment("NOMATCHMAKING"):
if OS.get_environment("NOMATCHMAKING") == "0":
isMatchmaking = true
if isServer:
if isMatchmaking:
_setupMatchmaking()
get_tree().change_scene("res://scenes/server.tscn")
#else:
# var email = "[email protected]"
# var password = "somesupersecretpassword"
#
# var session = yield(client.authenticate_email_async(email, password, null, false), "completed")
# var account = yield(client.get_account_async(session), "completed")
# print("User id: %s" % account.user.id)
# myName = account.user.username
# #print("User username: '%s'" % account.user.username)
# #print("Account virtual wallet: %s" % str(account.wallet))
#
#
# socket = Nakama.create_socket()
# socket.connect("received_match_presence", self, "_on_match_presence")
# var connectionAttempt: NakamaAsyncResult = yield(socket.connect_async(session), "completed")
# if connectionAttempt.is_exception():
# print("An error occured: %s" % connectionAttempt)
# return
#
# var created_match : NakamaAsyncResult = yield(client.rpc_async(session, "create_match_rpc"), "completed")
# if created_match.is_exception():
# print("An error occured: %s" % created_match)
# return
#
# var matchInfo = JSON.parse(created_match.payload).result
# print("New match with id %s.", matchInfo.MatchId)
#
# var match_id = matchInfo.MatchId
# var joined_match = yield(socket.join_match_async(match_id), "completed")
# if joined_match.is_exception():
# print("An error occured: %s" % joined_match)
# return
# for presence in joined_match.presences:
# print("User id %s name %s'." % [presence.user_id, presence.username])
#
# print(matchInfo)
# global.EXTPORT = int(matchInfo.Port)
# global.MATCH_ID = matchInfo.MatchId
#
# get_tree().change_scene("res://scenes/client.tscn")
func _setupMatchmaking():
var email = "[email protected]"
var password = "someultramegasecretpassword"
global.EXTPORT = int(OS.get_environment("EXTPORT"))
global.MATCH_ID = OS.get_environment("MATCHID")
session = yield(client.authenticate_email_async(email, password, null, true), "completed")
var account = yield(client.get_account_async(session), "completed")
var connectionAttempt: NakamaAsyncResult = yield(socket.connect_async(session), "completed")
if connectionAttempt.is_exception():
print("An error occured: %s" % connectionAttempt)
return
var joined_match = yield(socket.join_match_async(global.MATCH_ID), "completed")
if joined_match.is_exception():
print("An error occured: %s" % joined_match)
return
socket.send_match_state_async(global.MATCH_ID, OP.REGISTER_AS_SERVER, OS.get_environment("PASSWORD"))
func _on_match_presence(p_presence : NakamaRTAPI.MatchPresenceEvent):
for p in p_presence.joins:
connected_opponents[p.user_id] = p
for p in p_presence.leaves:
connected_opponents.erase(p.user_id)
print("Connected opponents: %s" % [connected_opponents])
func _on_socket_connected():
pass
func _on_socket_closed():
pass
func _on_socket_error(error):
print(error)
func Authenticate(email, password, create):
session = yield(client.authenticate_email_async(email, password, null, create), "completed")
if session.is_exception():
emit_signal("authenticated", {
Message = session.get_exception().message,
Code = session.get_exception().status_code
})
print(session.to_string())
else:
emit_signal("authenticated", {
Message = "",
Code = 0
})
func GetAccount():
var account = yield(client.get_account_async(session), "completed")
if account.is_exception():
print(account.to_string())
return
myAccount = account
emit_signal("accountInfo")
func CreateMatch():
print("Attempting to create match...")
if not socket.is_connected_to_host():
if not socket.is_connecting_to_host():
var connectionAttempt: NakamaAsyncResult = yield(socket.connect_async(session), "completed")
if connectionAttempt.is_exception():
print("An error occured: %s" % connectionAttempt)
return
else:
print("Still attempting to connect to the server...")
return
else:
var created_match : NakamaAsyncResult = yield(client.rpc_async(session, "create_match_rpc"), "completed")
if created_match.is_exception():
print("An error occured: %s" % created_match)
return
var matchInfo = JSON.parse(created_match.payload).result
var match_id = matchInfo.MatchId
print(match_id)
var joined_match = yield(socket.join_match_async(match_id), "completed")
if joined_match.is_exception():
print("An error occured: %s" % joined_match)
return
myMatchId = match_id
for presence in joined_match.presences:
print("User id %s name %s'." % [presence.user_id, presence.username])
func ConnectSocket():
if not socket.is_connected_to_host():
if not socket.is_connecting_to_host():
var connectionAttempt: NakamaAsyncResult = yield(socket.connect_async(session), "completed")
if connectionAttempt.is_exception():
print("An error occured: %s" % connectionAttempt)
return
else:
print("Still attempting to connect to the server...")
return
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment