Feasibility of Creating a WebGL Build with Unity Transport 2.1+ and Netcode for GameObjects (NGO)

I have developed a multiplayer card game using Netcode for GameObjects (NGO) and planned to deploy the clients via a WebGL build hosted on a web server. However, I am encountering significant difficulties in achieving this.

I am seeking advice on the feasibility of my current project design, and in case it is feasible some guidance on overcoming the issues I’m facing.

What I Have Done So Far:

  1. Research and Documentation: I have read various forums and Unity documentation that indicate Unity Transport 2.1+ supports WebGL builds.

  2. Conditional Compilation: surround the multiplay sdk code with #if UNITY_SERVER || UNITY_EDITOR Implemented the #if SERVER as Multiplay code simply can’t be exported into Android builds. As discussed i this forum previously

  3. Minimal Project Structure to isolate the problem:

  • Player and Player Manager scripts.
  • Network Manager object with NetworkManager comp.
    -Server Startup
  • MatchmakerClient script - with simply pool rules to ensure functionality.

While I added Transport 2.1 and added Conditional Compilation, I keep encountering errors. Every time I resolve one error, another one appears. The project works locally,. A build NGO test allocation is successful. However, generating a fully functional WebGL build that includes multiplayer functionality has been elusive. I can create a WebGL build without the multiplayer design, but integrating everything has not been successful.
I spend some long nights on this and I wonder if keep banging my head against the wall is the right way to advance…

Precisely my questions are:

  1. Feasibility: Is it feasible to achieve my design goal using Unity Transport 2.1+ and NGO for a WebGL build?
  2. Alternatives: Should I consider setting aside NGO if I aim to stream clients from a web server? What are the risks involved in continuing on this path?
  3. Resources: Are there any tutorials, repositories, or detailed guides that could help me navigate this process effectively?

Any insights, advice, or references to relevant resources would be immensely appreciated. Thank you for your time and assistance.

It would be more helpful if you detailed these errors rather than a high-level overview that you encountered many issues. The latter is not unusual since many devs struggle with the kind of errors caused by execution order, latency, remote synchronization, applying singleplayer concepts to multiplayer, and not properly testing the foundation.

Like, it’s easy enough to start networking. The a game gets build on top. And then, eventually, way too late, the developer thinks of returning to the menu and starting a new session. This fails, big time, possibly because the whole architecture doesn’t support the application lifecycle with all its details.

In this example, a common source of error is calling Shutdown() instantly followed by StartXxx() - not taking into consideration that Shutdown() is not an instant operation. However the issues that follow do not reflect that, it will likely simply not start networking or an expected event isn’t called.

I would advise to make a simple test case. Build a (Linux?) server and WebGL client, both have to have WebSockets enabled, and write just enough code to StartServer and StartClient to connect the client with that one (perhaps locally hosted) server. This ought to work fine with NGO 2.0 or even 1.9.

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}`);
});

This has been proven numerous times. There’s no reason to doubt that WebGL Netcode is working fine.

This is a well-known limitation to at least web developers, and not too surprising from the standpoint of browser developers where security trumps every feature, even if that means the feature will not be available. WebSocket is client-only, it does not allow incoming connections because the browsers don’t allow incoming connections, therefore no web app regardless of the technology can act as a server / service.

You need to make sure that BOTH client and server have WebSocket enabled.
If it still fails it would be good if you posted this specific issue as a new topic with all the details you have.

It’s not quite that simple. Consider making a test without matchmaking, instead connecting directly to the server IP to ensure that part is working.

This is part of what I meant about a stable foundation and testing. You need to take the smallest possible step and confirm that this is working before moving on, otherwise you have two possible sources of error (connection, matchmaking) and the number of these error sources will only keep growing and cause even weirder issues.

Note that ANY service call may throw an exception. Enclosing them in try/catch is mandatory!

It may not be meant as production code but this needs a timeout or a retry counter to exit out of a possible infinite loop.

Order of execution can be VERY important! Always register events before you call StartXxxxx. I know for certain that if you do the same with StartServer and then register OnServerStarted you will not receive the OnServerStarted event because it is invoked from within StartServer.

I’m under the impression that you need to specify the server listen address (third param) as “0.0.0.0” though I’m not sure if the default isn’t exactly that. The manual indicates that you ought to specify it.

Only a client can be a local player. :wink:

Have you confirmed that this is working?

Note that you can simply set the bool in the transport before StartXxxx and use Application.platform == RuntimePlatform.WebGLPlayer; or the preprocessor symbol UNITY_WEBGL

1 Like

Thanks so much for the invested return. I’ve created a new post based on your suggestion regarding my issue with connecting the WebGL build to the NGO server. You can find it here.

For me at least removing the matchmaker layer makes it much more complex. I basically use MatchMaker only to provide the client a dynamic IP and Port. Working directly with the multiplay via the editor bounds me to a fix IP which is define only before I create the webgl build.