-
-
Save ultimateprogramer/500242c623316491c0c447bae9392f12 to your computer and use it in GitHub Desktop.
Initial prototype for attaching dockerized dedicated Game Servers to Nakama matches
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
---------------------------------------------------------------------------- | |
-- * "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 file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
-- 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") |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# 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