My main issue is to find out if this design, i.e. webgl - Unity Transport - Netcode for Game Objects, can work together. I understand that I am bound to suffer during the development. I posted my question not because of the different obstacles I have (ex: conditional compilation, CORS, web-socket transport with multiplayer structure, etc’) but because I am not even sure they are my real problem. I.e. I wish to understand if I can solve it. I raise this question as I find little if no information about the flow, better say, I find some post, that were mostly not answered. In unity documentation I also find: " Because the WebGL player is constrained by browser capabilities, it’s currently impossible to start a server in a WebGL player (even with the WebSocketNetworkInterface
)." here. Facing constants obstacles that I thought simply to confirm this design feasibility, i.e. to verify the path I try to follow is exploitable.
To reply to your specific context requst:
Suppose a very simple project, which only connect the client via matchmaker to NGO. This below project works. The unity engine client can connects locally to the unity NGO server. These scripts also allow me to load a webgl script which I load with server.js. But this webgl cannot connect to the NGO server. I have added firewall permission to port 9000.
The client script is attache to a client empty object:
The server empty object holds server.cs and Example_ServerQueryHandler.
The GamePlayer is attahced to the PlayerPrefab.
I have also added Assets/Editor/WebGLBuildPostProcessor.cs which activate the unity transform use websockets option only for webgl builds.
Many thanks for your time and consideration.
using System.Collections.Generic;
using System.Threading.Tasks;
using TMPro;
using Unity.Netcode;
using Unity.Netcode.Transports.UTP;
using Unity.Services.Authentication;
using Unity.Services.Core;
using Unity.Services.Matchmaker;
using Unity.Services.Matchmaker.Models;
using UnityEngine;
public class Client : MonoBehaviour
{
static bool initialized;
async void Start()
{
if (!initialized)
{
await UnityServices.InitializeAsync();
AuthenticationService.Instance.SwitchProfile(UnityEngine.Random.Range(0, 1000000).ToString());
await AuthenticationService.Instance.SignInAnonymouslyAsync();
initialized = true;
}
await StartSearch();
}
async Task StartSearch()
{
var players = new List<Player>
{
new(AuthenticationService.Instance.PlayerId, new Dictionary<string, object>())
};
var attributes = new Dictionary<string, object>();
string queueName = "test";
var options = new CreateTicketOptions(queueName, attributes);
while (!await FindMatch(players, options)) // if we dont find a match, wait a second and try again
await Awaitable.WaitForSecondsAsync(1f);
}
async Task<bool> FindMatch(List<Player> players, CreateTicketOptions options)
{
var transport = NetworkManager.Singleton.GetComponent<UnityTransport>();
var ticketResponse = await MatchmakerService.Instance.CreateTicketAsync(players, options);
while (true)
{
await Awaitable.WaitForSecondsAsync(1f);
Debug.Log("Polling");
var ticketStatusResponse = await MatchmakerService.Instance.GetTicketAsync(ticketResponse.Id);
if (ticketStatusResponse?.Value is MultiplayAssignment assignment)
{
Debug.Log("Response " + assignment.Status);
FindFirstObjectByType<TMP_Text>()?.SetText("Response " + assignment.Status);
switch (assignment.Status)
{
case MultiplayAssignment.StatusOptions.Found:
{
if (assignment.Port.HasValue)
{
transport.SetConnectionData(assignment.Ip, (ushort) assignment.Port);
bool result = NetworkManager.Singleton.StartClient();
// Logging and showing on UI
Debug.Log("StartClient " + result);
FindFirstObjectByType<TMP_Text>().SetText("StartClient " + result);
NetworkManager.Singleton.OnConnectionEvent += LogConnectionEvent;
return result; // if we fail to connect try again w/ a false result
}
Debug.LogError("No port found");
return false;
}
case MultiplayAssignment.StatusOptions.Timeout:
case MultiplayAssignment.StatusOptions.Failed:
{
Debug.LogError(assignment.ToString());
return false;
}
}
}
}
}
void LogConnectionEvent(NetworkManager manager, ConnectionEventData data)
{
switch (data.EventType)
{
case ConnectionEvent.ClientConnected:
FindFirstObjectByType<TMP_Text>().SetText("Client connected " + data.ClientId +
" Count:" +
NetworkManager.Singleton.ConnectedClientsIds.Count + " Port:" +
(manager.NetworkConfig.NetworkTransport as UnityTransport)?.ConnectionData.Port);
break;
case ConnectionEvent.ClientDisconnected:
FindFirstObjectByType<TMP_Text>()
.SetText("Client disconnected " + data.ClientId + " Count:" +
NetworkManager.Singleton.ConnectedClientsIds.Count + " Port:" +
(manager.NetworkConfig.NetworkTransport as UnityTransport)?.ConnectionData.Port);
break;
}
}
}
public class Server : MonoBehaviour
{
string _ticketId;
void Start()
{
DontDestroyOnLoad(gameObject);
#if UNITY_SERVER || UNITY_EDITOR
StartCoroutine(StartServer());
StartCoroutine(ApproveBackfillTicketEverySecond());
#endif
}
#if UNITY_SERVER || UNITY_EDITOR
async Awaitable StartServer()
{
await UnityServices.InitializeAsync();
var server = MultiplayService.Instance.ServerConfig;
var transport = NetworkManager.Singleton.GetComponent<UnityTransport>();
transport.SetConnectionData("0.0.0.0", server.Port);
Debug.Log("Network Transport " + transport.ConnectionData.Address + ":" + transport.ConnectionData.Port);
if (!NetworkManager.Singleton.StartServer())
{
Debug.LogError("Failed to start server");
throw new Exception("Failed to start server");
}
NetworkManager.Singleton.OnClientConnectedCallback += (clientId) => { Debug.Log("Client connected"); };
NetworkManager.Singleton.OnServerStopped += (reason) => { Debug.Log("Server stopped"); };
NetworkManager.Singleton.SceneManager.LoadScene("Level1", LoadSceneMode.Single);
Debug.Log($"Started Server {transport.ConnectionData.Address}:{transport.ConnectionData.Port}");
var callbacks = new MultiplayEventCallbacks();
callbacks.Allocate += OnAllocate;
callbacks.Deallocate += OnDeallocate;
callbacks.Error += OnError;
callbacks.SubscriptionStateChanged += OnSubscriptionStateChanged;
while (MultiplayService.Instance == null)
{
await Awaitable.NextFrameAsync();
}
var events = await MultiplayService.Instance.SubscribeToServerEventsAsync(callbacks);
await CreateBackfillTicket();
}
void OnSubscriptionStateChanged(MultiplayServerSubscriptionState obj)
{
Debug.Log($"Subscription state changed: {obj}");
}
void OnError(MultiplayError obj)
{
Debug.Log($"Error received: {obj}");
}
async void OnDeallocate(MultiplayDeallocation obj)
{
Debug.Log($"Deallocation received: {obj}");
await MultiplayService.Instance.UnreadyServerAsync();
}
async void OnAllocate(MultiplayAllocation allocation)
{
Debug.Log($"Allocation received: {allocation}");
await MultiplayService.Instance.ReadyServerForPlayersAsync();
}
async Task CreateBackfillTicket()
{
MatchmakingResults results =
await MultiplayService.Instance.GetPayloadAllocationFromJsonAs<MatchmakingResults>();
Debug.Log(
$"Environment: {results.EnvironmentId} MatchId: {results.MatchId} MatchProperties: {results.MatchProperties}");
var backfillTicketProperties = new BackfillTicketProperties(results.MatchProperties);
string queueName = "test";
string connectionString = MultiplayService.Instance.ServerConfig.IpAddress + ":" +
MultiplayService.Instance.ServerConfig.Port;
var options = new CreateBackfillTicketOptions(queueName,
connectionString,
new Dictionary<string, object>(),
backfillTicketProperties);
Debug.Log("Requesting backfill ticket");
_ticketId = await MatchmakerService.Instance.CreateBackfillTicketAsync(options);
}
IEnumerator ApproveBackfillTicketEverySecond()
{
for (int i = 4; i >= 0; i--)
{
Debug.Log($"Waiting {i} seconds to start backfill");
yield return new WaitForSeconds(1f);
}
while (true)
{
yield return new WaitForSeconds(1f);
if (String.IsNullOrWhiteSpace(_ticketId))
{
Debug.Log("No backfill ticket to approve");
continue;
}
Debug.Log("Doing backfill approval for _ticketId: " + _ticketId);
yield return MatchmakerService.Instance.ApproveBackfillTicketAsync(_ticketId);
Debug.Log("Approved backfill ticket: " + _ticketId);
}
}
#endif
}
public class Example_ServerQueryHandler : MonoBehaviour
{
const ushort k_DefaultMaxPlayers = 10;
const string k_DefaultServerName = "MyServerExample";
const string k_DefaultGameType = "MyGameType";
const string k_DefaultBuildId = "test2";
const string k_DefaultMap = "MyMap";
#if UNITY_SERVER || UNITY_EDITOR
IServerQueryHandler m_ServerQueryHandler;
async void Start()
{
while (MultiplayService.Instance == null)
{
await Awaitable.NextFrameAsync();
}
m_ServerQueryHandler = await MultiplayService.Instance.StartServerQueryHandlerAsync(
k_DefaultMaxPlayers, k_DefaultServerName, k_DefaultGameType, k_DefaultBuildId, k_DefaultMap);
}
void Update()
{
if (m_ServerQueryHandler != null)
{
if (NetworkManager.Singleton.ConnectedClients.Count != m_ServerQueryHandler.CurrentPlayers)
m_ServerQueryHandler.CurrentPlayers = (ushort)NetworkManager.Singleton.ConnectedClients.Count;
m_ServerQueryHandler.UpdateServerCheck();
}
}
public void ChangeQueryResponseValues(ushort maxPlayers, string serverName, string gameType, string buildId)
{
m_ServerQueryHandler.MaxPlayers = maxPlayers;
m_ServerQueryHandler.ServerName = serverName;
m_ServerQueryHandler.GameType = gameType;
m_ServerQueryHandler.BuildId = buildId;
}
public void PlayerCountChanged(ushort newPlayerCount)
{
m_ServerQueryHandler.CurrentPlayers = newPlayerCount;
}
#endif
}
public class GamePlayer : NetworkBehaviour
{
[ContextMenu("Send Test")]
public void SendTest()
{
Debug.Log("Sending Test");
TestServerRpc();
}
[ServerRpc]
void TestServerRpc()
{
TestClientRpc();
}
[ClientRpc]
void TestClientRpc()
{
Debug.Log("Got Client Rpc");
}
public override void OnNetworkSpawn()
{
base.OnNetworkSpawn();
if (IsLocalPlayer)
{
Debug.Log("I am the local player");
}
}
void Start()
{
if (IsClient && IsLocalPlayer)
{
Debug.Log("Sending server rpc");
TestServerRpc();
}
}
}
public class WebGLBuildPostProcessor : IPreprocessBuildWithReport
{
public int callbackOrder => 0;
public void OnPreprocessBuild(BuildReport report)
{
if (report.summary.platform == BuildTarget.WebGL)
{
SetWebSocketTransport(true);
}
else
{
SetWebSocketTransport(false);
}
}
private void SetWebSocketTransport(bool enable)
{
UnityTransport transport = GameObject.FindFirstObjectByType<UnityTransport>();
if (transport != null)
{
transport.UseWebSockets = enable;
EditorUtility.SetDirty(transport);
}
}
}
the Server.js is:
const express = require('express');
const compression = require('compression');
const path = require('path');
const app = express();
const port = 8080;
app.use(compression());
app.use((req, res, next) => {
res.header('Access-Control-Allow-Origin', '*');
res.header('Access-Control-Allow-Methods', 'GET,POST');
res.header('Access-Control-Allow-Headers', 'Content-Type');
res.header('Content-Security-Policy', "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; connect-src *");
next();
});
// Set Content-Encoding and Content-Type headers for gzipped files
app.get('*.js.gz', (req, res, next) => {
res.set('Content-Encoding', 'gzip');
res.set('Content-Type', 'application/javascript');
console.log('Serving gzipped JS file:', req.url);
next();
});
app.get('*.data.gz', (req, res, next) => {
res.set('Content-Encoding', 'gzip');
res.set('Content-Type', 'application/octet-stream');
console.log('Serving gzipped data file:', req.url);
next();
});
app.get('*.wasm.gz', (req, res, next) => {
res.set('Content-Encoding', 'gzip');
res.set('Content-Type', 'application/wasm');
console.log('Serving gzipped WASM file:', req.url);
next();
});
// Serve the WebGL build
app.use(express.static(path.join(__dirname, '.')));
// Handle favicon.ico requests
app.get('/favicon.ico', (req, res) => res.status(204));
// Serve index.html for all other routes
app.get('*', (req, res) => {
res.sendFile(path.resolve(__dirname, 'index.html'));
});
app.listen(port, () => {
console.log(`WebGL build is served at http://localhost:${port}`);
});