Init 1
Dogfight! - Elixir server
posted on 2025 Jan 01Starting with the server side of the game (code can be found in the GitHub repository), Elixir is pretty neat and allows for various ways to implement a distributed TCP server. Going for the most simple and straight forward approach, leaving refinements and polish for later. After more than 3 years of using Elixir full time, I believe it’s really one of the cleanest backend languages around, extremely productive and convenient (pattern matching in Elixir slaps), there are two points that make it stand-out and interesting to try for this little project:
-
Concurrency and Scalability: Built on the Erlang VM (BEAM), which is known for its ability to handle a large number of concurrent processes with low latency, a real outstanding piece of engineering created by the major telecom experts on earth. This makes Elixir highly suitable for applications that require high concurrency and scalability, such as real-time systems and web applications.
-
Fault Tolerance: Elixir inherits Erlang’s “let it crash” philosophy, which encourages developers to write systems that can recover from failures automatically, this results in highly resilient applications that can continue to operate even when parts of the system fail. Each process operates in isolation and supervision trees allow for automatically recovery; potentially, it’s possible to ship code really fast without the need of being too defensive and tune the system based on the evolution of the use-cases.
The main idea for the application is to write a highly concurrent and distributed TCP based system to handle players, for the first iteration:
- A TCP entry point that acts as an acceptor process, its only purpose will be to accept new connecting clients and spawn process inside the cluster to handle the lifetime of the connection (i.e. the game)
GenServer
is the main abstraction (kinda low level but easy to use for a first run) that will be used to represent- A game server, or better, an event handler, this will be the entity representing the main source of truth for connected clients, it’s basically an instantiated game. For the first iteration we will have a single running game server; subsequently, it will extended to be spawned on-demand as part of a hosting game logic from players requesting it
- Each connecting player, once the acceptor complete the handshake, it will demand the managing of the client to its own process
- The main structures exchanged between server and clients will be
- A game state, will contain all the players, initially capped to a maximum to keep things simple, and each player will have a fixed amount of projectiles to shoot
- Events, which may be directional in nature, such as
MOVE UP
MOVE DOWN
MOVE LEFT
MOVE RIGHT
SHOOT
(this will follow the direction of the projectile, which in turns will coincide with the player direction)SPAWN POWER UP
And so on
- Each player process and game process will be spawned inside a cluster on multiple nodes, with location transparency and message passing this will be a breeze
A very basic TCP server acceptor
We’re gonna use :gen_tcp
from the builtin Erlang
library, starting the
listen loop in a dedicated Task
is enough for the time being, management of
newly connected clients will be demanded to their own proper GenServer
.
Let’s start with something really bare-bone, basically a slight variation of the official docs example is all we need to get started
defmodule Dogfight.Server do
@moduledoc """
Main entry point of the server, accepts connecting clients and defer their
ownership to the game server. Each connected client is handled by a
`GenServer`, the `Dogfight.Player`. The `Dogfight.Game.EventHandler` governs
the main logic of an instantiated game and broadcasts updates to each
registered player.
"""
require Logger
alias Dogfight.Game.Event, as: GameEvent
alias Dogfight.ClusterServiceSupervisor
def listen(port) do
# The options below mean:
#
# 1. `:binary` - receives data as binaries (instead of lists)
# 2. `packet: :raw` - receives data as it comes (stream of binary over the wire)
# 3. `active: true` - non blocking on `:gen_tcp.recv/2`, doesn't wait for data to be available
# 4. `reuseaddr: true` - allows us to reuse the address if the listener crashes
#
{:ok, socket} =
:gen_tcp.listen(port, [:binary, packet: :raw, active: true, reuseaddr: true])
Logger.info("Accepting connections on port #{port}")
accept_loop(socket)
end
defp accept_loop(socket) do
with {:ok, client_socket} <- :gen_tcp.accept(socket),
player_id <- UUID.uuid4(),
player_spec <- player_spec(client_socket, player_id),
{:ok, pid} <- Horde.DynamicSupervisor.start_child(ClusterServiceSupervisor, player_spec),
:ok <- :gen_tcp.send(client_socket, player_id) do
Dogfight.Game.EventHandler.apply_event(pid, GameEvent.player_connection(player_id))
:gen_tcp.controlling_process(client_socket, pid)
else
error -> Logger.error("Failed to accept connection, reason: #{inspect(error)}")
end
accept_loop(socket)
end
defp player_spec(socket, player_id) do
%{
id: Dogfight.Player,
start: {Dogfight.Player, :start_link, [player_id, socket]},
type: :worker,
restart: :transient
}
end
end
The main interesting points are
accept_loop/1
is a recursive call, once a new client connect it dispatches it to a process and call itself again- if the accept call succeed, the first thing we do is to generate an UUID. This part is currently very brittle, no protocol defined to establish an handshake yet, but later on what we expect to do is a sort of authentication, with a token session state to handle reconnections and security concerns, not a priority at the moment.
- After the ID generation, we’re gonna use
Horde
dynamic supervisor to spawn processes that are automatically cluster-aware, the node where they will be spawned is completely abstracted away and doesn’t matter. (For a simpler solution we could even manually start theGenSever
with a call tostart_link/1
, it’s really that simple). - The
Dogfight.Game.EventHandler.register_player/2
call basically add thepid
of the newly instantiated process to the existing game server (as explained above, there will be a single global game server for the first version). This assumes that the game server is already running here, started at application level already. - Finally, we transfer the ownership of the connected socket to the newly spawned process through
:gen_tcp.controlling_process/2
The player process
After the accept has been successfully performed, the player_spec/2
call
provides all that’s required for the Horde.DynamicSupervisor
to spawn a
Dogfight.Player
process somewhere in the cluster. Conceptually it’s nothing
more than a connection state, and thus it’s pretty trivial. The registration of
each PID to the game server can be a little cumbersome later on, and it may be
a good opportunity to explore some pub-sub based solution such as the one shipped
by Phoenix; for the time
being it’s ok to keep things simple.
defmodule Dogfight.Player do
@moduledoc """
This module represents a player in the Dogfight game. It handles the player's
connection, actions, and communication with the game server.
"""
require Logger
use GenServer
alias Dogfight.Game.Codecs.BinaryCodec
alias Dogfight.Game.Event, as: GameEvent
def start_link(player_id, socket) do
GenServer.start_link(__MODULE__, {player_id, socket})
end
def init({player_id, socket}) do
Logger.info("Player #{player_id} connected, registering to game server")
{:ok, %{player_id: player_id, socket: socket}}
end
def handle_info({:tcp, _socket, data}, state) do
with {:ok, event} <- BinaryCodec.decode_event(data) do
Dogfight.Game.EventHandler.apply_event(self(), event)
else
{:ok, :codec_error} -> Logger.error("Decode failed, unknown event")
end
{:noreply, state}
end
def handle_info({:tcp_closed, _socket}, state) do
Logger.info("Player #{state.player_id} disconnected")
Dogfight.Game.EventHandler.apply_event(
self(),
GameEvent.player_disconnection(state.player_id)
)
{:noreply, state}
end
def handle_info({:tcp_error, _socket, reason}, state) do
Logger.error("Player transmission error #{inspect(reason)}")
{:noreply, state}
end
def handle_info({:update, game_state}, state) do
send_game_update(state.socket, game_state)
{:noreply, state}
end
defp send_game_update(socket, game_state) do
:gen_tcp.send(socket, BinaryCodec.encode(game_state))
end
end
Most of the handlers are the typical ones for a bare bone TCP connection, the
only real addition is the send_game_update/2
call which forward a message to
the connected client, in this case the binary encoded game state as the
payload.
The main TCP handler, is responsible for receiving packets from the client, in this first iteration, it’s main responsibility will be to just decode the payload into an event to be applied to the game state; currently there is no definition of this component, it will be the main topic of the next section.
A little event handling system
As explained above, the game handling will be designed as a sort of event queue, where each event will influence the state of the game; naturally, the first brick required is the definition of what an event is, in this game, events could include things like
- player connections and/or drop
- player movements
- shooting
- power-up generation and placement
- power-up collection
First thing, we’re gonna define a very simple set of events that we can extend later
defmodule Dogfight.Game.Event do
@moduledoc """
This structure represents any game event to be applied to a game state to
transition to a new state
"""
alias Dogfight.Game.State
@type t ::
{:player_connection, State.player_id()}
| {:player_disconnection, State.player_id()}
| {:move, {State.player_id(), State.direction()}}
| {:shoot, State.player_id()}
| {:pickup_power_up, State.player_id()}
| {:spawn_power_up, State.power_up_kind()}
| {:start_game}
| {:end_game}
def player_connection(player_id), do: {:player_connection, player_id}
def player_disconnection(player_id), do: {:player_disconnection, player_id}
def move(player_id, direction), do: {:move, {player_id, direction}}
def shoot(player_id), do: {:shoot, player_id}
end
The game event handler
Now that we have a small set of events, we want to process them. The event handler is the main process that govern the state of a game, it is in essence a match which can be hosted by one of the player to invite others to join. At the moment this logic is yet to be implemented and it’s essentially skipped, there is a single game event handler started as part of the application startup.
At the core, it’s basically an event queue, events received by connected clients as
well as time based generated for the natural progression of the game are handled here,
applying them to the associated Game.State
will provide new updated versions of the
game.
The main responsibilities of the GenServer
is to provide a single source of truth to
all the connected clients:
- Each connecting player, after having received an identifier, are registered to the
game server (this happens in the acceptor TCP server via the
register_player/2
call) - Once a player is registered, a ship is spawned in a random position for them via the
GameState.add_player/2
for the given ID. - Broadcast periodic updates of the game state to all connected clients to keep updating
the GUI of each of them (currently set at 50 ms by
@tick_rate_ms
, but it’s not really important now)
defmodule Dogfight.Game.EventHandler do
@moduledoc """
Represents a game server for the Dogfight game. It handles the game state and
player actions. It is intended as the main source of thruth for each instantiated game,
broadcasting the game state to each connected player every `@tick` milliseconds.
"""
require Logger
use GenServer
alias Dogfight.Game.State, as: GameState
@tick_rate_ms 50
def start_link(_) do
GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
end
@impl true
def init(_) do
game_state = GameState.new()
schedule_tick()
{:ok, %{players: %{}, game_state: game_state}}
end
def register_player(pid, player_id) do
GenServer.call(__MODULE__, {:register_player, pid, player_id})
GenServer.cast(__MODULE__, {:add_player, player_id})
end
def apply_event(pid, event) do
case event do
{:player_connection, player_id} ->
GenServer.call(__MODULE__, {:register_player, pid, player_id})
GenServer.cast(__MODULE__, {:add_player, player_id})
{:player_disconnection, player_id} ->
GenServer.call(__MODULE__, {:unregister_player, player_id})
GenServer.cast(__MODULE__, {:drop_player, player_id})
event ->
GenServer.cast(__MODULE__, {:apply_event, pid, event})
end
end
defp schedule_tick() do
Process.send_after(self(), :tick, @tick_rate_ms)
end
@impl true
def handle_info(:tick, state) do
new_game_state = GameState.update(state.game_state)
updated_state = %{state | game_state: new_game_state}
broadcast_game_state(updated_state)
schedule_tick()
{:noreply, updated_state}
end
@impl true
def handle_call({:register_player, pid, player_id}, from, state) do
new_state =
Map.update!(state, :players, fn players ->
Map.put(players, player_id, pid)
end)
{:reply, from, new_state}
end
@impl true
def handle_call({:unregister_player, player_id}, from, state) do
new_state =
Map.update!(state, :players, fn players ->
Map.delete(players, player_id)
end)
{:reply, from, new_state}
end
@impl true
def handle_cast({:add_player, player_id}, state) do
game_state =
case GameState.add_player(state.game_state, player_id) do
{:ok, game_state} ->
broadcast_game_state(%{state | game_state: game_state})
game_state
{:error, error} ->
Logger.error("Failed spawining spaceship, reason: #{inspect(error)}")
state.game_state
end
{:noreply, %{state | game_state: game_state}}
end
@impl true
def handle_cast({:drop_player, player_id}, state) do
{:noreply, %{state | game_state: GameState.drop_player(state.game_state, player_id)}}
end
@impl true
def handle_cast({:apply_event, _pid, event}, state) do
updated_state =
with {:ok, game_state} <- GameState.apply_event(state.game_state, event) do
updated_state = %{state | game_state: game_state}
broadcast_game_state(updated_state)
updated_state
else
e ->
Logger.error("Failed to apply event, reason: #{inspect(e)}")
state
end
{:noreply, updated_state}
end
defp broadcast_game_state(state) do
Enum.each(Map.values(state.players), fn pid ->
send(pid, {:update, state.game_state})
end)
end
end
The init/1
call is pretty cheap, no need to add an handle_continue/2
as it
won’t really block. To be noted that the registration of a new player, performs
a call and a cast. That because we want to make sure the state is updated
before spawning the ship for the new player. Also there is no check yet for
already existing ships, disconnections and reconnection etc. For all the
unexpected cases we just log error or crash, it will be for a second pass to
actually clean up and harden the code. It’s pretty simple and straight-forward.
The game state
The game state represents the main entities present in a game, it’s the main payload of information exchanged between clients and server to keep all the players in sync.
It is indeed pretty simple and definitely not optimized at the moment, there’s a number of things that can be improved and done differently, some of the fields are a carry over from the original C implementation which I’m gonna use as the main test-bed, but the aim is to then update the state for a better v2 representation. For the sake of simplicity, the first version will carry the following entities:
- A set of players active in the game, for simplicity it will be a simple map
player_id -> spaceship
- Each player’s spaceship is represented by
- position: a vector 2D x and y representing the position in the screen
- hp: just health points, initially set at 5
- direction: self-explanatory, this can be (not exactly precise but work for a prototype)
:idle | :up | :down | :left | :right
- alive?: an alive boolean flag
- bullets: set to a maximum of 5, composed by
- position: a vector 2D x and y representing the position in the screen
- direction: again, a direction, this will be aligned with the player direction
- active?: an active boolean flag
- Each player’s spaceship is represented by
- power_ups: these will be spawned randomly on a time-basis, made of
- position: a vector 2D x and y representing the position in the screen
- kind: an atom representing the type of effect it will have on the player that
captures it, initially can be
:hp_plus_one | :hp_plus_three | :ammo_plus_one | nil
At the moment we’re not gonna handle scaling and screen size for each player, it is initially assumed that each player will play in a small 800x600 window.
The main entity, spaceship
Before we create the Game.State
module, it may be a good idea to define a
simple behaviour for the spaceship: generally, I tend to be against
abstractions and go for the most concrete implementation first, but I have the
feeling the spaceship will be one of the things that will be easily generated
with different characteristics. It’s a small interface anyway, in Elixir the
coupling is not as prohibitive as other languages anyway.
defmodule Dogfight.Game.Spaceship do
@moduledoc """
Spaceship behaviour
"""
alias Dogfight.Game.State
@type t :: any()
@callback move(t(), State.direction()) :: t()
@callback shoot(t()) :: t()
@callback update_bullets(t()) :: t()
end
The main traits we’re interested in any given spaceship are
- The move logic, think about slow heavily-armed spaceship or quick smaller fighters etc.
- The shoot logic, again, heavy hitters, barrage rockets or multi-directional machine guns.
- The update bullets logic, fast bullets, lasers, or slow big boys etc.
A first simple implementation, something like a no-frills no-fancy default
spaceship, the DefaultSpaceship
follows
defmodule Dogfight.Game.DefaultSpaceship do
@moduledoc """
DefaultSpaceship module, represents the main entity of each player, a default
spaceship without any particular trait, with a base speed of 3 units and base HP
of 5.
"""
defmodule Bullet do
@moduledoc """
Bullet inner module, represents a bullet of the spaceship entity
"""
@type t :: %__MODULE__{
position: Vec2.t(),
direction: State.direction(),
active?: boolean(),
boundaries: %{width: non_neg_integer(), height: non_neg_integer()}
}
defstruct [:position, :direction, :active?, :boundaries]
alias Dogfight.Game.State
alias Dogfight.Game.Vec2
@bullet_base_speed 6
def new(width, height) do
%__MODULE__{
position: %Vec2{x: 0, y: 0},
direction: State.idle(),
active?: false,
boundaries: %{width: width, height: height}
}
end
def update(%{active?: false} = bullet), do: bullet
def update(bullet) do
position = bullet.position
bullet =
case bullet.direction do
:up ->
%{bullet | position: Vec2.add_y(position, -@bullet_base_speed)}
:down ->
%{bullet | position: Vec2.add_y(position, @bullet_base_speed)}
:left ->
%{bullet | position: Vec2.add_x(position, -@bullet_base_speed)}
:right ->
%{bullet | position: Vec2.add_x(position, @bullet_base_speed)}
_ ->
bullet
end
boundaries_check(bullet)
end
defp boundaries_check(%{position: %{x: x}, boundaries: %{width: width}} = bullet)
when x < 0 or x >= width,
do: %{bullet | active?: false}
defp boundaries_check(%{position: %{y: y}, boundaries: %{height: height}} = bullet)
when y < 0 or y >= height,
do: %{bullet | active?: false}
defp boundaries_check(bullet), do: bullet
end
@behaviour Dogfight.Game.Spaceship
alias Dogfight.Game.State
alias Dogfight.Game.Bullet
alias Dogfight.Game.Vec2
@type t :: %__MODULE__{
position: Vec2.t(),
hp: non_neg_integer(),
direction: State.direction(),
alive?: boolean(),
bullets: [Bullet.t(), ...]
}
defstruct [:position, :hp, :direction, :alive?, :bullets]
@base_hp 5
@base_bullet_count 5
@base_spaceship_speed 3
def spawn(width, height) do
%__MODULE__{
position: Vec2.random(width, height),
direction: State.idle(),
hp: @base_hp,
alive?: true,
bullets:
Stream.repeatedly(fn -> __MODULE__.Bullet.new(width, height) end)
|> Enum.take(@base_bullet_count)
}
end
@impl true
def move(spaceship, direction) do
position = spaceship.position
updated_position =
case direction do
:up -> Vec2.add_y(position, -@base_spaceship_speed)
:down -> Vec2.add_y(position, @base_spaceship_speed)
:left -> Vec2.add_x(position, -@base_spaceship_speed)
:right -> Vec2.add_x(position, @base_spaceship_speed)
:idle -> position
end
%{spaceship | direction: direction, position: updated_position}
end
@impl true
def shoot(spaceship) do
bullets =
spaceship.bullets
|> Enum.map_reduce(false, fn
bullet, false when bullet.active? == false ->
{
%{
bullet
| active?: true,
direction: spaceship.direction,
position: spaceship.position
}, true}
bullet, updated ->
{bullet, updated}
end)
|> elem(0)
%{
spaceship
| bullets: bullets
}
end
@impl true
def update_bullets(%{alive?: false} = spaceship), do: spaceship
@impl true
def update_bullets(spaceship) do
%{spaceship | bullets: Enum.map(spaceship.bullets, &__MODULE__.Bullet.update/1)}
end
end
As shown, it’s a rather basic implementation that does very little, but everything we expect from a spaceship nonetheless:
- Base HP of 5 units, each bullet will deal 1 unit of damage to begin
- Base bullet count of 5, power ups may provide additional ammos, bullets are meant to automatically replenish over time
- A base movement speed of 3 units, this will be largely dependent on the client FPS
- An internal representation of the most elementary bullet with a base speed double of the ship
As a final side-note, there is a Vec2
utility module called there, we will omit
its implementation as it’s pretty straight forward and very short, it does exactly
what it looks like, vector 2D capabilities such as
- spawn of a random vector
- sum of 2 vectors
- sum of a constant on both coordinates
We finally define the main entity, this will carry the main piece of information to keep all the client in sync:
defmodule Dogfight.Game.State do
@moduledoc """
Game state management, Ships and bullet logics, including collision.
Represents the source of truth for each connecting player, and its updated
based on the input of each one of the connected active players
"""
alias Dogfight.Game.Event
alias Dogfight.Game.Spaceship
alias Dogfight.Game.DefaultSpaceship
alias Dogfight.Game.Vec2
@type player_id :: String.t()
@type power_up_kind :: :hp_plus_one | :hp_plus_three | :ammo_plus_one | nil
@type power_up :: %{
position: Vec2.t(),
kind: power_up_kind()
}
@type direction :: :idle | :up | :down | :left | :right
@typep status :: :in_progress | :closed | nil
@type t :: %__MODULE__{
players: %{player_id() => Spaceship.t()},
power_ups: [power_up()],
status: status()
}
defstruct [:players, :power_ups, :status]
@screen_width 800
@screen_height 600
def new do
%__MODULE__{
power_ups: [],
status: :closed,
players: %{}
}
end
@spec add_player(t(), player_id()) :: {:ok, t()} | {:error, :dismissed_ship}
def add_player(game_state, player_id) do
case Map.get(game_state.players, player_id) do
nil ->
players =
Map.put(
game_state.players,
player_id,
DefaultSpaceship.spawn(@screen_width, @screen_height)
)
{:ok, %{game_state | players: players}}
%{alive?: true} ->
{:ok, game_state}
_spaceship ->
{:error, :dismissed_ship}
end
end
@spec drop_player(t(), player_id()) :: t()
def drop_player(game_state, player_id) do
Map.update!(game_state, :players, fn players ->
Map.delete(players, player_id)
end)
end
@spec update(t()) :: t()
def update(game_state) do
%{
game_state
| players:
Map.new(game_state.players, fn {player_id, spaceship} ->
{player_id, DefaultSpaceship.update_bullets(spaceship)}
end)
}
end
defp fetch_spaceship(players_map, player_id) do
case Map.fetch(players_map, player_id) do
:error -> {:error, :dismissed_ship}
%{alive?: false} -> {:error, :dismissed_ship}
{:ok, _spaceship} = ok -> ok
end
end
@spec apply_event(t(), Event.t()) :: {:ok, t()} | {:error, :dismissed_ship}
def apply_event(game_state, {:move, {player_id, direction}}) do
with {:ok, spaceship} <- fetch_spaceship(game_state.players, player_id) do
{:ok,
%{
game_state
| players:
Map.put(game_state.players, player_id, DefaultSpaceship.move(spaceship, direction))
}}
end
end
def apply_event(game_state, {:shoot, player_id}) do
with {:ok, spaceship} <- fetch_spaceship(game_state.players, player_id) do
{:ok,
%{
game_state
| players: Map.put(game_state.players, player_id, DefaultSpaceship.shoot(spaceship))
}}
end
end
def apply_event(game_state, {:spawn_power_up, power_up_kind}) do
power_up = %{position: Vec2.random(@screen_width, @screen_height), kind: power_up_kind}
{:ok,
%{
game_state
| power_ups: [power_up | game_state.power_ups]
}}
end
def apply_event(_game_state, _event), do: raise("Not implemented")
def idle, do: :idle
def move_up, do: :up
def move_down, do: :down
def move_left, do: :left
def move_right, do: :right
def shoot, do: :shoot
end
A small set of functions to manage a basic game state:
new/0
just creates an empty, or base stateadd_player/2
called by theEventHandler
, it’s triggered on connect when a new player connectsapply_event/2
called by theEventHandler
when any event coming from players or scheduled on a time-basisupdate/1
this is a kind of engine for the state, it’s called on a time-basis by theEventHandler
and it allows the state to naturally evolve based on it’s current configuration; for example, if there are 3 bullets active across 2 players that fired, this function will update their position according to their speed (and possibly acceleration/deceleration later on, if we feel like adding that logic too)
We will need to update the main entry point of the program, the Application
to
correctly start a supervision tree including the Server
and the EventHandler
.
defmodule Dogfight.Application do
@moduledoc """
Documentation for `Dogfight`.
Server component for the Dogfight battle arena game, developed to have
some fun with game dev and soft-real time distributed systems.
"""
use Application
def start(_type, _args) do
port = String.to_integer(System.get_env("DOGFIGHT_TCP_PORT") || "6699")
children = [
{Cluster.Supervisor, [topologies(), [name: Dogfight.ClusterSupervisor]]},
{
Horde.Registry,
name: Dogfight.ClusterRegistry, keys: :unique, members: :auto
},
{
Horde.DynamicSupervisor,
name: Dogfight.ClusterServiceSupervisor, strategy: :one_for_one, members: :auto
},
Dogfight.Game.EventHandler,
Supervisor.child_spec({Task, fn -> Dogfight.Server.listen(port) end}, restart: :permanent)
]
opts = [strategy: :one_for_one, name: Dogfight.Supervisor]
Supervisor.start_link(children, opts)
end
# Can also read this from conf files, but to keep it simple just hardcode it for now.
# It is also possible to use different strategies for autodiscovery.
# Following strategy works best for docker setup we using for this app.
defp topologies do
[
game_state_nodes: [
strategy: Cluster.Strategy.Epmd,
config: [
hosts: [:"app@n1.dev", :"app@n2.dev", :"app@n3.dev"]
]
]
]
end
end
Code can be found in the GitHub repository.
References
Categories: #elixir #networking #c #game-development #low-level