Init 1
Category: c
Dogfight! - Client MVP
posted on 2025 Jan 16
In the previous parts the focus has been on laying some foundations for the core logic of the game, there is still plenty to do and the code is barely prototypical, no real test has been done yet but it’s good enough to try and show something on a terminal and a window, what we have so far:
- A TCP server accepting incoming connection and creating player contexts
- A game state to represent entities in a fixed size grid
- Spaceships
- Bullets
- Power-ups (this not yet governed in practice but the concept is there)
- A rudimentary event system to apply changes and actions to the game state
- A binary based protocol to exchange payloads
The next bit is a client side to communicate with the server these information and see it reflected based on the user input, we’re gonna need
- A TCP client to connect to the server component
- An implementation of the binary protocol, this is gonna be from scratch as well as we’re not using any structured library
- A graphic layer to display the state of the game, among which I considered:
- A way to capture the user input and serialized it to be sent to the server
Brief intro on the language of choice
I will use the original battletank_client.c as a starting point, it’s pretty raw and simple but the logic is roughly the same. This time around I will implement it in a different language than C. I love C, it’s my main go-to choice for low-level applications and, for how many flaws and dangers and annoyances it poses, I can’t help but find joy in implementing stuff in it. So it was naturally my first choice here as well, but after having implemented a basic version of the game already, I decided to take the chance to give an honest try to one of the other new kids on the block.
The choices were mostly driven by two requirements
- System language, no garbage collection
- Simplicity, not too much frills, in the style of C
There are a growing number of so-called C-killer languages being shipped consistently in the recent years, none of them really worthy of the title yet, but some feel at least like a good start with some pretty nice design and features; in the end, my shortlist was
- Zig
- Odin
- Hare
Hare
fell immediately as a second contender, I don’t know much of the
language itself but for some reason I wasn’t impressed by its showcase, one
cool point is the
qbe,
felt nice to see a new language not relying on LLVM as a backend but producing
(and releasing for adoption for other compilers too) something around 70% of
LLVM with 90% of bloat less, pretty impressive feat.
The final choice has really been a toss of the coin between Odin
and Zig
.
Both provide a nice set of features and ports to Raylib
(which was already
the main choice for the GUI layer). Zig
seemed a little more mature and is
showing some impressive feats recently with some great products shipped in it
(TigerBeetle
, Bun
and more recently Ghostty
are all great quality and
high performance software) plus it was slightly higher on my TO-TRY list.
Starting the client
For the client side, we will start from where we left, probably not the best choice but it somehow felt natural to begin the development of it by the main connecting point, the protocol.
const std = @import("std");
const net = std.net;
const testing = std.testing;
const max_players: usize = 5;
const max_bullets: usize = 5;
const player_size: usize = @sizeOf(Player) + 36;
const power_up_size: usize = @sizeOf(PowerUp);
const Vector2D = struct {
x: i32,
y: i32,
pub fn print(self: Vector2D, writer: anytype) !void {
try writer.print("({d}, {d})", .{ self.x, self.y });
}
};
pub const Direction = enum {
idle,
up,
down,
left,
right,
pub fn print(self: Direction, writer: anytype) !void {
const dir_names = [_][]const u8{
"idle", "up", "down", "left", "right",
};
try writer.print("{s}", .{dir_names[@intFromEnum(self)]});
}
};
pub const Bullet = struct {
position: Vector2D,
direction: Direction,
active: bool,
pub fn print(self: Bullet, writer: anytype) !void {
try writer.print(" position: ", .{});
try self.position.print(writer);
try writer.print(" Direction: ", .{});
try self.direction.print(writer);
try writer.print(" Active: {s}\n", .{if (self.active) "true" else "false"});
}
};
pub const Player = struct {
position: Vector2D,
hp: i32,
direction: Direction,
alive: bool,
bullets: [max_bullets]Bullet,
pub fn print(self: Player, writer: anytype) !void {
try writer.print(" position: ", .{});
try self.position.print(writer);
try writer.print(" HP: {d}\n", .{self.hp});
try writer.print(" Direction: ", .{});
try self.direction.print(writer);
try writer.print(" Alive: {s}\n", .{if (self.alive) "true" else "false"});
try writer.print(" Bullets:\n", .{});
for (self.bullets) |bullet| {
try bullet.print(writer);
}
}
};
const PowerUpKind = enum {
none,
hp_plus_onene,
hp_plus_threeee,
ammo_plus_onene,
pub fn print(self: PowerUpKind, writer: anytype) !void {
const kind_names = [_][]const u8{
"none", "hp_plus_one", "hp_plus_three", "ammo_plus_one",
};
try writer.print("{s}", .{kind_names[@intFromEnum(self)]});
}
};
pub const GameStatus = enum {
in_progresss,
closed,
pub fn print(self: GameStatus, writer: anytype) !void {
const status_names = [_][]const u8{ "In Progress", "Closed" };
try writer.print("{s}", .{status_names[@intFromEnum(self)]});
}
};
pub const PowerUp = struct {
position: Vector2D,
kind: PowerUpKind,
pub fn print(self: PowerUp, writer: anytype) !void {
try writer.print("position: ", .{});
try self.position.print(writer);
try writer.print(" Kind: ", .{});
try self.kind.print(writer);
try writer.print("\n", .{});
}
};
pub const GameState = struct {
players: std.StringHashMap(Player),
status: GameStatus,
power_ups: []PowerUp,
pub fn print(self: GameState, writer: anytype) !void {
try writer.print("GameState:\n", .{});
try writer.print(" Power ups:\n", .{});
for (self.power_ups) |power_up| {
try power_up.print(writer);
}
try writer.print(" Players:\n", .{});
var iterator = self.players.iterator();
var player_index: usize = 0;
while (iterator.next()) |entry| {
try writer.print(" Player {d} - {s}:\n", .{ player_index, entry.key_ptr.* });
try entry.value_ptr.print(writer);
player_index += 1;
}
}
};
pub fn decode(buffer: []const u8, allocator: std.mem.Allocator) !GameState {
var buffered_stream = std.io.fixedBufferStream(buffer);
var reader = buffered_stream.reader();
const total_length = try reader.readInt(i32, .big);
const game_status = try reader.readByte();
const power_ups = try decode_power_ups(reader, allocator);
const usize_total_length: usize = @intCast(total_length);
// Rough calculation of the players count based on the bytes already
// read and the size expected for each player
const players_count = (usize_total_length - (power_ups.len * power_up_size) - @sizeOf(u8) - @sizeOf(i32) - @sizeOf(i16)) / player_size;
var players = std.StringHashMap(Player).init(allocator);
for (0..players_count) |_| {
var player = Player{
.position = Vector2D{
.x = try reader.readInt(i32, .big),
.y = try reader.readInt(i32, .big),
},
.hp = try reader.readInt(u8, .big),
.alive = try reader.readInt(u8, .big) != 0, // Deserialize bool as u8
.direction = @enumFromInt(try reader.readInt(u8, .big)),
.bullets = undefined,
};
var binary_player_id: [36]u8 = undefined;
_ = try reader.readAll(&binary_player_id);
const player_id = try std.fmt.allocPrint(allocator, "{s}", .{binary_player_id[0..]});
var bullets: [max_bullets]Bullet = undefined;
for (&bullets) |*bullet| {
bullet.position = Vector2D{
.x = try reader.readInt(i32, .big),
.y = try reader.readInt(i32, .big),
};
bullet.active = try reader.readInt(u8, .big) != 0; // Deserialize bool as u8
bullet.direction = @enumFromInt(try reader.readInt(u8, .big));
}
player.bullets = bullets; // Assign bullets array
try players.put(player_id, player);
}
return GameState{ .players = players, .power_ups = power_ups, .status = @enumFromInt(game_status) };
}
fn decode_power_ups(reader: anytype, allocator: std.mem.Allocator) ![]PowerUp {
const raw_size = try reader.readInt(i16, .big);
const size = @divExact(raw_size, ((@sizeOf(i32) * 2) + @sizeOf(u8)));
if (size < 0) {
return error.InvalidSize; // Return an error for negative size
}
const usize_size: usize = @intCast(size);
const power_ups = try allocator.alloc(PowerUp, usize_size);
for (power_ups) |*power_up| {
power_up.position = Vector2D{ .x = try reader.readInt(i32, .big), .y = try reader.readInt(i32, .big) };
power_up.kind = @enumFromInt(try reader.readByte());
}
return power_ups;
}
Basically the Zig
representation of the defined structures in the previous
part, enriched with a convenient debugging utility in the form of a simple
print function for each entity.
The gamestate is only one half of the puzzle, we must also define how to interact with it, specifically how to send events, this part is crucial for the client side.
const std = @import("std");
const gs = @import("gamestate.zig");
pub const PlayerId = [36]u8;
const move_u8: u8 = 2;
const shoot_u8: u8 = 3;
pub const Event = union(enum) {
move: MoveEvent,
shoot: PlayerId,
pub const MoveEvent = struct {
player_id: PlayerId,
direction: gs.Direction,
};
};
pub fn encode(event: Event, allocator: std.mem.Allocator) ![]u8 {
return switch (event) {
.move => |move_event| {
const buffer = try allocator.alloc(u8, @sizeOf(u8) + @sizeOf(Event.MoveEvent));
errdefer allocator.free(buffer); // Ensure buffer is freed on error
var buffered_stream = std.io.fixedBufferStream(buffer);
var writer = buffered_stream.writer();
try writer.writeInt(u8, move_u8, .big);
try writer.writeInt(u8, @intFromEnum(move_event.direction), .big);
try writer.writeAll(&move_event.player_id);
return buffer;
},
.shoot => |player_id| {
const buffer = try allocator.alloc(u8, @sizeOf(u8) + @sizeOf(PlayerId));
errdefer allocator.free(buffer); // Ensure buffer is freed on error
var buffered_stream = std.io.fixedBufferStream(buffer);
var writer = buffered_stream.writer();
try writer.writeInt(u8, shoot_u8, .big);
try writer.writeAll(&player_id);
return buffer;
},
};
}
TCP connection
Going fast here, the next step required is the connection piece to actually talk with the server side, starting from some helper functions. Zig has a pretty rich and well defined network layer so the resulting module is very straight forward and self-contained.
What we need to support is a request-reply paradigm essentially
- A
receiveUpdate
function to read the serialized gamestate from the wire coming from the server - A
sendUpdate
to interact and modify the received gamestate and update it on the server side
const std = @import("std");
const net = std.net;
const gs = @import("gamestate.zig");
const bufsize: usize = 1024;
pub fn connect(host: []const u8, port: comptime_int) !net.Stream {
const peer = try net.Address.parseIp4(host, port);
const stream = try net.tcpConnectToAddress(peer);
return stream;
}
// TODO Placeholder, this will carry some additional logic going forward
pub fn handshake(stream: *const net.Stream, buffer: *[36]u8) !void {
_ = try stream.reader().read(buffer);
}
pub fn receiveUpdate(stream: *const net.Stream, allocator: std.mem.Allocator) !gs.GameState {
var buffer: [bufsize]u8 = undefined;
_ = try stream.reader().read(&buffer);
return gs.decode(&buffer, allocator);
}
pub fn sendUpdate(stream: *const net.Stream, buffer: []const u8) !void {
try stream.writer().writeAll(buffer);
}
Besides the typical TCP stream features to connect, there is an handshake
function defined which will later on handle the handshake between the server
and clients, thinking about authentication, tokens and reconnection logic to
resume a game etc. For now it carries the simplest placeholder of reading what
comes first thing from the server, i.e. the generated player ID.
Reading the first game state
We have all the elements for a basic interaction now, i.e. a connection to the server and print the original state of the game as a new player joining the game. This can be done, for simplicty, in the main function to begin with
//! Dogfight! client side
//!
//! Connects to the gameserver running at port 6699 and receive the gamestate
//! with its own positioned ship
const std = @import("std");
const net = @import("network.zig");
const gs = @import("gamestate.zig");
const server_port = 6699;
pub fn main() anyerror!void {
std.debug.print("Connecting to game server {}\n", .{server_port});
const allocator = std.heap.page_allocator;
// Connect to peer
const stream = try net.connect("127.0.0.1", server_port);
defer stream.close();
// Read player ID
var player_id: [36]u8 = undefined;
try net.handshake(&stream, &player_id);
std.debug.print("Player ID: {s}\n", .{player_id});
// Receive the game state from the server
const gamestate = try net.receiveUpdate(&stream, allocator);
// Log it to console for debug
const stdout = std.io.getStdOut().writer();
try gamestate.print(stdout);
}
A simple mix run --no-halt
on the Elixir
server side to run the server
component, a zig build run
in the client directory and the game state
should be printed on the terminal.
Dogfight! - Elixir server
posted on 2025 Jan 01
Starting 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
Dogfight!
posted on 2024 Dec 29
A couple of weeks ago, inspired by an as gripping as nerdy tv series (check out Halt and Catch Fire 🔥), I explored a little idea, a small, self-contained low level project to build an old arcade game - battletank arena - with a little kink in the form of a centralized TCP server to handle multiplayer through the network.
An important note, I’m not a game developer, and don’t have any significant experience on it, best practices and patterns, the code and design in general will likely be subpar to say the least, the aim is just having fun while learning somthing.
The rules were simple
- Keep it small and simple to keep it fun
- Use the least amount of external dependencies as possible
I think I somewhat respected them as much as possible, not pedantically but in a good measure. During the development I
- Developed a first simpler version using
ncurses
to handle the “GUI” - Implemented some very dumb synchronization logic to keep FPS consistent between players
- An even dumber binary protocol to communicate, game state being the only payload exchanged
- A micro set of features (bullets, power ups, life points etc)
I then moved on and got intrigued by the possibility of extending the client side of the
game with a little more ambitious graphical layer, using raylib
. The library is pretty
solid, small but powerful; I still kept most of the core feature-set as simple as possible,
in no particular order:
- Select server based with non-blocking sockets, no async libraries such as
UV
and the like - The binary protocol exchanges the entire game state, while it’s fine for the size is pretty contained, it would’ve been simple to evolve it to send delta updates
- All the controls are keyboard based, no oblique movements, acceleration
- No scaling/proper handling of the screen size of each client (nasty bugs are totally ignored)
- No optimizations of sort, not lag compensation, path prediction and other painful network management issues
- No sprite animations, bullet explosions and so on
All of the above are “easy” upgrades that could be tackled, what caught my
curiosity is, on paper, this kind of games are mostly heavily I/O based, the
heavy work is made on client side to render something eye-pleasing essentially,
how well would it work an highly scalable TCP server in another language
instead of C to manage the multiplayer stack, e.g. games, rooms, chats possibly
and so on. So I decided to give it a go in Elixir and port my
battletank-server
to an elixir based implementation, which would make it
painless to distribute in a cluster and happily use all the resources of all
the nodes.
Goals
Once again, simplicity and a small codebase are paramount here, the project
aims to be a variations on the previous iteration, hopefully with some
additions enabled by how convenient elixir
is for network-based soft real-time
applications. There is no expectations or quality goals, it is intended to be
an exploratory and fun work.
Moreover, the development will be divided in the server side and the client side, leveraging most of the work previously done.
Server side
- An
elixir
based TCP server, probably using the builtin:gen_tcp
fromerlang
- Binary encoding/decoding of the game state, subsequently add delta updates as suggested above
- Distributed by default, probably throwing
libcluster
andhorde
to the mix to make it smoother - Game lobbies, chats, maybe a leaderboard
Client side
- Adding support to delta encoding/decoding of the game state
- Explore further
raylib
, nicer sprites and possibly animations - A menu or something to enable a game lobby or hosting a game
- In-game chat support
- Possibly written in
Zig
. Although I can already leverage a C implementation it may be a good opportunity to explore a new language, plus Zig has full ABI compatibility with C and a nice Raylib port
I reckon all of the above, although it doesn’t look like that much, is pretty ambitious and will take some time to be achieved. There will be bugs.
Roadmap
Coding will happen during free-time and will not necessarily be consistent, I will try to journal as much as possible, but the high-level roadmap is expected to be something like
- Server - basic TCP server setup
- Server - add support for the binary protocol used to communicate in
battletank
- Server - add support for game instantiation and distribution across multiple nodes
- Client - some graphical improvements
- Both - delta encoding/decoding of the game state
- Both - extending the protocol to control pre-game logic (lobbies, game hosting etc)
- Both - cherries on top, refinements
I’ll try to stick to it by documenting this little journey in short articles, with the following currently available in various stage of development (some are to be considered stretch goals)
- Dogfight! - Elixir server
- Dogfight! - Binary protocol
- Dogfight! - Client MVP
- Dogfight! - Zig, checking Raylib.zig
- Dogfight! - Elixir and Zig, game state revamp
- Dogfight! - Elixir and Zig, delta state encoding
- Dogfight! - Elixir and Zig, hosting a game
- Dogfight! - Elixir and Zig, in game chat
References
Battletank!
posted on 2024 Sep 28
Small project idea to have some fun with C, a micro multiplayer classic game with as less dependencies as possible. I’ve always been curious about the challenges of game development so I started from one of the simplest ideas. The latest update of the code can be found in the repository codepr/battletank.git.
The game
It’s about a best effort terminal based implementation of the classic
battle-tank, starting from a single player single tank and extending it to work
as a multiplayer server with sockets to sync the game state across players.
To begin with, the only dependency is ncurses
, later on, if I get confident enough,
I will consider something fancier such as Raylib
.
Why
To have some fun, small old school programs are fun to mess with. In addition, although I worked for a brief stint for an AAA gaming company, I was mainly developing on the back-end side of the product, pre-game lobbies, chat rooms and game server deployments; but didn’t really know much about the inner logic of the game itself. This was a good opportunity to try and learn something about game development starting from the basics.
Ideas
In no particular order, and not necessarily mandatory:
- Implement a very simple and stripped down game logic ✅
- 1 player, 1 tank, 1 bullet ✅
- Handle keyboard input ✅
- Design a better structured game state, multiple tanks, each tank loaded with bullets
- Multiplayer through TCP sockets
- Implement a TCP server ✅
- Implement a TCP client ✅
- Implement a network protocol (text based or binary) ✅
- The clients will send their input, server handle the game state and broadcasts it to the connected clients ✅
- Heartbeat server side, drop inactive clients
- Small chat? Maybe integrate chatlite
- Ensure screen size scaling is maintained in sync with players
- Walls
- Life points
- Bullets count
- Recharge bullets on a time basis
- Power ups (faster bullets? Larger hit box? Mines?)
- Explore SDL2 or Raylib for some graphic or sprites
Main challenges
The game is pretty simple, the logic is absolutely elementary, just x, y axis as direction for both the tank and the bullets. The main challenges are represented by keeping players in sync and ensure that the battlefield size is correctly scaled for each one of them (not touched this yet).
For the communication part I chose the simplest and ubiquitous solution, a
non-blocking server on select
(yikes, maybe at least poll
) and a timeout on
read client, this way the main loops are not blocked indefinitely and the game
state can flow. There are certainly infinitely better ways to do it, but
avoiding threading and excessive engineered solutions was part of the scope.
There is not plan to make anything more than have some fun out of this project,
one simple upgrade would be to migrate the server implementation to epoll
or
kqueue
, however it’s definitely not a game to be expected to have a
sufficiently high number of players to prove problematic to handle for the good
old select
call.
Something I find more interesting is delving a little more into graphics, I
literally don’t know anything about it but I can see libraries such as Raylib
seem to make it simple enough to generate some basic animations and sprites,
the program is sufficiently simple to try and plug it in, hopefully without too
much troubles.
Implementation
I won’t go deep in details, some parts are to be considered boilerplate, such as serialization, TCP helpers and such. To get into details of those parts, the repository is codepr/battletank.git.
The game can be divided in the main modules:
- The game state
- Tank
- Bullet
- A game server, handles the game state and serves as the unique authoritative source of truth.
- A game client, connects to the server, provides a very crude terminal based graphic battlefield and handles input from the player.
- A protocol to communicate. Initially I went for a text-based protocol, but I’m not very fond of them, so I decided for a binary one eventually, a very simple one.
The game state
The game state is the most simple I could imagine to begin with
- An array of tanks
- Each tank has 1 bullet
Short and sweet, to keep in sync, the server is only required to update the coordinates of each tank and bullet and send them to the clients. This structure is probably where additional improvements mentioned in the intro paragraph could live, power ups, walls, bullets and their kinds, mines etc.
game_state.h
// Possible directions a tank or bullet can move.
typedef enum { IDLE, UP, DOWN, LEFT, RIGHT } Direction;
// Only fire for now, can add something else such as
// DROP_MINE etc.
typedef enum {
FIRE = 5,
} Action;
// Represents a bullet with its position, direction, and status.
// Can include bullet kinds as a possible update for the future.
typedef struct {
int x;
int y;
Direction direction;
bool active;
} Bullet;
// Represents a tank with its position, direction, and status.
// Contains a single bullet for simplicity, can be extended in
// the future to handle multiple bullets, life points, power-ups etc.
typedef struct {
int x;
int y;
Direction direction;
bool alive;
Bullet bullet;
} Tank;
typedef struct {
Tank *players;
size_t players_count;
size_t player_index;
} Game_State;
// General game state managing
void game_state_init(Game_State *state);
void game_state_free(Game_State *state);
void game_state_update(Game_State *state);
// Tank management
void game_state_spawn_tank(Game_State *state, size_t index);
void game_state_dismiss_tank(Game_State *state, size_t index);
void game_state_update_tank(Game_State *state, size_t tank_index,
unsigned action);
Tanks and bullets
Although the structures introduced are trivial, some helper functions to manage tanks and bullets can come handy; when the server starts, the first thing will be to init a global game state. When a new player connects, a tank will be spawned in the battlefield, I opted for a random position spawning in a small set of coordinates. In hindsight, I could’ve easily set a fixed number of players such as 10, I went for a dynamic array on auto-pilot basically. To be noted that as of now I’m not really correctly freeing the allocated tanks (these are the only structure that is heap allocated) as it’s not really necessary, the memory will be released at shutdown of the program anyway and the number of expected players is not that big. That said, it’s definitely best practice to handle the case correctly, I may address that at a later stage.
game_state.c
void game_state_init(Game_State *state) {
state->players_count = 2;
state->players = calloc(2, sizeof(Tank));
for (size_t i = 0; i < state->players_count; ++i) {
state->players[i].alive = false;
state->players[i].bullet.active = false;
}
}
void game_state_free(Game_State *state) { free(state->players); }
void game_state_spawn_tank(Game_State *state, size_t index) {
// Extend the players pool if we're at capacity
if (index > state->players_count) {
state->players_count *= 2;
state->players = realloc(state->players, state->players_count);
}
if (!state->players[index].alive) {
state->players[index].alive = true;
state->players[index].x = RANDOM(15, 25);
state->players[index].y = RANDOM(15, 25);
state->players[index].direction = 0;
}
}
void game_state_dismiss_tank(Game_State *state, size_t index) {
state->players[index].alive = false;
}
And here to follow the remaining functions needed to actually update the state of the game, mainly manipulation of the X, Y axis for the tank and bullet directions based on actions coming from each client.
To check collision initially I just check that the coordinates of a given tank
collide with those of a given bullet. Admittedly I didn’t focus much on
that (after all there isn’t even a score logic yet), for a first test run I
was more interested into seeing actually tanks moving and be in sync with
each other through the network, but check_collision
still provides a good
starting point to expand on later.
game_state.c
static void fire_bullet(Tank *tank) {
if (!tank->bullet.active) {
tank->bullet.active = true;
tank->bullet.x = tank->x;
tank->bullet.y = tank->y;
tank->bullet.direction = tank->direction;
}
}
void game_state_update_tank(Game_State *state, size_t tank_index,
unsigned action) {
switch (action) {
case UP:
state->players[tank_index].y--;
state->players[tank_index].direction = UP;
break;
case DOWN:
state->players[tank_index].y++;
state->players[tank_index].direction = DOWN;
break;
case LEFT:
state->players[tank_index].x--;
state->players[tank_index].direction = LEFT;
break;
case RIGHT:
state->players[tank_index].x++;
state->players[tank_index].direction = RIGHT;
break;
case FIRE:
fire_bullet(&state->players[tank_index]);
break;
default:
break;
}
}
static void update_bullet(Bullet *bullet) {
if (!bullet->active) return;
switch (bullet->direction) {
case UP:
bullet->y--;
break;
case DOWN:
bullet->y++;
break;
case LEFT:
bullet->x -= 2;
break;
case RIGHT:
bullet->x += 2;
break;
default:
break;
}
if (bullet->x < 0 || bullet->x >= COLS || bullet->y < 0 ||
bullet->y >= LINES) {
bullet->active = false;
}
}
static void check_collision(Tank *tank, Bullet *bullet) {
if (bullet->active && tank->x == bullet->x && tank->y == bullet->y) {
tank->alive = false;
bullet->active = false;
}
}
/**
* Updates the game state by advancing bullets and checking for collisions
* between tanks and bullets.
*
* - Creates an array of pointers to each player's bullet for easy access during
* collision checks.
* - For each player:
* - Updates their bullet by calling `update_bullet`.
* - Checks for collisions between the player's tank and every other player's
* bullet using `check_collision`.
* - Skips collision checks between a player and their own bullet.
*/
void game_state_update(Game_State *state) {
Bullet *bullets[state->players_count];
for (size_t i = 0; i < state->players_count; ++i)
bullets[i] = &state->players[i].bullet;
for (size_t i = 0; i < state->players_count; ++i) {
update_bullet(&state->players[i].bullet);
for (size_t j = 0; j < state->players_count; ++j) {
if (j == i) continue; // Skip self collision
check_collision(&state->players[i], bullets[j]);
}
}
}
The client side
The client is the main entry point for each player, once started it connects to the battletank server and provides a very crude terminal based graphic battlefield and handles input from the player:
- upon connection,it syncs with the server on the game state, receiving an index that uniquely identifies the player tank in the game state
- the server continually broadcasts the game state to keep the clients in sync
- clients will send actions to the servers such as movements or bullet fire
- the server will update the general game state and let it be broadcast in the following cycle
Out of scope (for now)
The points above provide a very rudimentary interface to just see something work, there are many improvements and limitations to be overcome in the pure technical aspect that are not yet handled, some of these in no particular order:
- screen size scaling: each client can have a different screen size, this makes it tricky to ensure a consistent experience between all the participants, in the current state of things, a lot of glitches are likely to happen due to this fact.
- clients disconnections and re-connections, reusing existing tanks if already instantiated
- heartbeat logic to ensure clients aliveness
These are all interesting challenges (well, probably the heartbeat and proper
client tracking are less exciting, but the screen scaling is indeed
interesting) and some of these limitations may be address in an hypothetical
battletank v0.0.2
depending on inspiration.
Moving on with the code, the first part of the client side requires some helpers to
handle the UI, as agreed, this is not gonna be a graphical game (yet?) so ncurses
provides very handy and neat functions to draw something basic on terminal. I don’t
know much about the library itself but by the look of the APIs and their behaviour,
from my understanding of the docs it provides some nice wrappers around manipulation
of escape sequences for VT100 terminals and compatibles, similarly operating in raw
mode allowing for a fine-grained control over the keyboard input and such.
battletank_client.c
static void init_screen(void) {
// Start curses mode
initscr();
cbreak();
// Don't echo keypresses to the screen
noecho();
// Enable keypad mode
keypad(stdscr, TRUE);
nodelay(stdscr, TRUE);
// Hide the cursor
curs_set(FALSE);
}
static void render_tank(const Tank *const tank) {
if (tank->alive) {
// Draw the tank at its current position
mvaddch(tank->y, tank->x, 'T');
}
}
static void render_bullet(const Bullet *const bullet) {
if (bullet->active) {
// Draw the bullet at its current position
mvaddch(bullet->y, bullet->x, 'o');
}
}
static void render_game(const Game_State *state) {
clear();
for (size_t i = 0; i < state->players_count; ++i) {
render_tank(&state->players[i]);
render_bullet(&state->players[i].bullet);
}
refresh();
}
static unsigned handle_input(void) {
unsigned action = IDLE;
int ch = getch();
switch (ch) {
case KEY_UP:
action = UP;
break;
case KEY_DOWN:
action = DOWN;
break;
case KEY_LEFT:
action = LEFT;
break;
case KEY_RIGHT:
action = RIGHT;
break;
case ' ':
action = FIRE;
break;
}
return action;
}
In the last function handle_input
the unsigned action
returned will
be the main command we send to the server side (pretty simple huh? Ample
margin to enrich this semantic).
Next in line comes the networking helpers, required to manage the communication with the server side, connection, send and receive:
battletank_client.c
static int socket_connect(const char *host, int port) {
struct sockaddr_in serveraddr;
struct hostent *server;
struct timeval tv = {0, 10000};
// socket: create the socket
int sfd = socket(AF_INET, SOCK_STREAM, 0);
if (sfd < 0) goto err;
setsockopt(sfd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(struct timeval));
setsockopt(sfd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(struct timeval));
// gethostbyname: get the server's DNS entry
server = gethostbyname(host);
if (server == NULL) goto err;
// build the server's address
bzero((char *)&serveraddr, sizeof(serveraddr));
serveraddr.sin_family = AF_INET;
bcopy((char *)server->h_addr, (char *)&serveraddr.sin_addr.s_addr,
server->h_length);
serveraddr.sin_port = htons(port);
// connect: create a connection with the server
if (connect(sfd, (const struct sockaddr *)&serveraddr, sizeof(serveraddr)) <
0)
goto err;
return sfd;
err:
perror("socket(2) opening socket failed");
return -1;
}
static int client_connect(const char *host, int port) {
return socket_connect(host, port);
}
static int client_send_data(int sockfd, const unsigned char *data, size_t datasize) {
ssize_t n = network_send(sockfd, data, datasize);
if (n < 0) {
perror("write() error");
close(sockfd);
exit(EXIT_FAILURE);
}
return n;
}
static int client_recv_data(int sockfd, unsigned char *data) {
ssize_t n = network_recv(sockfd, data);
if (n < 0) {
perror("read() error");
close(sockfd);
exit(EXIT_FAILURE);
}
return n;
}
All simple boilerplate code mostly, to handle a fairly traditional TCP connection, the only bit that’s interesting here is represented by the lines
setsockopt(sfd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(struct timeval));
setsockopt(sfd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(struct timeval));
These two lines ensure that the read
system call times out after a certain
period, seemingly simulating a non-blocking socket behaviour (not really, but
the part that’s interesting for us). This is the very first solution and the
simplest that came to mind but it allows to run a recv loop without blocking
indefinitely, as the server will constantly push updates, the client wants to be
as up-to-date as possible to keep rendering an accurate and consistent game
state.
This part happens in the game_loop
function, a very slim and stripped down
client-side engine logic to render and gather inputs from the client to the
server:
battletank_client.c
// Main game loop, capture input from the player and communicate with the game
// server
static void game_loop(void) {
int sockfd = client_connect("127.0.0.1", 6699);
if (sockfd < 0) exit(EXIT_FAILURE);
Game_State state;
game_state_init(&state);
unsigned char buf[BUFSIZE];
// Sync the game state for the first time
int n = client_recv_data(sockfd, buf);
protocol_deserialize_game_state(buf, &state);
unsigned action = IDLE;
while (1) {
action = handle_input();
if (action != IDLE) {
memset(buf, 0x00, sizeof(buf));
n = protocol_serialize_action(action, buf);
client_send_data(sockfd, buf, n);
}
n = client_recv_data(sockfd, buf);
protocol_deserialize_game_state(buf, &state);
render_game(&state);
}
}
int main(void) {
init_screen();
game_loop();
endwin();
return 0;
}
The main function is as light as it gets, just initializes the ncurses
screen to
easily calculate COLS
and LINES
the straight to the game loop, with the
flow being:
- Connection to the server
- Sync of the game state, including other possibly already connected players
- Non blocking wait for input, if made, send it to the server to update the game state for everyone connected
- Receive data from the server, i.e. the game state, non blocking.
The server
The server side handles the game state and serves as the unique authoritative source of truth.
- clients sync at their first connection and their tank is spawned in the battlefield, the server will send a unique identifier to the clients (an int index for the time being, that represents the tank assigned to the player in the game state)
As with the client, a bunch of communication helpers to handle the TCP
connections. The server will be a TCP non-blocking server (man select
/
poll
/ epoll
/ kqueue
), relying on select
call to handle I/O events.
Select is not the most efficient mechanism for I/O multiplexing, it’s in fact
quite dated, the first approach to the problem, it’s a little quirky and among
other things it requires to linearly scan over all the monitored descriptors
each time an event is detected, it’s also an user-space call, which adds a
minor over-head in context switching and it’s limited to 1024 file descriptor
in total but:
- It’s ubiquitous, basically every *nix system provides the call
- It’s very simple to use and provides everything required for a PoC
- It’s more than enough for the use case, even with tenth of players
it would handle the load very well,
poll
andepoll
are really designed towards other scales, in the order of 10K of connected sockets.
battletank_server.c
// We don't expect big payloads
#define BUFSIZE 1024
#define BACKLOG 128
#define TIMEOUT 30000 // 30 ms
// Generic global game state
static Game_State game_state = {0};
/* Set non-blocking socket */
static int set_nonblocking(int fd) {
int flags, result;
flags = fcntl(fd, F_GETFL, 0);
if (flags == -1) goto err;
result = fcntl(fd, F_SETFL, flags | O_NONBLOCK);
if (result == -1) goto err;
return 0;
err:
fprintf(stderr, "set_nonblocking: %s\n", strerror(errno));
return -1;
}
static int server_listen(const char *host, int port, int backlog) {
int listen_fd = -1;
const struct addrinfo hints = {.ai_family = AF_UNSPEC,
.ai_socktype = SOCK_STREAM,
.ai_flags = AI_PASSIVE};
struct addrinfo *result, *rp;
char port_str[6];
snprintf(port_str, 6, "%i", port);
if (getaddrinfo(host, port_str, &hints, &result) != 0) goto err;
/* Create a listening socket */
for (rp = result; rp != NULL; rp = rp->ai_next) {
listen_fd = socket(rp->ai_family, rp->ai_socktype, rp->ai_protocol);
if (listen_fd < 0) continue;
/* set SO_REUSEADDR so the socket will be reusable after process kill */
if (setsockopt(listen_fd, SOL_SOCKET, SO_REUSEADDR, &(int){1},
sizeof(int)) < 0)
goto err;
/* Bind it to the addr:port opened on the network interface */
if (bind(listen_fd, rp->ai_addr, rp->ai_addrlen) == 0)
break; // Succesful bind
close(listen_fd);
}
freeaddrinfo(result);
if (rp == NULL) goto err;
/*
* Let's make the socket non-blocking (strongly advised to use the
* eventloop)
*/
(void)set_nonblocking(listen_fd);
/* Finally let's make it listen */
if (listen(listen_fd, backlog) != 0) goto err;
return listen_fd;
err:
return -1;
}
static int server_accept(int server_fd) {
int fd;
struct sockaddr_in addr;
socklen_t addrlen = sizeof(addr);
/* Let's accept on listening socket */
fd = accept(server_fd, (struct sockaddr *)&addr, &addrlen);
if (fd <= 0) goto exit;
(void)set_nonblocking(fd);
return fd;
exit:
if (errno != EWOULDBLOCK && errno != EAGAIN) perror("accept");
return -1;
}
static int broadcast(int *client_fds, const unsigned char *buf, size_t count) {
int written = 0;
for (int i = 0; i < FD_SETSIZE; i++) {
if (client_fds[i] >= 0) {
// TODO check for errors writing
written += network_send(client_fds[i], buf, count);
}
}
return written;
}
Again, the main just initializes the ncurses
screen (this is the reason why
the PoC will assume that the players will play from their own full size
terminal, as currently there is no scaling mechanism in place to ensure
consistency) and run the main select
loop waiting for connections. Clients
are tracked in the simplest way possible by using an array and each new
connected client will be assigned its index in the main array as the index for
his tank in the game state.
battletank_server.c
static void server_loop(int server_fd) {
fd_set readfds;
int client_fds[FD_SETSIZE];
int maxfd = server_fd;
int i = 0;
unsigned char buf[BUFSIZE];
struct timeval tv = {0, TIMEOUT};
unsigned long long current_time_ns = 0, remaining_us = 0,
last_update_time_ns = 0;
// Initialize client_fds array
for (i = 0; i < FD_SETSIZE; i++) {
client_fds[i] = -1;
}
while (1) {
FD_ZERO(&readfds);
FD_SET(server_fd, &readfds);
for (i = 0; i < FD_SETSIZE; i++) {
if (client_fds[i] >= 0) {
FD_SET(client_fds[i], &readfds);
if (client_fds[i] > maxfd) {
maxfd = client_fds[i];
}
}
}
memset(buf, 0x00, sizeof(buf));
int num_events = select(maxfd + 1, &readfds, NULL, NULL, &tv);
if (num_events == -1) {
perror("select() error");
exit(EXIT_FAILURE);
}
if (FD_ISSET(server_fd, &readfds)) {
// New connection request
int client_fd = server_accept(server_fd);
if (client_fd < 0) {
perror("accept() error");
continue;
}
for (i = 0; i < FD_SETSIZE; i++) {
if (client_fds[i] < 0) {
client_fds[i] = client_fd;
game_state.player_index = i;
break;
}
}
if (i == FD_SETSIZE) {
fprintf(stderr, "Too many clients\n");
close(client_fd);
continue;
}
printw("[info] New player connected\n");
printw("[info] Syncing game state\n");
printw("[info] Player assigned [%ld] tank\n",
game_state.player_index);
// Spawn a tank in a random position for the new connected
// player
game_state_spawn_tank(&game_state, game_state.player_index);
// Send the game state
ssize_t bytes = protocol_serialize_game_state(&game_state, buf);
bytes = network_send(client_fd, buf, bytes);
if (bytes < 0) {
perror("network_send() error");
continue;
}
printw("[info] Game state sync completed (%d bytes)\n", bytes);
}
for (i = 0; i < FD_SETSIZE; i++) {
int fd = client_fds[i];
if (fd >= 0 && FD_ISSET(fd, &readfds)) {
ssize_t count = network_recv(fd, buf);
if (count <= 0) {
close(fd);
game_state_dismiss_tank(&game_state, i);
client_fds[i] = -1;
printw("[info] Player [%d] disconnected\n", i);
} else {
unsigned action = 0;
protocol_deserialize_action(buf, &action);
printw(
"[info] Received an action %s from player [%d] (%ld "
"bytes)\n",
str_action(action), i, count);
game_state_update_tank(&game_state, i, action);
printw("[info] Updating game state completed\n");
}
}
}
// Send update to the connected clients, currently with a TIMEOUT of
// 16ms is roughly equal to 60 FPS. Checks for the last update sent and
// adjust the select timeout so to make it as precise and smooth as
// possible and respect the deadline
current_time_ns = get_microseconds_timestamp();
remaining_us = current_time_ns - last_update_time_ns;
if (remaining_us >= TIMEOUT) {
// Main update loop here
game_state_update(&game_state);
size_t bytes = protocol_serialize_game_state(&game_state, buf);
broadcast(client_fds, buf, bytes);
last_update_time_ns = get_microseconds_timestamp();
tv.tv_sec = 0;
tv.tv_usec = TIMEOUT;
} else {
tv.tv_sec = 0;
tv.tv_usec = TIMEOUT - remaining_us;
}
// We're using ncurses for convenience to initialize ROWS and LINES
// without going raw mode in the terminal, this requires a refresh to
// print the logs
refresh();
}
}
int main(void) {
srand(time(NULL));
// Use ncurses as its handy to calculate the screen size
initscr();
scrollok(stdscr, TRUE);
printw("[info] Starting server %d %d\n", COLS, LINES);
game_state_init(&game_state);
int server_fd = server_listen("127.0.0.1", 6699, BACKLOG);
if (server_fd < 0) exit(EXIT_FAILURE);
server_loop(server_fd);
return 0;
}
An interesting bit is the syncing of the framerate client-side with the udpates
coming from the server, the initial implementation relies on select
timing
out every at 30ms, that means that the game will update consistently when there
is not input from any client (e.g. every one is not moving), but realistically,
all the players will be moving frequently, resulting in the select
call to
detect I/O events on the observed sockets before the TIMEOUT deadline.
This may generate weird and funny bugs, such as bullets flying much faster than
expected when tanks are moving. A naive but simple approach to solve the
problem is to track the timestamp in nanoseconds of each update and update the
select
timeout accordingly.
- check for remaining us (microseconds) left to reach the TIMEOUT deadline
in the current cycle, if we’re already beyond, send a gamestate update,
record the last update us and reset the
select
timeout - if the remaining us have not yet reached the deadline, update the
select
timeout to TIMEOUT - remaining
This way, the update frequency is ensured to be mostly consitent at roughly the same time each cycle.
That’s all folks, an extremely small and simple battletank should allow multiple players to join and shoot single bullets. No collisions nor scores or life points yet, but it’s a starting point, in roughly 600 LOC:
battletank (main) $ ls *.[c,h] | xargx cloc
8 text files.
8 unique files.
0 files ignored.
github.com/AlDanial/cloc v 2.02 T=0.02 s (521.2 files/s, 61767.2 lines/s)
-------------------------------------------------------------------------------
Language files blank comment code
-------------------------------------------------------------------------------
C 5 134 172 563
C/C++ Header 3 17 10 52
-------------------------------------------------------------------------------
SUM: 8 151 182 615
-------------------------------------------------------------------------------
References
Time-series adventures
posted on 2024 Apr 13
Databases, how they interact with the filesystem at low-level, have always represented a fascinating topic for me. I implemented countless in-memory key-value stores at various level of abstraction and various stages of development; with multiple languages, Scala, Python, Elixir, Go. Gradually going more and more in details, it was finally time to try and write something a bit more challenging.
A relational database was too big of a step to begin with and being the persistence the very first big problem I could think of facing almost immediately, I wanted something that could be implemented based on a simple but extremely versatile and powerful structure, the log. I wanted something that could be implemented on top of a this simple concept, in my mind the system should’ve basically been a variation of a Kafka commit log, but instead of forwarding binary chunks to connected consumers (sendfile and how Kafka works internally is another quite interesting topic I explored and I’d like to dig into a bit more in the future); a time-series seemed interesting and fitting my initial thoughts.
The programming language to write it in was the next decision to make, I considered a couple of choices:
- Elixir/Erlang - a marvel, fantastic back-end language, the BEAM is a piece of art, I use Elixir daily at work though so I craved for something of a change my scenery
- Go - great design, simplicity and productivity like no others, also a bit boring too
- Rust - different beast from anything else, requires discipline and perseverance to retain productivity, I like it, but for it’s very nature and focus on safety, I always feel kinda constrained and fighting the compiler more than the problem I’m solving (gotta put some more effort into it to make it click completely). Will certainly pick it up again for something else in the future.
- C++ - Nope, this was a joke, didn’t consider this at all
Eventually I always find myself coming back to a place of comfort with C. I can’t really say the reason, it’s a powerful tool with many dangers and when I used it for my toy projects in the past, I almost never felt enough confidence to say “this could to be used in a prod environment”. I fought with myself on it for many years, learning Rust, flirting with Go, but I eventually gave up and embraced my comfort place. They’re great languages, I used Go for work for a while and Rust seems a good C++ replacement most of time, provided you use it with consistency (borrow checker and lifetimes can be hellish to deal and re-learn if out of shape).
With C, I reckon it all boils down to it’s compactness, conceptually simple and leaving very little to imagination of what’s happening under the hood. I’m perfectly conscious how easy it is to introduce memory-related bugs, and implementing the same dynamic array for each project can get old pretty fast; but what matters to me, ultimately, is having fun, and C provides me with that. Timeseries have always been a fascinating topic for me
Design
Conceptually it’s not really an efficient or particularly smart architecture, I approached the implementation on a best-effort way, the idea being to delay as much as possible the need of non-basic data-structures such as arrays.
The two main segments are just fixed size arrays where each position stores a pointer to the first position of a dynamic array. This to be able to store all the data points included in the time range that the segment covers
Each time a new record is to be inserted, first it is appended as a new entry in the Write-Ahead-Log (WAL) and only then it is store in the in-memory segment where it belongs. The WAL acts as a disaster recovery policy, it is solely responsible for storing incoming records in order to be able to read them and re-populate the segment on restarts.
In short:
- two segments of 15 minutes each
- each position represents a second, so 900 is the length of the segments
- each second points to a dynamic array storing positions based on the microsecond portion of the timestamp of the point
- each time a new point is inserted, it is first stored on a WAL on disk in order to be able to recover in case of crash
- once the main segment is full, or a timestamp too far in the future is received (i.e. more than 15 minutes past the 1st point store)
- the tail segment gets persisted on disk and becomes immutable, WAL is cleared
- the head segment gets persisted on disk and becomes immutable, WAL is cleared
- the head segment becomes the new tail segment
- a new head in-memory segment is generated
- immutable segments on disk are paired with an index file to read records from the past
Although there are plenty of improvements, fixes and other things to take care of, this function is roughly at the heart of the logic (there is a secondary logic in here for which we flush on disk also based on the WAL size, nothing of interest for the purpose of simplicity)
/*
* Set a record in a timeseries.
*
* This function sets a record with the specified timestamp and value in the
* given timeseries. The function handles the storage of records in memory and
* on disk to ensure data integrity and efficient usage of resources.
*
* @param ts A pointer to the Timeseries structure representing the timeseries.
* @param timestamp The timestamp of the record to be set, in nanoseconds.
* @param value The value of the record to be set.
* @return 0 on success, -1 on failure.
*/
int ts_insert(Timeseries *ts, uint64_t timestamp, double_t value) {
// Extract seconds and nanoseconds from timestamp
uint64_t sec = timestamp / (uint64_t)1e9;
uint64_t nsec = timestamp % (uint64_t)1e9;
char pathbuf[MAX_PATH_SIZE];
snprintf(pathbuf, sizeof(pathbuf), "%s/%s/%s", BASE_PATH, ts->db_data_path,
ts->name);
// if the limit is reached we dump the chunks into disk and create 2 new
// ones
if (wal_size(&ts->head.wal) >= TS_FLUSH_SIZE) {
uint64_t base = ts->prev.base_offset > 0 ? ts->prev.base_offset
: ts->head.base_offset;
size_t partition_nr = ts->partition_nr == 0 ? 0 : ts->partition_nr - 1;
if (ts->partitions[partition_nr].clog.base_timestamp < base) {
if (partition_init(&ts->partitions[ts->partition_nr], pathbuf,
base) < 0) {
return -1;
}
partition_nr = ts->partition_nr;
ts->partition_nr++;
}
// Dump chunks into disk and create new ones
if (partition_flush_chunk(&ts->partitions[partition_nr], &ts->prev) < 0)
return -1;
if (partition_flush_chunk(&ts->partitions[partition_nr], &ts->head) < 0)
return -1;
// Reset clean both head and prev in-memory chunks
ts_deinit(ts);
}
// Let it crash for now if the timestamp is out of bounds in the ooo
if (sec < ts->head.base_offset) {
// If the chunk is empty, it also means the base offset is 0, we set
// it here with the first record inserted
if (ts->prev.base_offset == 0)
ts_chunk_init(&ts->prev, pathbuf, sec, 0);
// Persist to disk for disaster recovery
wal_append_record(&ts->prev.wal, timestamp, value);
// If we successfully insert the record, we can return
if (ts_chunk_record_fit(&ts->prev, sec) == 0)
return ts_chunk_set_record(&ts->prev, sec, nsec, value);
}
if (ts->head.base_offset == 0)
ts_chunk_init(&ts->head, pathbuf, sec, 1);
// Persist to disk for disaster recovery
wal_append_record(&ts->head.wal, timestamp, value);
// Check if the timestamp is in range of the current chunk, otherwise
// create a new in-memory segment
if (ts_chunk_record_fit(&ts->head, sec) < 0) {
// Flush the prev chunk to persistence
if (partition_flush_chunk(&ts->partitions[ts->partition_nr],
&ts->prev) < 0)
return -1;
// Clean up the prev chunk and delete it's WAL
ts_chunk_destroy(&ts->prev);
wal_delete(&ts->prev.wal);
// Set the current head as new prev
ts->prev = ts->head;
// Reset the current head as new head
ts_chunk_destroy(&ts->head);
wal_delete(&ts->head.wal);
}
// Insert it into the head chunk
return ts_chunk_set_record(&ts->head, sec, nsec, value);
}
The current state
At the current stage of development, it’s still a very crude core set of features but it seems to be working as expected, with definitely many edge cases and assertions to solve; the heart of the DB is there, and can be built into a dynamic library to be used on a server. The repository can be found at https://github.com/codepr/roach.
Main features
- Fixed size records: to keep things simple each record is represented by just a timestamp with nanoseconds precision and a double
- In memory segments: Data is stored in time series format, allowing efficient querying and retrieval based on timestamp, with the last slice of data in memory, composed by two segments (currently covering 15 minutes of data each)
- The last 15 minutes of data
- The previous 15 minutes for records out of order, totaling 30 minutes
- Commit Log: Persistence is achieved using a commit log at the base, ensuring durability of data on disk.
- Write-Ahead Log (WAL): In-memory segments are managed using a write-ahead log, providing durability and recovery in case of crashes or failures.
What’s in the road map
- Duplicate points policy
- CRC32 of records for data integrity
- Adopt an arena for memory allocations
- Memory mapped indexes, above a threshold enable binary search
- Schema definitions
Timeseries library APIs
tsdb_init(1)
creates a new databasetsdb_close(1)
closes the databasets_create(3)
creates a new Timeseries in a given databasets_get(2)
retrieve an existing Timeseries from a databasets_insert(3)
inserts a new point into the Timeseriests_find(3)
finds a point inside the Timeseriests_range(4)
finds a range of points in the Timeseries, returning a vector with the resultsts_close(1)
closes a Timeseries
Writing a Makefile
A simple Makefile
to build the library as a .so
file that can be linked to any project as an external lightweight dependency or used alone.
CC=gcc
CFLAGS=-Wall -Wextra -Werror -Wunused -std=c11 -pedantic -ggdb -D_DEFAULT_SOURCE=200809L -Iinclude -Isrc
LDFLAGS=-L. -ltimeseries
LIB_SOURCES=src/timeseries.c src/partition.c src/wal.c src/disk_io.c src/binary.c src/logging.c src/persistent_index.c src/commit_log.c
LIB_OBJECTS=$(LIB_SOURCES:.c=.o)
libtimeseries.so: $(LIB_OBJECTS)
$(CC) -shared -o $@ $(LIB_OBJECTS)
%.o: %.c
$(CC) $(CFLAGS) -fPIC -c $< -o $@
clean:
@rm -f $(LIB_OBJECTS) libtimeseries.so
Building the library is a simple thing now, just a single command make
, to link it to a main, just a one liner
gcc -o my_project main.c -I/path/to/timeseries/include -L/path/to/timeseries -ltimeseries
LD_LIBRARY_PATH=/path/to/timeseries.so ./my_project
to run the main, a basic example of interaction with the library
#include "timeseries.h"
int main() {
// Initialize the database
Timeseries_DB *db = tsdb_init("testdb");
if (!db)
abort();
// Create a timeseries, retention is not implemented yet
Timeseries *ts = ts_create(db, "temperatures", 0, DP_IGNORE);
if (!ts)
abort();
// Insert records into the timeseries
ts_insert(&ts, 1710033421702081792, 25.5);
ts_insert(&ts, 1710033422047657984, 26.0);
// Find a record by timestamp
Record r;
int result = ts_find(&ts, 1710033422047657984, &r);
if (result == 0)
printf("Record found: timestamp=%lu, value=%.2lf\n", r.timestamp, r.value);
else
printf("Record not found.\n");
// Release the timeseries
ts_close(&ts);
// Close the database
tsdb_close(db);
return 0;
}
A server draft
Event based server (rely on ev at least initially), TCP as the main transport protocol, text-based custom protocol inspired by RESP but simpler:
$
string type!
error type#
array type:
integer type;
float type\r\n
delimiter
With the following encoding:
<type><length>\r\n<payload>\r\n
For example a simple hello string would be
$5\r\nHello\r\n
Simple query language
Definition of a simple, text-based format for clients to interact with the server, allowing them to send commands and receive responses.
Basic outline
- Text-Based Format: Use a text-based format where each command and response is represented as a single line of text.
- Commands: Define a set of commands that clients can send to the server to perform various operations such as inserting data, querying data, and managing the database.
- Responses: Define the format of responses that the server sends back to clients after processing commands. Responses should provide relevant information or acknowledge the completion of the requested operation.
Core commands
Define the basic operations in a SQL-like query language
-
CREATE creates a database or a timeseries
CREATE <database name>
CREATE <timeseries name> INTO <database name> [<retention period>] [<duplication policy>]
-
INSERT insertion of point(s) in a timeseries
INSERT <timeseries name> INTO <database name> <timestamp | *> <value>, ...
-
SELECT query a timeseries, selection of point(s) and aggregations
SELECT <timeseries name> FROM <database name> AT/RANGE <start_timestamp> TO <end_timestamp> WHERE value [>|<|=|<=|>=|!=] <literal> AGGREGATE [AVG|MIN|MAX] BY <literal>
-
DELETE delete a timeseries or a database
DELETE <database name> DELETE <timeseries name> FROM <database name>
Flow:
-
Client Sends Command: Clients send commands to the server in the specified text format.
-
Server Parses Command: The server parses the received command and executes the corresponding operation on the timeseries database.
-
Server Sends Response: After processing the command, the server sends a response back to the client indicating the result of the operation or providing requested data.
-
Client Processes Response: Clients receive and process the response from the server, handling success or error conditions accordingly.
Sol - An MQTT broker from scratch. Refactoring & eventloop
posted on 2019 Sep 25
UPDATE: 2020-02-07
In the previous 6 parts we explored a fair amount of common CS topics such as networks and data structures, the little journey ended up with a bugged but working toy to play with.
Sol - An MQTT broker from scratch. Part 6 - Handlers
posted on 2019 Mar 08
This part will focus on the implementation of the handlers, they will be mapped one-on-one with MQTT commands in an array, indexed by command type, making it trivial to call the correct function depending on the packet type.
Sol - An MQTT broker from scratch. Part 5 - Topic abstraction
posted on 2019 Mar 08
In the Part 4 we explored some useful concepts and implemented two data structures on top of those concepts.
Sol - An MQTT broker from scratch. Part 4 - Data structures
posted on 2019 Mar 07
Before proceeding to the implementation of all command handlers, we’re going to design and implement some of the most common data structures needed to the correct functioning of the server, namely hashtable, list and a trie.
Sol - An MQTT broker from scratch. Part 3 - Server
posted on 2019 Mar 06
This part deal with the implementation of the server part of our application, by
using the network
module we drafted on part-2
it should be relative easy to handle incoming commands from a MQTT clients respecting 3.1.1
standards as we defined on part 1.
Sol - An MQTT broker from scratch. Part 2 - Networking
posted on 2019 Mar 04
Let’s continue from where we left, in the part 1
we defined and roughly modeled the MQTT v3.1.1 protocol and our src/mqtt.c
module has now all unpacking functions, we must add the remaining build helpers
and the packing functions to serialize packet for output.
Sol - An MQTT broker from scratch. Part 1 - The protocol
posted on 2019 Mar 03
It’s been a while that for my daily work I deal with IoT architectures and research best patterns to develop such systems, including diving through standards and protocols like MQTT;