We port a multiplayer game from C ++ to the web with Cheerp, WebRTC and Firebase
Introduction
Our company Leaning Technologies provides solutions for porting traditional desktop applications to the web. Our C ++ Cheerp compiler generates a combination of WebAssembly and JavaScript, which provides both easy browser interaction and high performance.
As an example of its application, we decided to port a multiplayer game for the web and chose Teeworlds for this. Teeworlds is a multi-player, two-dimensional retro game with a small but active community of players (including me!). It is small in terms of both downloadable resources and CPU and GPU requirements - an ideal candidate.
Works in the Teeworlds browser
We decided to use this project to experiment with general solutions for porting network code to the web . This is usually done in the following ways:
XMLHttpRequest / fetch if the network part consists only of HTTP requests, or
WebSockets
Both solutions require hosting the server component on the server side, and none of them allows you to use UDP as the transport protocol. This is important for real-time applications such as video conferencing and gaming software, because delivery guarantees and TCP packet ordering can interfere with low latencies.
There is a third way - use the network from a browser: WebRTC .
RTCDataChannel supports both reliable and unreliable transmission (in the latter case, if possible, it tries to use UDP as the transport protocol), and can be used both with a remote server and between browsers. This means that we can port the entire application to the browser, including the server component!
However, this is an additional difficulty: before the two WebRTC peers can exchange data, they need to perform a relatively complicated handshake procedure for connection, which requires several third-party entities (a signal server and one or more STUN / TURN servers).
Ideally, we would like to create a network API internally using WebRTC, but as close as possible to the UDP Sockets interface, which does not need to establish a connection.
This will allow us to take advantage of WebRTC without the need to disclose complex details to the application code (which we wanted to change as little as possible in our project).
Minimum WebRTC
WebRTC is a set of APIs available in browsers that provides peer-to-peer audio, video, and arbitrary data transfer.
The connection between the peers is established (even if there is NAT on one or both sides) using the STUN and / or TURN servers through a mechanism called ICE. Peers exchange ICE information and channel parameters through the SDP offer and answer protocol.
Wow! How many abbreviations at a time. Let's briefly explain what these concepts mean:
Session Traversal Utilities for NAT ( STUN ) - a protocol for bypassing NAT and receiving a pair (IP, port) for exchanging data directly with the host. If he manages to complete his task, then peers can independently exchange data with each other.
Traversal Using Relays around NAT ( TURN ) is also used to bypass NAT, but it does this by redirecting data through a proxy that is visible to both peers. It adds latency and is more costly to execute than STUN (because it is used throughout the communication session), but sometimes this is the only option.
Interactive Connectivity Establishment ( ICE ) is used to select the best possible way to connect two peers based on information obtained by directly connecting peers, as well as information received by any number of STUN and TURN servers.
Session Description Protocol ( SDP ) is a format for describing the parameters of the connection channel, for example, ICE candidates, multimedia codecs (in the case of an audio / video channel), etc. ... One of the peers sends an SDP Offer ("offer"), and the second responds with SDP Answer ("response"). After that, a channel is created.
To create such a connection, peers need to collect the information they received from the STUN and TURN servers and exchange it with each other.
The problem is that they do not yet have the ability to exchange data directly, so there must be an out-of-band mechanism for exchanging this data: a signal server.
A signal server can be very simple, because its only task is to redirect data between peers at the “handshake” stage (as shown in the diagram below).
WebRTC Simplified Handshake Sequence
Teeworlds Network Model Overview
The network architecture of Teeworlds is very simple:
The client and server components are two different programs.
Clients enter the game by connecting to one of several servers, each of which hosts only one game at a time.
All data transfer in the game is through the server.
A special master server is used to collect a list of all public servers that are displayed in the game client.
Thanks to the use of WebRTC for data exchange, we can transfer the server component of the game to the browser where the client is located. It gives us a great opportunity ...
Get rid of servers
The lack of server logic has a nice advantage: we can deploy the entire application as static content on Github Pages or on our own equipment behind Cloudflare, thus ensuring fast downloads and high uptime for free. In fact, it will be possible to forget about them, and if we are lucky and the game becomes popular, then the infrastructure will not have to be modernized.
However, for the system to work, we still have to use an external architecture:
One or more STUN servers: we have a choice of several free options.
At least one TURN server: there are no free options here, so we can either set up our own or pay for the service. Fortunately, most of the time, the connection can be made through the STUN servers (and provide true p2p), but TURN is needed as a fallback.
Signal server: unlike the other two aspects, signaling is not standardized. What the signal server will actually be responsible for depends on the application in some way. In our case, before establishing a connection, it is necessary to exchange a small amount of data.
Teeworlds master server: it is used by other servers to notify of its existence and clients to search for public servers. Although it is not required (clients can always connect to a server they know manually), it would be nice to have it so that players can participate in games with random people.
We decided to use Google’s free STUN servers, and deployed one TURN server on our own.
Teeworlds master server is implemented very simply: as a list of objects containing information (name, IP, card, mode, ...) of each active server. The servers publish and update their own object, and the clients take the entire list and display it to the player. We also display the list on the home page as HTML so that players can simply click on the server and go straight to the game.
Signaling is closely related to our socket implementation, described in the next section.
List of servers inside the game and on the home page
Socket Implementation
We want to create an API as close as possible to Posix UDP Sockets to minimize the number of changes needed.
We also want to implement the necessary minimum required for the simplest data exchange over the network.
For example, we do not need real routing: all peers are in the same “virtual LAN” associated with a specific instance of the Firebase database.
Therefore, we do not need unique IP addresses: for unique identification of peers, it is sufficient to use unique values of Firebase keys (similar to domain names), and each peer locally assigns “fake” IP addresses to each key that needs to be converted. This completely eliminates the need for a global IP address assignment, which is a non-trivial task.
Here is the minimum API we need to implement:
// Create and destroy a socket int socket(); int close(int fd); // Bind a socket to a port, and publish it on Firebase int bind(int fd, AddrInfo* addr); // Send a packet. This lazily create a WebRTC connection to the // peer when necessary int sendto(int fd, uint8_t* buf, int len, const AddrInfo* addr); // Receive the packets destined to this socket int recvfrom(int fd, uint8_t* buf, int len, AddrInfo* addr); // Be notified when new packets arrived int recvCallback(Callback cb); // Obtain a local ip address for this peer key uint32_t resolve(client::String* key); // Get the peer key for this ip String* reverseResolve(uint32_t addr); // Get the local peer key String* local_key(); // Initialize the library with the given Firebase database and // WebRTc connection options void init(client::FirebaseConfig* fb, client::RTCConfiguration* ice);
The API is simple and similar to the Posix Sockets API, but it has several important differences: registering callbacks, assigning local IPs and a lazy connection .
Callback Registration
Even if the source program uses non-blocking I / O, the code needs to be refactored to run in a web browser.
The reason for this is that the event loop in the browser is hidden from the program (be it JavaScript or WebAssembly).
In a native environment, we can write code this way
while(running) { select(...); // wait for I/O events while(true) { int r = readfrom(...); // try to read if (r < 0 && errno == EWOULDBLOCK) // no more data available break; ... } ... }
If the event loop is hidden for us, then we need to turn it into something like this:
auto cb = []() { // this will be called when new data is available while(true) { int r = readfrom(...); // try to read if (r < 0 && errno == EWOULDBLOCK) // no more data available break; ... } ... }; recvCallback(cb); // register the callback
Local IP Assignment
The identifiers of the nodes in our “network” are not IP addresses, but Firebase keys (these are lines that look like this: -LmEC50PYZLCiCP-vqde
).
This is convenient because we do not need a mechanism to assign IPs and verify their uniqueness (as well as their disposal after disconnecting the client), but it is often necessary to identify peers by a numerical value.
For this, the resolve
and reverseResolve
functions are used: the application somehow gets the string value of the key (through user input or through the master server), and can convert it to an IP address for internal use. The rest of the API also receives this value instead of a string for simplicity.
This is similar to a DNS lookup, only performed locally at the client.
That is, IP addresses cannot be shared between different clients, and if you need some kind of global identifier, you will have to generate it in a different way.
Lazy mix
UDP does not need a connection, but, as we saw, before starting data transfer between two peers, WebRTC requires a long connection process.
If we want to provide the same level of abstraction, ( sendto
/ recvfrom
with arbitrary peers without first connecting), then we must make a “lazy” (delayed) connection inside the API.
Here is what happens during the normal exchange of data between the "server" and the "client" in the case of using UDP, and what our library should do:
The server calls bind()
to tell the operating system that it wants to receive packets to the specified port.
Instead, we will publish the open port in Firebase under the server key and listen to events in its subtree.
The server calls recvfrom()
, accepting packets from any host to this port.
In our case, we need to check the incoming queue of packets sent to this port.
Each port has its own queue, and we add the source and destination ports to the beginning of the WebRTC datagrams to know which queue to redirect when a new packet arrives.
The call is non-blocking, so if there are no packets, we simply return -1 and set errno=EWOULDBLOCK
.
The client receives, by some external means, the IP and port of the server, and calls sendto()
. Also, an internal call to bind()
is performed, so the subsequent recvfrom()
will receive a response without explicitly executing bind.
In our case, the client externally receives the string key and uses the resolve()
function to obtain the IP address.
At this point, we begin the “handshake” of WebRTC if the two peers are not yet connected to each other. Connections to different ports of the same peer use the same DataRannel WebRTC.
We also do indirect bind()
so that the server can reconnect in the next sendto()
in case it sendto()
for some reason.
The server is notified of the client connecting when the client writes its SDP offer under the server port information in Firebase, and the server responds with its own response.
The diagram below shows an example of the movement of messages for a socket scheme and the transmission of the first message from the client to the server:
Complete connection step diagram between client and server
Conclusion
If you have read to the end, then you are probably interested in looking at the theory in action. The game can be played on teeworlds.leaningtech.com , try it!
Friendly match between colleagues
The network library code is freely available on Github . Join chatting on our channel in Gitter !