This guide will help you create a headless server on Edgegap for a Unity project using FishNet as its networking solution.
The core of this sample is the MatchmakingSystem script. This script communicates with the Edgegap API to find live deployments of the available server for the game. If no available servers are found, it requests the Edgegap API to deploy a new instance of the game’s server for the client based on their location. This script explains the core concepts of communicating with the Edgegap API using the Unity HTTP Requests system and utilizing the data sent by the Edgegap API for making decisions for the game’s matchmaking.
In this sample, the MatchmakingSystem will try to find an ongoing game for our player and if no games are available, it will automatically deploy a new game server.
This project uses a few free assets from the Unity Asset Store. At the end of this document there are links to all these assets. This Project is tested on Unity Version 2021 LTS.
Get the base project
You can find the sample project on our GitHub. Depending on which version of FishNet that you wish to use, download the appropriate version folder from the Github, then open the project in Unity.
The steps shown in this tutorial remain the same regardless of which project version you use. They are simply available to accommodate for the breaking changes between FishNet versions.
Adapt your game to support Dedicated Game Server (DGS) mode
Open the scene located at Assets/SpaceEdge/Scenes/MainMenu. Make sure that Start On Headless is checked on the ServerManager component of the NetworkManager GameObject. This ensures that the network manager auto start server when the build mode is set to Headless.
Now add a port number on the default transport component (Tugboat). Note this port number as later we will have to expose the same port in our Dockerfile and also use the same port while configuring the app on Edgegap.
Check that the DefaultScenes component have Offline and Online scene set up properly, so the SceneManager can auto load the proper scene based on the Network Mode (Server or Client)
In the build settings, switch the build type to Dedicated Server and the target platform to linux.
If you are building the game with “IL2CPP” scripting backend then you would need to install sysroot toolchain from here - Unity IL2CPP Build Support for Linux | Sysroot Base | 2.0.3
Click on the Build button, when prompted, enter the name ServerBuild as the build name. Once the build is complete you will have 2 files (ServerBuild.x86_64 and UnityPlayer.so) and 1 folder (ServerBuild_Data). Move both the files and the folder inside a new folder and name the new folder build.
Docker Build
Create a new folder named DockerBuild and move the build folder inside it. Create a new file inside the DockerBuild and name this file as Dockerfile and make sure the file has no extension, open the file using a text editor that allows changing the Encoding Type and Line Ending of the file (Notepad++, BBEdit, etc" alt="">
. Once the Dockerfile is created, open it using the text editor and paste the below text in it.
Take a note of the line #10 EXPOSE 7770/udp this is the port number and protocol type we are going to configure in the Edgegap console later on. Make sure the file Encoding is set to UTF-8 and the Line Ending is set to Line Feed (LF). Now save the file without any extension (.txt, .rtf, etc)
Now create one more file in the DockerBuild folder and name it boot.sh. Open using the text editor and paste the below text in it.
Again make sure the Line Endings and Encoding is correct (LF and UTF-8 respectively). Save the file with the extension .sh.
Open a command prompt from the DockerBuild folder, then run the following command to build the image.
For ARM CPU (Mac M1, M2, etc.) users, see the dedicated page.
docker build . -t <IMAGE_NAME>:<VERSION_TAG>
Next, push the image to a registry with these commands.
Using Linux
# login, a prompt will ask the password
docker login -u '<REGISTRY_USERNAME>' <REGISTRY_URL>
# add another tag to your image corresponding to the registry
docker image tag <IMAGE_NAME>:<VERSION_TAG> <REGISTRY_URL>/<PROJECT_NAME>/<IMAGE_NAME>:<VERSION_TAG>
#push the image
docker push <REGISTRY_URL>/<PROJECT_NAME>/<IMAGE_NAME>:<VERSION_TAG>
Using cmd
# login, a prompt will ask the password
docker login -u <REGISTRY_USERNAME> <REGISTRY_URL>
# add another tag to your image corresponding to the registry
docker image tag <IMAGE_NAME>:<VERSION_TAG> <REGISTRY_URL>/<PROJECT_NAME>/<IMAGE_NAME>:<VERSION_TAG>
#push the image
docker push <REGISTRY_URL>/<PROJECT_NAME>/<IMAGE_NAME>:<VERSION_TAG>
Using Powershell
# login, a prompt will ask the password
docker login -u '<REGISTRY_USERNAME>' <REGISTRY_URL>
# add another tag to your image corresponding to the registry
docker image tag <IMAGE_NAME>:<VERSION_TAG> <REGISTRY_URL>/<PROJECT_NAME>/<IMAGE_NAME>:<VERSION_TAG>
#push the image
docker push <REGISTRY_URL>/<PROJECT_NAME>/<IMAGE_NAME>:<VERSION_TAG>
Afterwards, you should be able to see your uploaded image on the dashboard if you are using the Edgegap Container Registry. See this doc if you want to use the Edgegap registry. You can also use another private registry.
Deploying to Edgegap
Navigate to the Applications & Games page of the dashboard. Click on the Create New button in the top right hand corner to access the application form. Here are the fields and how to fill them properly :
Application name : Can be any notable name you want to use to easily recognize your application among others.
Image : Can be any specific image you want to use to easily recognize your application among others.
Version name : You may want to use a version name to describe the scope of the version you are deploying. Examples may be “demo”, “production”, “v1”, “v2”.
Container :
Registry : “[URL]”, where [URL] is the value from the credentials you can display on the Container Repository page.
Image repository : “[PROJECT]/[YOUR GAME]”, where [PROJECT] and [YOUR GAME] are the values you used earlier when pushing the docker image.
Tag : “[TAG]”, where [TAG] is the value you used earlier when pushing the docker image.
Tick “Using a private repository”
Private registry username : “[USERNAME]”, where [USERNAME] is the value from your credentials.
Private registry token : “[TOKEN]”, where [TOKEN] is the value from your credentials.
Requirements : Left as is.
For the port number itself, use 7770 (remember this is the port we exposed in our dockerfile using the EXPOSE 7770/udp command) For the port protocol use UDP (The default FishNet transport tugboat uses only UDP so select only UDP and not TCP/UDP) For the port name use UDP_PORT. It’s crucial that you leave the Verification toggle on for our “MatchmakingSystem” to work properly.
Configure The Client Build
Back in the Unity project, select the MatchmakingSystem gameObject and change the following fields:
AppName: Set it to the “Application name” you used while setting up the Edgegap application.
AppVersion: Set it to the “Version name” you used while setting up the Edgegap application.
AuthHeaderValue: Set it to your Edgegap API token that you can get from the dashboard.
Remove the token word from the API token because we are setting this as the Authentication Header schema during the Awake method of the MatchmakingSystem.
You may also update the script itself with the same values, located under Assets/SpaceEdge/Scripts/Systems.
Make sure that you uncheck the Start On Headless option in the NetworkManager gameObject as well.
If you wish to create a client build, open the Build Settings and change the build type to Windows, Mac, Linux, and the build platform to any platform of your choice. Running the game in the editor will work just as well.
To test the whole system, simply run the client; Enter your player name (optional) and click on the start game button. The MatchmakingSystem should now search for any live deployments to connect you, and if no live deployments are found, it will request Edgegap to deploy a new instance of your game’s server and connect you to that instance once it’s ready.
You now have a Fishnet project available to deploy on demand!
If there is a ConnectionFailed error on the client side, it may be because the Start On Headless option was not enabled in the server build, which means the deployment is not in a state to accept connections since the server never properly started. Make sure the option is enabled before trying again by creating a new container image and app version.
It may also be because app version's port name is not the same as in the MatchmakingSystem script. Make sure that both have the same name, in this case "UDP_PORT", before trying again.
Bonus: Seat Sessions Management
With seat-based deployments, it's possible to use Fishnet to create a system that automatically removes hanging Edgegap sessions once a player disconnects from the server, using a custom NetworkBehaviour script attached to a new empty gameObject in the online scene. This script uses both callback and Remote Procedure Call RPC functions.
When the server starts, the script retrieves the list of session IDs linked to its deployment from the Edgegap API and stores it server-side. Afterwards, when a new player connects to the server, a client-side TargetRpc function is initiated that will send the player's IP address back to the server via a ServerRpc function. Since every player will need to call this function, the RequireOwnership attribute of the ServerRpc function is set to false.
With the player's IP, the server checks for a matching IP in each session's data; The server gets the session data using its cached ID with the Edgegap API. If a match is found, the session ID is mapped to that player's NetworkConnection.
Since new sessions can be added after the server starts, the list of session IDs is updated and the new sessions are checked if a match can't be found the first time around.
Finally, once a player disconnects from the server, the server uses that player's NetworkConnection to retrieve their associated session ID, then uses the Edgegap API to delete that session. This frees a socket in the deployment for a new player to join.
public class SessionNetworkBehaviour : NetworkBehaviour
{
private static readonly string edgegapUrl = "https://api.edgegap.com/v1";
private string apiToken;
private string deploymentId;
private Dictionary<NetworkConnection, string> connectionToSessionIdMap = new Dictionary<NetworkConnection, string>();
private Dictionary<NetworkConnection, bool> mappingInProgress = new Dictionary<NetworkConnection, bool>();
private ArrayList currentDeploymentSessions = new();
private ArrayList newSessions = new();
public override void OnStartServer()
{
base.OnStartServer();
deploymentId = System.Environment.GetEnvironmentVariable("ARBITRIUM_REQUEST_ID");
apiToken = System.Environment.GetEnvironmentVariable("API_TOKEN");
if (string.IsNullOrEmpty(apiToken))
{
Debug.LogError("Edgegap API token not found in environment variables.");
}
else
{
Debug.Log("Edgegap API token found: " + apiToken);
}
StartCoroutine(GetNewSessionList());
}
public override void OnSpawnServer(NetworkConnection conn)
{
base.OnSpawnServer(conn);
Debug.Log($"Adding player for connection: {conn.ClientId}");
RpcSendClientIpAddress(conn);
}
[TargetRpc]
public void RpcSendClientIpAddress(NetworkConnection conn)
{
Debug.Log("Sending IP to server");
StartCoroutine(FetchPublicIpAndSendToServer());
}
private IEnumerator FetchPublicIpAndSendToServer()
{
UnityWebRequest request = UnityWebRequest.Get("https://api.ipify.org?format=json");
yield return request.SendWebRequest();
if (request.result == UnityWebRequest.Result.Success)
{
string responseText = request.downloadHandler.text;
string clientIp = JObject.Parse(responseText)["ip"].ToString();
CmdStoreClientIpAddress(clientIp);
}
else
{
Debug.LogError("Failed to fetch public IP address.");
}
}
[ServerRpc(RequireOwnership = false)]
public void CmdStoreClientIpAddress(string clientIp, Channel channel = Channel.Reliable, NetworkConnection conn = null)
{
Debug.Log($"Received Client IP: {clientIp}");
mappingInProgress[conn] = true; // Set mapping in progress to true
Debug.Log($"Stored IP address for connection {conn.ClientId}: {clientIp}");
// Start mapping the session to the connection
StartCoroutine(CheckCachedSessions(conn, clientIp));
}
private IEnumerator GetNewSessionList()
{
newSessions.Clear();
string url = $"{edgegapUrl}/status/{deploymentId}";
UnityWebRequest request = UnityWebRequest.Get(url);
request.SetRequestHeader("Authorization", apiToken);
yield return request.SendWebRequest();
if (request.result == UnityWebRequest.Result.Success)
{
string responseText = request.downloadHandler.text;
Debug.Log("Deployment Status Response: " + responseText);
var json = JObject.Parse(responseText);
var sessions = json["sessions"] as JArray;
foreach (var session in sessions)
{
string sessionId = session["session_id"]?.ToString();
if (!currentDeploymentSessions.Contains(sessionId))
{
newSessions.Add(sessionId);
currentDeploymentSessions.Add(sessionId);
}
}
Debug.Log("New session list initialized");
}
}
private IEnumerator CheckCachedSessions(NetworkConnection conn, string clientAddress)
{
foreach (string sessionId in currentDeploymentSessions)
{
yield return GetSessionAndMap(conn, sessionId, clientAddress);
if (connectionToSessionIdMap.ContainsKey(conn))
{
break;
}
}
if (!connectionToSessionIdMap.ContainsKey(conn))
{
Debug.Log("Could not map player with current cached sessions");
yield return GetNewSessionList();
foreach (string sessionId in newSessions)
{
yield return GetSessionAndMap(conn, sessionId, clientAddress);
if (connectionToSessionIdMap.ContainsKey(conn))
{
break;
}
}
}
}
private IEnumerator GetSessionAndMap(NetworkConnection conn, string sessionId, string clientAddress)
{
Debug.Log($"Fetching session details for session ID: {sessionId}");
string url = $"{edgegapUrl}/session/{sessionId}";
UnityWebRequest request = UnityWebRequest.Get(url);
request.SetRequestHeader("Authorization", apiToken);
yield return request.SendWebRequest();
if (request.result == UnityWebRequest.Result.Success)
{
Debug.Log("Session fetched successfully.");
string responseText = request.downloadHandler.text;
Debug.Log("Session Response: " + responseText);
var session = JObject.Parse(responseText);
var sessionUsers = session["session_users"] as JArray;
if (sessionUsers != null && sessionUsers.Count > 0)
{
Debug.Log($"Found {sessionUsers.Count} users in session.");
foreach (var user in sessionUsers)
{
string playerIp = user["ip"]?.ToString();
Debug.Log($"Player IP: {playerIp}, Connection IP: {clientAddress}");
if (playerIp == clientAddress && !connectionToSessionIdMap.ContainsKey(conn))
{
connectionToSessionIdMap[conn] = sessionId;
Debug.Log($"Mapped session ID {sessionId} to connection {conn.ClientId} with IP {playerIp}");
// Additional confirmation logging
if (connectionToSessionIdMap.ContainsKey(conn))
{
Debug.Log($"Session ID {sessionId} successfully stored for connection {conn.ClientId} with IP {playerIp}");
}
else
{
Debug.LogError($"Failed to store session ID {sessionId} for connection {conn.ClientId} with IP {playerIp}");
}
break;
}
}
}
else
{
Debug.LogError("No users found in session.");
}
}
else
{
Debug.LogError($"Error fetching session: {request.error}");
Debug.LogError($"Response Code: {request.responseCode}");
Debug.LogError($"Response Text: {request.downloadHandler.text}");
}
mappingInProgress[conn] = false; // Set mapping in progress to false once done
}
public override void OnDespawnServer(NetworkConnection conn)
{
base.OnDespawnServer(conn);
Debug.Log($"Server disconnected client: {conn.ClientId}");
StartCoroutine(HandleDisconnect(conn));
}
private IEnumerator HandleDisconnect(NetworkConnection conn)
{
while (mappingInProgress.ContainsKey(conn) && mappingInProgress[conn])
{
Debug.Log($"Waiting for session mapping to complete for connection {conn.ClientId}...");
yield return new WaitForSeconds(0.1f);
}
if (connectionToSessionIdMap.TryGetValue(conn, out string sessionId))
{
Debug.Log($"Deleting session ID: {sessionId}");
StartCoroutine(DeleteSession(sessionId));
connectionToSessionIdMap.Remove(conn);
mappingInProgress.Remove(conn);
currentDeploymentSessions.Remove(sessionId);
}
else
{
Debug.LogWarning("No session ID found for this connection.");
}
}
private IEnumerator DeleteSession(string sessionId)
{
Debug.Log($"Sending request to delete session ID: {sessionId}");
string url = $"{edgegapUrl}/session/{sessionId}";
UnityWebRequest request = UnityWebRequest.Delete(url);
request.SetRequestHeader("Authorization", apiToken);
yield return request.SendWebRequest();
if (request.result == UnityWebRequest.Result.Success)
{
Debug.Log("Session deleted successfully.");
}
else
{
Debug.LogError($"Error deleting session: {request.error}");
Debug.LogError($"Response Code: {request.responseCode}");
Debug.LogError($"Response Text: {request.downloadHandler.text}");
}
}
}
Optional features
This script can be expanded upon with optional, independent features that help manage the sessions in specific cases.
For example, a timeout can be set to remove empty seat sessions after a configurable amount of time following full server initialization. This is a useful feature in the case of a player that quits before their matchmaker ticket gets resolved, which would create an empty session once a match is found. If the player never sends their IP address to be mapped to the session ID before the timeout resolves, then that session gets deleted to free up a socket.
Another feature can be to disconnect inactive players after a prolonged period of time in order to free up sockets. Once connected to the server, the client needs to send some minimalistic heartbeat message to the server every few seconds, or otherwise gets disconnected if too many heartbeats are missed in a row. Both the amount of time between heartbeats and the maximum number of messages that can be missed in a row can be configured, depending on the game's needs. In this sample, a heartbeat gets sent every few seconds if the spaceship is moving around the map or shooting.
public class SessionNetworkBehaviour : NetworkBehaviour
{
...
private bool listInProgress = false;
[Header("Empty Session Timeout")]
[SerializeField] private bool enableEmptyTimeout;
[SerializeField] private float deleteAfterSeconds = 15f;
[Header("Inactive Player Timeout")]
[SerializeField] private bool enableInactiveTimeout;
[SerializeField] private int maxMissedHeartbeats = 3;
[SerializeField] private float secondsBetweenHeartbeats = 3f;
private Dictionary<NetworkConnection, float> connectionToLastHeartbeatMap = new();
private float clientTimeSinceLastHeartbeat = 0;
public override void OnStartServer()
{
base.OnStartServer();
deploymentId = Environment.GetEnvironmentVariable("ARBITRIUM_REQUEST_ID");
apiToken = Environment.GetEnvironmentVariable("API_TOKEN");
if (string.IsNullOrEmpty(apiToken))
{
Debug.LogError("Edgegap API token not found in environment variables.");
}
else
{
Debug.Log("Edgegap API token found: " + apiToken);
}
StartCoroutine(InitiateSessionManage());
}
private IEnumerator InitiateSessionManage()
{
yield return new WaitForSeconds(0.5f);
StartCoroutine(GetNewSessionList());
if (enableEmptyTimeout)
{
StartCoroutine(StartServerInitSessionsTimeout());
}
}
...
[TargetRpc]
public void RpcSendClientIpAddress(NetworkConnection conn)
{
Debug.Log("Sending IP to server");
StartCoroutine(FetchPublicIpAndSendToServer());
clientTimeSinceLastHeartbeat = secondsBetweenHeartbeats;
}
...
private IEnumerator GetNewSessionList()
{
listInProgress = true;
newSessions.Clear();
string url = $"{edgegapUrl}/status/{deploymentId}";
UnityWebRequest request = UnityWebRequest.Get(url);
request.SetRequestHeader("Authorization", apiToken);
yield return request.SendWebRequest();
if (request.result == UnityWebRequest.Result.Success)
{
string responseText = request.downloadHandler.text;
Debug.Log("Deployment Status Response: " + responseText);
var json = JObject.Parse(responseText);
var sessions = json["sessions"] as JArray;
foreach (var session in sessions)
{
string sessionId = session["session_id"]?.ToString();
if (!currentDeploymentSessions.Contains(sessionId))
{
newSessions.Add(sessionId);
currentDeploymentSessions.Add(sessionId);
}
}
Debug.Log("New session list initialized");
}
listInProgress = false;
}
...
private IEnumerator GetSessionAndMap(NetworkConnection conn, string sessionId, string clientAddress)
{
Debug.Log($"Fetching session details for session ID: {sessionId}");
string url = $"{edgegapUrl}/session/{sessionId}";
UnityWebRequest request = UnityWebRequest.Get(url);
request.SetRequestHeader("Authorization", apiToken);
yield return request.SendWebRequest();
if (request.result == UnityWebRequest.Result.Success)
{
Debug.Log("Session fetched successfully.");
string responseText = request.downloadHandler.text;
Debug.Log("Session Response: " + responseText);
var session = JObject.Parse(responseText);
var sessionUsers = session["session_users"] as JArray;
if (sessionUsers != null && sessionUsers.Count > 0)
{
Debug.Log($"Found {sessionUsers.Count} users in session.");
foreach (var user in sessionUsers)
{
string playerIp = user["ip"]?.ToString();
Debug.Log($"Player IP: {playerIp}, Connection IP: {clientAddress}");
if (playerIp == clientAddress && !connectionToSessionIdMap.ContainsKey(conn))
{
connectionToSessionIdMap[conn] = sessionId;
Debug.Log($"Mapped session ID {sessionId} to connection {conn.ClientId} with IP {playerIp}");
if (enableInactiveTimeout)
{
connectionToLastHeartbeatMap[conn] = TimeManager.ServerUptime;
StartCoroutine(CheckClientHeartbeat(conn));
}
// Additional confirmation logging
if (connectionToSessionIdMap.ContainsKey(conn))
{
Debug.Log($"Session ID {sessionId} successfully stored for connection {conn.ClientId} with IP {playerIp}");
}
else
{
Debug.LogError($"Failed to store session ID {sessionId} for connection {conn.ClientId} with IP {playerIp}");
}
break;
}
}
}
else
{
Debug.LogError("No users found in session.");
}
}
else
{
Debug.LogError($"Error fetching session: {request.error}");
Debug.LogError($"Response Code: {request.responseCode}");
Debug.LogError($"Response Text: {request.downloadHandler.text}");
}
mappingInProgress[conn] = false; // Set mapping in progress to false once done
}
...
#region EmptySessionTimeout
private IEnumerator StartServerInitSessionsTimeout()
{
while (listInProgress)
{
Debug.Log($"Waiting to retrieve session list...");
yield return new WaitForSeconds(0.1f);
}
foreach (string sessionId in currentDeploymentSessions)
{
StartCoroutine(WaitForConnectionTimeout(sessionId));
}
}
private IEnumerator WaitForConnectionTimeout(string sessionID)
{
Debug.Log($"Checking for empty timeout on session {sessionID}");
bool delete = false;
DateTime timeout = DateTime.Now.AddSeconds(deleteAfterSeconds);
while (!connectionToSessionIdMap.ContainsValue(sessionID))
{
yield return new WaitForSeconds(0.1f);
if (DateTime.Now >= timeout)
{
delete = true;
break;
}
}
if (delete && !connectionToSessionIdMap.ContainsValue(sessionID))
{
Debug.Log($"No connection initiated by client after {deleteAfterSeconds}s, deleting session {sessionID}");
StartCoroutine(DeleteSession(sessionID));
currentDeploymentSessions.Remove(sessionID);
newSessions.Remove(sessionID);
}
}
#endregion
#region InactivePlayerTimeout
private IEnumerator CheckClientHeartbeat(NetworkConnection conn)
{
int counter = 0;
while (counter < maxMissedHeartbeats && connectionToSessionIdMap.ContainsKey(conn))
{
double lastHeartbeatDiff = TimeManager.ServerUptime - connectionToLastHeartbeatMap[conn];
if (lastHeartbeatDiff >= secondsBetweenHeartbeats)
{
counter += 1;
Debug.Log($"Connection {conn.ClientId} has missed {counter} heartbeat(s) in a row");
}
else
{
counter = 0;
Debug.Log($"Reset heartbeat counter for connection {conn.ClientId}");
}
yield return new WaitForSeconds(secondsBetweenHeartbeats);
}
if (counter >= maxMissedHeartbeats && conn.IsActive)
{
Debug.Log($"Connection {conn.ClientId} inactive for too long, disconnecting");
conn.Disconnect(true);
connectionToLastHeartbeatMap.Remove(conn);
}
}
[Client]
private void Update()
{
if (enableInactiveTimeout)
{
var move = InputPollingSystem.MoveInput;
var rotate = InputPollingSystem.RotateInput;
if (clientTimeSinceLastHeartbeat >= secondsBetweenHeartbeats && (InputPollingSystem.FireInput || move != Vector2.zero || rotate != Vector2.zero))
{
Debug.Log("Sending heartbeat to server");
clientTimeSinceLastHeartbeat = 0;
StoreClientHeartbeatTime();
}
clientTimeSinceLastHeartbeat += Time.deltaTime;
}
}
[ServerRpc(RequireOwnership = false)]
public void StoreClientHeartbeatTime(Channel channel = Channel.Reliable, NetworkConnection conn = null)
{
connectionToLastHeartbeatMap[conn] = TimeManager.ServerUptime;
}
#endregion
}
Free Assets Used
Cartoon FX Remaster Free | VFX Particles | Unity Asset Store
Sci-Fi Sfx | Audio Sound FX | Unity Asset Store
Starfield Skybox | 2D Sky | Unity Asset Store
Star Sparrow Modular Spaceship | 3D Space | Unity Asset Store