Connecting Unity3D to an Erlang game server
In this protip, I'll show how you can easily make a TCP connection between Erlang and Unity3D (C#).
This protip assumes that the reader is familiar with basic Erlang concepts such as a supervisor or gen_server as well as the basics of Unity scripting in C#. Also, some code will be left out. I only show the necessary parts.
Basics
The most important thing here to understand is how the TCP protocol is designed and how we want to (ab)use it. TCP is a streaming protocol, which means that if we send N bytes of data, depending on a lot of things, we may receive it in one packet or in multiple packets. This is unfortunate for us, since usually we want to send one message (like move the player to (X,Y)) and not a bunch of bytes. The method we want to use is called framing. This is a really simple thing: first we send how long is the message (usually 1, 2 or 4 bytes long integer), and comes the actual message.
Since the average message is quite short, let’s choose 2 bytes to indicate the length of the message.
Server
Supervisor
Let's start with the Erlang part. The key here is to use Erlang’s built-in framing system.
First create a supervisor. The init/1
method should look something like this:
init([]) ->
case gen_tcp:listen(Port, [{active, false}, binary, {packet, 2}]) of
{ok, Socket} -> {ok, SupervisorFlags, [ChildDefinition]};
{error, Error} -> {error, Error}
end.
I recommend using a simple_one_for_one
type child here. Also make sure that something spawns a few child after the supervisor starts, so there will be processes actually listening to the network port.
The important part here is the {packet, 2}
option:
Packets consist of a header specifying the number of bytes in the packet, followed by that number of bytes. The length of header can be one, two, or four bytes; containing an unsigned integer in big-endian byte order. Each send operation will generate the header, and the header will be stripped off on each receive operation.
(see: inet:setopts/2)
This is exactly what we want. Note the big-endian byte order, we have to deal with it in the C# code later.
TCP server
Secondly comes the TCP server process. This should use the gen_server
behavior.
-record(state, {socket=nil}).
init(Socket) ->
gen_server:cast(self(), accept),
{ok, #state{socket=Socket}}.
handle_cast(accept, S = #state{socket=ListenSocket}) ->
{ok, AcceptSocket} = gen_tcp:accept(ListenSocket),
ok = inet:setopts(AcceptSocket, [{active, once}]),
{noreply, S#state{socket=AcceptSocket};
handle_cast({tcp, Socket, Data}, S = #state{socket=Socket}) ->
Reply = some_module:calculate_reply(Data),
gen_tcp:send(Socket, Reply),
inet:setopts(Socket, {active, once}),
{noreply, S}.
handle_info({tcp, Socket, Data}, State) ->
gen_server:cast(self(), {tcp, Socket, Data}),
{noreply, State};
handle_info({tcp_closed, _Socket}, State) ->
{stop, tcp_closed, State}.
This is it. Let's see what's happening here. In init/1
and handle_info/2
, basically we redirect the messages to handle_cast/2
, to do things the gen_server way.
With inet:setopts/2
we activate the socket to receive a message.
Client
The C# client has two classes. The NetworkController (which is a MonoBehaviour) and Message, which is a simple class.
Take a look on the NetworkController.
public class NetworkController : MonoBehaviour {
void Awake() {
DontDestroyOnLoad(this);
}
// Use this for initialization
void Start() {
startServer();
}
// Update is called once per frame
void Update() {
processMessage();
}
static TcpClient client = null;
static BinaryReader reader = null;
static BinaryWriter writer = null;
static Thread networkThread = null;
private static Queue<Message> messageQueue = new Queue<Message>();
static void addItemToQueue(Message item) {
lock(messageQueue) {
messageQueue.Enqueue(item);
}
}
static Message getItemFromQueue() {
lock(messageQueue) {
if (messageQueue.Count > 0) {
return messageQueue.Dequeue();
} else {
return null;
}
}
}
static void processMessage() {
Message msg = getItemFromQueue();
if (msg != null) {
// do some processing here, like update the player state
}
}
static void startServer() {
if (networkThread == null) {
connect();
networkThread = new Thread(() => {
while (reader != null) {
Message msg = Message.ReadFromStream(reader);
addItemToQueue(msg);
}
lock(networkThread) {
networkThread = null;
}
});
networkThread.Start();
}
}
static void connect() {
if (client == null) {
string server = "localhost";
int port = 12345;
client = new TcpClient(server, port);
Stream stream = client.GetStream();
reader = new BinaryReader(stream);
writer = new BinaryWriter(stream);
}
}
public static void send(Message msg) {
msg.WriteToStream(writer);
writer.Flush();
}
}
This class does several things.
- The
Awake()
method makes sure that this class won't be destroyed. - The
connect()
method starts the TCP connection and sets the reading and writing stream. Since the protocol uses binary data (frame length) it's important to useBinaryReader
andBinaryWriter
here. - The
startServer()
method starts a background thread, which reads messages and puts them to a queue in a thread-safe way (addItemToQueue()
). - On every frame render, the
Update()
method callsprocessMessage()
, which dequeues one message at a time (this is thread-safe too). With this technique it's possible to use actual background threads in Unity. - The public method
send()
sends a message. This will be probably called by an another controller.
And to put all the pieces together, here comes the Message class:
public class Message {
public ushort length { get; set; }
public byte[] content { get; set; }
public static Message ReadFromStream(BinaryReader reader) {
ushort len;
byte[] len_buf;
byte[] buffer;
len_buf = reader.ReadBytes(2);
if (BitConverter.IsLittleEndian) {
Array.Reverse(len_buf);
}
len = BitConverter.ToUInt16(len_buf, 0);
buffer = reader.ReadBytes(len);
return new Message(buffer);
}
public void WriteToStream(BinaryWriter writer) {
byte[] len_bytes = BitConverter.GetBytes(length);
if (BitConverter.IsLittleEndian) {
Array.Reverse(len_bytes);
}
writer.Write(len_bytes);
writer.Write(content);
}
public Message(byte[] data) {
content = data;
}
}
Nothing complicated is going on here. There is a static method which can create an instance by reading from the wire and there's a WriteToStream()
method which writes the contents of the message to the output stream. The only thing to note here is the endian conversion. There might be a better way to do this in C#, but this one was the first one I have found, and it does the job.
I hope you enjoyed this post. Feel free to leave comments.
Written by Tamás Demeter-Haludka
Related protips
3 Responses
hey, where is '''somemodule:calculatereply(Data)''' ? :4
Great tutorial! One thing though: I think you missed "length= (ushort)data.Length;" in the Message constructor. Hope that helps someone!
Just checking, is there a problem using UDP with Unity?