Technical demo of MMO Game using Unity3D with dedicated server using Elixir.
Hope that inspire someone trying to use Elixir Server and Unity3D client.
! Disclaimer This is my first time to build networking and elixir. So explanation may be inaccurate or inappropriate.
- Simple authentication using MySQL.
- Replicates component's property.
public sealed class NetworkTransform : NetworkComponent
{
[SerializeField]
private float m_positionThreshold = 0.01f;
[SerializeField]
private float m_rotationThreshold = 0.01f;
[Replicable, JsonConverter(typeof(VectorConverter))]
public Vector3 Position { get => transform.position; set => transform.position = value; }
[Replicable, JsonConverter(typeof(QuaternionConverter))]
public Quaternion Rotation { get => transform.rotation; set => transform.rotation = value; }
- Linear interpolation of network transform.
Assumes that all client is reliable.
This is bad assumption. But since this is simple demo project, I didn't implement functionalities such as checking whether the state is valid.
Scale property of Transform is ignored for simplicity
I don't care about scale and it is rarely concerned.
Adds components dynamically is not supported.
As same as above, it is just for simplicity. To solve this, you need to write custom AddComponent method. And when a component added and it is NetworkComponent then adds the component to NetworkEntity's component map.
Entities having local authority will not receive states from the server.
Needs to add some logic to force update local entities.
- Clone or download repository https://github.com/Astroray073/UElixir.git
- Install Elixir if you not already install it.
- Install MySQL.
First, you need to get dependencies for our mix project.
# Run this command at ~/UElixir/Server
mix deps.get # Get dependencies for mix project.
To connect to SQL database, you need to modify ~UElixir/config/config.ex.
use Mix.Config
config :logger,
backends: [:console],
compile_time_purge_level: :info
config :uelixir, UElixir.Database,
database: "uelixir_database",
username: "root",
password: "password",
hostname: "localhost"
config :uelixir, ecto_repos: [UElixir.Database]
Modify the username and password fields as your database admin account.
mix run --no-halt
- Build unity project.
- Run client.
- Login via information your account information.
Detailed explanation about this project on server side.
- Ranch : TCP acceptor pool.
- Jason : Fast json serializer.
- Ecto : SQL Database wrapper and query generator.
- MyXQL : MySQL driver.
- /config/config.ex : mix config file.
- /lib
- /model : data models.
- user.ex : schema definition for user account.
- /protocol : network modules of UElixir.
- authentication.ex : manages user connection information.
- channel.ex : manages user groups.
- listener.ex : handles TCP packet and message.
- message.ex : represents the message from clients to the server.
- response.ex : represents the response from the server to clients.
- application.ex : application entry to supervise other modules.
- database.ex : Ecto repository to MySQL.
- u_elixir.ex : represents the server.
- /model : data models.
- mix.exs : mix project.
application.ex
UElixir.Application is entry point of this mix project. There are a few options you can adjust.
- port : the port number for your network connection.
- time_step : the internal time to apply authoritative state to your clients in milliseconds.
- channel_count : the number of channels to separate user group. (NOT_IMPLEMENTED)
defmodule UElixir.Application do
use Application
def start(_type, _args) do
import Supervisor.Spec
port = System.get_env("port", "4000") |> String.to_integer()
time_step = System.get_env("time_step", "100") |> String.to_integer()
children = [
{UElixir, [port: port, time_step: time_step, channel_count: 1]},
UElixir.Database,
UElixir.Authentication,
]
opts = [strategy: :one_for_one, name: UElixir.Supervisor]
Supervisor.start_link(children, opts)
end
def stop(_) do
:ok
end
end
Supervisor runs children : UElixir, Database, Authentication modules.
u_elixir.ex
After UElixir.Application starts, UElixir module will be started. UElixir module runs channels and its listeners for each socket.
def init(port: port, time_step: time_step, channel_count: channel_count) do
channel_list = Enum.reduce(1..channel_count, %{}, fn channel_index, acc ->
{:ok, pid} = Channel.start_link(%{time_step: time_step, entity_states: %{}, user_list: %{}})
Map.put_new(acc, channel_index, pid)
end)
:ranch.start_listener(make_ref(), :ranch_tcp, [port: port], UElixir.Listener, [])
start_tick(time_step)
{:ok, %{tick: 0, time_step: time_step, channel_list: channel_list}}
end
listener.ex
This is the key part of TCP communication.
defmodule UElixir.Listener do
require Logger
use GenServer
@behaviour :ranch_protocol
alias UElixir.{Message, Response, Authentication, Channel}
# Client API
def start_link(ref, _socket, transport, opts) do
pid = :proc_lib.spawn_link(__MODULE__, :init, [{ref, transport, opts}])
{:ok, pid}
end
# Server API
def init({ref, transport, _}) do
Logger.info("Starts protocol")
{:ok, socket} = :ranch.handshake(ref)
:ok = transport.setopts(socket, active: true, nodelay: true)
:gen_server.enter_loop(__MODULE__, [], %{
ref: ref,
socket: socket,
transport: transport,
# Channel index where the user is, TODO: Dynamically assign the value
channel_index: 1
})
end
If you uses GenServer, you cannot call GenServer.start_link/1
to initialize.
Because that call never return until init/1
returns.
The detailed explanation available here.
Handling Message
All messages is transported as json string.
# message receive callback
def handle_info({:tcp, _socket, data}, state) do
String.split(data, "\n", trim: true)
|> Enum.each(fn line -> dispatch_message(line, state) end)
{:noreply, state}
end
UElixir protocol assumes all messages ends with line-ending character '\n'
.
Splits line by line to send it over to dispatch_message/2
.
# handle all messages
@spec dispatch_message(String.t(), any()) :: :ok | {:error, any()}
defp dispatch_message(data, state) do
case Message.parse(data) do
{:ok, message} -> handle_message(message.request, message.id, message.arg, state)
{:error, reason} -> on_error(reason)
end
end
dispatch_message/2
call handle_message/4
callbacks to handle actual message.
Here is simple echo callback of handle_message/4
.
# echo callback
@spec handle_message(atom(), integer, String.t(), any()) :: :ok | {:error, any()}
defp handle_message(
:echo,
_from_user_id,
argument_string,
%{socket: socket, transport: transport}
) do
send_message(socket, transport, Response.new(:echo, :ok, argument_string))
end
All handle_message/4 has the same spec as above.
- argument 1 (Atom) : Represents the request from the client.
- argument 2 (Integer) : Tells what client request.
- argument 3 (String) : Varies by requests. In this echo request, this is the same string as client sent.
- argument 4 (Any) : Listener's state.
- ref : unique reference for this listener's parent.
- socket : listen socket.
- transport : As we use TCP, so this is the same as
:ranch_tcp
. - channel_index : What channel which the user is.
channel.ex
Channel represents the user group to communicate each other.
Given time step, It broadcasts entity states to all users in this group.
# broadcast entity states
def handle_info(
:broadcast_entity_states,
state = %{time_step: time_step, entity_states: entity_states, user_list: user_list}
) do
argument_string =
Map.values(entity_states)
|> Enum.join("\n")
message = Response.new(:replicate_entity_states, :ok, argument_string)
Enum.each(user_list, fn {socket, _user_id} ->
Listener.send_message(socket, :ranch_tcp, message)
end)
schedule_broadcast(self(), time_step)
{:noreply, state}
end
defp schedule_broadcast(pid, time_step) do
Process.send_after(pid, :broadcast_entity_states, time_step)
end
Detailed explanation about this project on client side.
- JSON .NET For Unity : Json .Net porting for Unity3D. Needed for Dictionary serialization.
- /UElixir
- /Attributes
- ReplicableAttribute.cs : Marks property as replicable.
- /Components
- NetworkComponent.cs : Base class for all network components.
- NetworkEntity.cs : Marks this game object as network entity.
- NetworkManager.cs : Manages network resources.
- NetworkTransform.cs : Replicable transform.
- /Protocol
- Authentication.cs : Helper to deal with authentication.
- Message.cs : Represents the message from clients to server.
- Response.cs : Represents the message from server to clients.
- /Serialization
- JsonSerializer.cs : Wrapper of Json .Net
- QuaternionConverter.cs : Custom json converter for UnityEngine.Quaternion.
- AssemblyInfo.cs : Assembly information.
- /Attributes
NetworkEntity
NetworkEntity represents unique entity on the network.
- Has Local Authority : Indicates whether this entity has local authority.
- Network Id : Unique id get from the server.
internal NetworkEntityState GetState()
{
var entityState = new NetworkEntityState
{
EntityId = NetworkId.ToString(),
ComponentStates = new List<NetworkComponentState>()
};
foreach (var networkComponent in m_networkComponents.Values)
{
entityState.ComponentStates.Add(networkComponent.GetState());
}
return entityState;
}
internal void SetState(NetworkEntityState entityState, int timeStamp)
{
Assert.AreEqual(NetworkId, new Guid(entityState.EntityId));
if (HasLocalAuthority)
{
return;
}
foreach (var componentState in entityState.ComponentStates)
{
if (m_networkComponents.TryGetValue(componentState.Name, out var networkComponent))
{
networkComponent.SetState(componentState, timeStamp);
}
else
{
Debug.LogError($"{componentState.Name} doesn't exist.");
}
}
}
NetworkEntity.GetState
collects all NetworkComponent
states.
NetworkEntity.SetState
applies the state from the server.
NetworkComponent
This represents the entity state of a single component.
I will explain it with concrete implementation : NetworkTransform
public sealed class NetworkTransform : NetworkComponent
{
[SerializeField]
private float m_positionThreshold = 0.01f;
[SerializeField]
private float m_rotationThreshold = 0.01f;
[Replicable, JsonConverter(typeof(VectorConverter))]
public Vector3 Position { get => transform.position; set => transform.position = value; }
[Replicable, JsonConverter(typeof(QuaternionConverter))]
public Quaternion Rotation { get => transform.rotation; set => transform.rotation = value; }
The thresholds prevents high frequency packet sending to reduce the server's burden.
Marking Position and Rotation property as Replicable tells NetworkComponent that these property are important and needs to be sent to server when it should be. NetworkComponent
identifies these properties by using Reflection
.
And another important thing to consider when updating transform is the interpolation.
On update new entity state, we need to know the time of the entity state. So NetworkEntity.SetState
has timeStamp argument. The timeStamp argument is an integer value of the tick count of the server when send this state.
defmodule UElixir.Response do
@derive Jason.Encoder
defstruct [:request, :result, :args, :time_stamp]
@type t :: %__MODULE__{request: atom(), result: atom(), args: String.t(), time_stamp: integer}
@spec new(atom(), atom(), String.t()) :: __MODULE__.t()
def new(request, result, args) do
%__MODULE__{request: request, result: result, args: args, time_stamp: UElixir.current_tick()}
end
end
When creates new response, the time_stamp is automatically filled with current server's tick count.
Now we can save state by time stamp to do interpolation to show smooth illusion to our client.
private IEnumerator UpdateTransform()
{
while (true)
{
yield return new WaitForFixedUpdate();
if (Entity.HasLocalAuthority
|| m_nextTimeStamp <= m_prevTimeStamp)
{
continue;
}
var duration = (m_nextTimeStamp - m_prevTimeStamp) * NetworkManager.Instance.TimeStep;
if (m_timer > duration)
{
Position = m_nextPosition;
Rotation = m_nextRotation;
continue;
}
var t = m_timer / duration;
Position = Vector3.Lerp(m_prePosition, m_nextPosition, t);
Rotation = Quaternion.Lerp(m_prevRotation, m_nextRotation, t);
m_timer += Time.fixedDeltaTime;
}
}
The detailed explanation about entity interpolation is here.
Useful resources I found when I was working on this project.