gvderg
Last Updated: May 17, 2017
·
8.986K
· yorirou
6070c18b2f3de0155705b277bbd43f2b

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 use BinaryReader and BinaryWriter 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 calls processMessage(), 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.

Say Thanks
Respond

2 Responses
Add your response

24612
None

hey, where is '''somemodule:calculatereply(Data)''' ? :4

over 1 year ago ·
28936

Great tutorial! One thing though: I think you missed "length= (ushort)data.Length;" in the Message constructor. Hope that helps someone!

about 2 months ago ·