Appearance
Appearance
A UDP passthrough is a mechanism that allows custom UDP servers to be embedded within a Metaplay backend, and it supports both local and cloud deployments transparently. Using a UDP server in conjunction with the Metaplay backend is an advanced use case that's not necessary for most games, but it can be useful if you already had a UDP server implemented for your game, and you wish to integrate it to Metaplay.
As an example, we'll integrate a minimal echo UDP server to Metaplay. Here are the steps we'll go through:
First, we'll create a separate C# project called ExampleUdpEchoServer
to define the UDP server:
namespace ExampleUdpEchoServer
{
public class Server
{
readonly int _port;
readonly string _identityString;
UdpClient _socket;
int _numBytesTransferred;
public Server(int port, string identityString)
{
_port = port;
_identityString = identityString;
}
public Task InitializeAsync()
{
_socket = new UdpClient(_port);
return Task.CompletedTask;
}
public async Task ServeAsync(CancellationToken ct)
{
// Reply to every packet
for (;;)
{
UdpReceiveResult msg = await _socket.ReceiveAsync(ct);
string replyStr = System.FormattableString.Invariant($"Reply from {_identityString}. Received {msg.Buffer.Length} bytes.");
byte[] reply = System.Text.Encoding.UTF8.GetBytes(replyStr);
_socket.Send(reply, endPoint: msg.RemoteEndPoint);
_numBytesTransferred += reply.Length;
}
}
public int NumBytesTransferred => _numBytesTransferred;
}
}
The next step is adding the project to the backend solution. Then, for the Server
project, we'll add a Project Reference to the custom project. In our example, this results in the following structure:
For the custom server to integrate into Metaplay's backend lifecycle, it must be encapsulated into an entity. We can fulfill this by creating a host actor that inherits from the SDK class UdpPassthroughHostActorBase
. In our echo sample, this results in the following entity actor:
class UdpHostActor : UdpPassthroughHostActorBase
{
ExampleUdpEchoServer.Server _server;
public UdpHostActor(EntityId entityId) : base(entityId)
{
}
protected override async Task InitializeSocketAsync(int port)
{
_server = new ExampleUdpEchoServer.Server(port, identityString: _entityId.ToString());
await _server.InitializeAsync();
}
protected override async Task ServeAsync(CancellationToken ct)
{
await _server.ServeAsync(ct);
}
}
Pro tip!
There is at most one UdpHostActor
running on each backend server process. This means you can use global state in the custom server implementation.
In Options.local.yaml
, add:
UdpPassthrough:
Enabled: true
LocalServerPort: 1234
The integrated UDP server should now be running in the local singleton server mode. We can verify that the integration works by starting the server and testing locally against 127.0.0.1:1234
or whichever port you configured in the previous step.
To avoid hard-coding ports and to programmatically find the UDP server gateway, we use the UdpPassthroughGateways
helper which works in all deployment types:
UdpPassthroughGateways.Gateway[] gateways = UdpPassthroughGateways.GetPublicGateways();
UdpPassthroughGateways.Gateway chosenGateway = gateways.PickRandom();
TestEchoWith(chosenGateway.FullyQualifiedDomainNameOrAddress, chosenGateway.Port);
With the implementation running locally, it's time to set up the necessary cloud configurations. For the cloud deployment, we need to declare the port range in the game server's Helm values as follows:
# UDP-specific configurations
experimental:
udp:
enabled: true
startPort: 9000
endPort: 9020
targetPort: 1234
targetShard: logic
The startPort
and endPort
range define the maximum range of external ports that can be used for the UDP passthrough. The targetPort
defines the internal UDP port on the server to relay traffic to, and targetShard
defines which game server shard the traffic should be relayed to.
With the above configurations, the cloud deployment of the game server will receive a DNS name of idler-develop-udp.p1.metaplay.io
, which hosts the public UDP ports. In the above case, with the target being the logic
shards, and because there are two of them, the public endpoints for the UDP passthroughs would be idler-develop-udp.p1.metaplay.io:[9000,9001]
, which would be passed through to logic-0:1234
and logic-1:1234
, respectively.
Note that in cloud deployments, it is not required to add a UdpPassthrough
options segment in Options.<env>.yaml
. The values set in Helm will be propagated to the server automatically.
If the server is not running in a singleton mode, i.e. if there are multiple server nodes, you must define the nodes on which the UDP passthrough should run. You can do this by updating the ShardingTopologies
in Options.base.yaml
. To, for example, to run the UDP server on all logic nodes, we would define:
Clustering:
ShardingTopologies:
MyTopology:
# Run it among the logic nodes
logic:
...
- UdpPassthrough
The HostActor
is an entity and it can communicate with other entities in the game with normal MetaMessage
s and EntityAsk
s. To demonstrate, we'll implement a minimal AdminApi controller that allows inspecting the custom server's NumBytesTransferred
, which is the total number bytes sent.
First let's add EntityAsk
messages to inspect the state of UdpEchoServer
:
public static class MessageCodes
{
...
public const int UdpEchoServerStatusRequest = 18104;
public const int UdpEchoServerStatusResponse = 18105;
}
[MetaMessage(MessageCodes.UdpEchoServerStatusRequest, MessageDirection.ServerInternal)]
public class UdpEchoServerStatusRequest : MetaMessage
{
}
[MetaMessage(MessageCodes.UdpEchoServerStatusResponse, MessageDirection.ServerInternal)]
public class UdpEchoServerStatusResponse : MetaMessage
{
public int NumBytesTransferred;
UdpEchoServerStatusResponse() { }
public UdpEchoServerStatusResponse(int numBytesTransferred)
{
NumBytesTransferred = numBytesTransferred;
}
}
class UdpHostActor
{
...
[EntityAskHandler]
UdpEchoServerStatusResponse HandleUdpEchoServerStatusRequest(UdpEchoServerStatusRequest request)
{
return new UdpEchoServerStatusResponse(_server.NumBytesTransferred);
}
}
Then, let's implement an AdminApi Controller:
public class UdpEchoApiController : GameAdminApiController
{
public UdpEchoApiController(ILogger<UdpEchoApiController> logger, IActorRef adminApi) : base(logger, adminApi)
{
}
[HttpGet("udpecho/gateways")]
[RequirePermission(MetaplayPermissions.Anyone)]
public object GetGateways()
{
return UdpPassthroughGateways.GetPublicGateways();
}
[HttpGet("udpecho/status")]
[RequirePermission(MetaplayPermissions.Anyone)]
public async Task<object> GetStatusAsync()
{
int numBytesTransferred = 0;
// Sum data from all servers.
UdpPassthroughGateways.Gateway[] gateways = UdpPassthroughGateways.GetPublicGateways();
foreach (UdpPassthroughGateways.Gateway gateway in gateways)
{
UdpEchoServerStatusResponse response = await EntityAskAsync<UdpEchoServerStatusResponse>(gateway.AssociatedEntityId, new UdpEchoServerStatusRequest());
numBytesTransferred += response.NumBytesTransferred;
}
return new
{
NumBytesTransferred = numBytesTransferred
};
}
}
Danger: Unsafe Example
udpecho/gateways
and udpecho/status
are using [RequirePermission(MetaplayPermissions.Anyone)]
instead of [RequirePermission(GamePermissions.ApiMyPermission)]
and therefore allow anyone with access to the LiveOps Dashboard to access these APIs. Real-world controllers should always require authorization.
Now, accessing api/udpecho/gateways
shows the set of public domain-port combinations of the running UDP Passthrough gateways, and api/udpecho/status
allows us to observe the total number of bytes transferred. Note that in the real world, exporting metrics of transfer amounts should be done with Prometheus.Counter
.
To help debug network and configuration issues, the Metaplay backend contains a UDP debug server. In Options.base.yaml
, enable UseDebugServer
as follows:
UdpPassthrough:
Enabled: true
LocalServerPort: 1234
+ UseDebugServer: true
This replaces the UDP server with a built-in UDP debug server. In our example, the UdpHostActor
would be replaced with the debug implementation. With the debug server in place, we can test connectivity by sending it UDP messages and inspecting responses. Here's an example with a Python script:
import socket
def ask(msg, ip, port):
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.sendto(msg.encode("utf-8"), (ip, port))
s.settimeout(5)
(reply, addr) = s.recvfrom(4096)
print("reply: ", reply.decode("utf-8"))
finally:
s.close()
host = input("host:")
port = int(input("port:"))
ipv4 = socket.gethostbyname(host)
ask("whoami", ipv4, port)
We would get:
host: idler-develop-udp.p1.metaplay.io
port: 9001
reply: You are 88.88.88.1:64607. I am logic-1.idler-develop-udp.p1.metaplay.io, entity UdpPassthrough:0000000002, local port 1234. LB is "idler-develop-udp.p1.metaplay.io".
You can use ask("help", ipv4, port)
for more details.