Overview
The Blizzard proyect is a solution to building multiplayer games in an authorative client-server architecture. This proyect consists of a Server and a Game Engine.
The proyect is still in development and very new, it is not a full-featured solution. As of now only the following are covered:
- ECS Game Engine (only data, no rendering)
- TCP authorative server
- Handling connections + disconnections
- Example client
There are many areas of improvement and for further development. The development roadmap can be seen in the next section!
Roadmap
There are many features missing in the proyect, here are some of the contemplated features for future development.
Server
- Cheating prevention
- Optimization
- Client prediction
- UDP server (now it's only TCP)
- Better communication protocols
- AI / ML enhancements
- Good logger
- Server metrics
- Game saving (like game recording...)
- Modularity (server customization)
- More examples and docs
Game Engine
- Improved ECS
- Math / physics library
- Out-of-the-box components for ECS
- Good logger
- Multi-platform support
- Window abstraction layer
- Renderer API (OpenGL or Vulkan)
- Modularity (engine customization)
- More examples and docs
See how to get started in the next section!
Getting Started
For a quick setup and seeing the game engine and server work together, try copying the example library in the GitHub repository. It is a library with two binaries, one for the server and one for the client example.
For now, there is not a quick "default" way to get started, because the Game and World need to be manually configured, so please see the learn section!
Server Architecture
The architecture decided for any game using this,is for the server to be an authorative. This means that the server receives user data, and decides how to update the game according to the inputs. After the update, it dispatches the updated game data to all players.
Client-Server Diagram
The server is missing many features like cheating prevention, player prediction, a better game loop, and metrics.
The server is made up of many parts:
- Connection listener (handles initial connections)
- Pool (Reference to connectors)
- Connector (Bridge between controller and pool)
- Controller (Handles application, player connections, message passing)
- Application (Runs the game, listens for controller messages)
Server Architecture Diagram
Connection Listener
When creating a server, it opens a port for listening to player connections. The server also creates a Game Pool
. When a player connects to the main TCP port of the server, the server tries to find an available game inside the Game Pool
. If it finds an available game, it returns the port number where the game is hosted (another TCP connection). If no game is found, it returns 0.
Pool
The Game Pool
creates as many games as specified in separate threads. It actually creates Connectors
. Each Connector
is like a wrapper around the Controller
. The Game Pool
stores each Connector
, so it can talk to the Server
about empty games, available ports, and so on.
Connector
The Connector
as said before, is like a wrapper around aController
. It just holds information like port number, max players and current player count. When a Connector
is created, it creates it's corresponding Controller
in a new thread.
Controller
The Controller
is where the most important things happen. When a Controller
is created, it opens a new TCP listener to a new port. It then runs the Application
on a separate thread, with capabilites to passing Messages
to it. When a player conencts to this new port, the Controller
will update the Connector
information as well as the Application
information. The Controller
opens two new threads for handling the player's input and also writing data back to the player. The Controller
serves as the entire communication controller between Application
and Client
.
Application
An Application
is a Network Application
made by the Blizzard Game Engine
. It handles the entire Game
logic as well as handling the player's inputs. More on this in the next section about the engine's architecture.
Engine Architecture
The engine is meant to be used with ECS architecture (Entity Component System). A game engine consists of many parts, which are not yet implemented. As of now, there is only:
- Core
- Logger
- Parsing
- Application Layer
- Event loop
- Network Input
- ECS
This is because the MVP was meant for integration with the server. This is the minimum needed for integration.
The future architecture will consist of more parts:
- Platform layer
- Multi-platform support
- File I/O
- Core
- Engine configuration
- Tests
- Math library
- System time
- Renderer
- OpenGL abstraction
- GUI
- Camera
- Sceneing
- Many other rendering goodies
- Application Layer
- Physics
- State machine
- Time
- Lifecycles
- Shutdown
- AI layer
- Tools
- Debugging
- Build system
Application
The Application
which as of now is only a Network Application
(although it can be used without it's networking capabilities), is where the most logic occurs.
It defines how fast to run the game, the order of function calling of the game, the game loop and how to pass messages and handle client input.
The application takes most importantly a Game
argument when creating one. The Game
follows ECS architecture. When creating a Game
, a World
is configured. A World
can have Entities
, Components
, and Systems
. The World
also accepts inputs from the Game
, which really means it accepts inputs from the Application
.
To understand better the ECS architecture implemented, check out the next section!
Learn
This is a tutorial to build the Example project in the GitHub repository. This will teach you the fundamentals to create your own TCP multiplayer data games using Blizzard's Game Engine and Server.
Configuration
Open a new terminal and create a new library crate:
cargo new my_game --lib
cd my_game
Inside the src
folder, create a bin
directory and add two files:
server.rs
client.rs
Make sure that each file has a main()
function!
This will be all there is to the folder and file structure.
Inside the root cargo.toml
file, add the following dependencies:
[dependencies]
serde = "1.0.13"
serde_json = "1.0"
serde_derive = "1.0"
blizzard-server = "0.1"
blizzard-engine = "0.1"
blizzard-engine_derive = "0.1"
Then install the dependencies / build the proyect in your terminal
cargo build
Your file structure should look like the following:
my_game/
src/
bin/
client.rs
server.rs
lib.rs
cargo.toml
cargo.lock
.gitignore
target/
Now let's make a simple multiplayer game where you can move a player's position!
World
The Blizzard Game Engine works with the Entity Component System architecture. The server runs a Game
, said game contains a World
, which contains all the Entities
and Components
. The World
also runs the Systems
, which process all of the Components
to run logic and update the game. You can read more about the ECS architecture online.
Import World
Inside the server.rs
file we will write all the game and server logic. At the top of the file add the following:
extern crate blizzard_engine;
use blizzard_engine::ecs::{World};
The first two line is for adding the blizzard engine to the binary. The second line is to use the World
trait definied for the ECS.
Create your world
Create your own world by creating any struct that implements Debug and Clone. Then simply apply the World
trait to your World:
#[derive(Debug, Clone)]
struct MyWorld {
}
impl World<Input> for MyWorld {
fn new() -> Self {
Self {
}
}
fn run_systems(&mut self, input: Input) {
// runs future systems
}
}
This won't compile yet! The World
trait takes a generic type I
, used for sending client (player) data to the world! Here we called it Input
. You can define input any way you like, it is meant to be flexible to however you like! For now, let's define Input
as the following tuple struct:
#[derive(Debug, Clone, Copy)]
struct Input(usize);
Next step: Creating Entities
and Components
!
Entities and Components
Hopefully by now you have read a little about Entities
and Components
from the ECS architecture.
Necessary imports
The Blizzard Engine comes with a default way of creating Entities
and Components
. Add the following to the top of your server.rs
file:
extern crate blizzard_engine_derive;
use blizzard_engine::ecs::{ComponentRegistry, EntityManager, World};
use blizzard_engine_derive::ComponentRegistry;
use std::collections::HashMap;
The top of your file should now look something like the following:
extern crate blizzard_engine;
extern crate blizzard_engine_derive;
use blizzard_engine::ecs::{ComponentRegistry, EntityManager, World};
use blizzard_engine_derive::ComponentRegistry;
use std::collections::HashMap;
Add Entities to your world
Adding entities to the world is very simple:
#[derive(Debug, Clone)]
struct MyWorld {
entity_manager: EntityManager,
}
impl World<Input> for MyWorld {
fn new() -> Self {
Self {
entity_manager: EntityManager::new(),
}
}
fn run_systems(&mut self, input: Input) {
// runs future systems
}
}
See the ecs
part of the Blizzard Game Engine docs to see all the methods available for adding and managing entities.
Let's add components to our World
.
Adding Components
Adding components is very easy, that's what the engine_derive
library is for, it provides a macro to generate components easily! Let's create two types of components, a Player
and a Position
:
#[derive(ComponentRegistry, Debug, Clone)]
struct PositionRegistry {
components: HashMap<u32, Position>,
}
#[derive(ComponentRegistry, Debug, Clone)]
struct PlayerRegistry {
components: HashMap<u32, usize>,
}
Our components are stored in a registry, hence the struct names end with "Registry". Components are stored inside a HashMap, the first key MUST be of type u32, since that is the UID that is associated to an Entity
when a Component
is created. The second key of the HashMap is of any type that is needed. This won't compile yet because Position
is not defined. Let's define it:
use std::ops::AddAssign;
#[derive(Debug, Clone, Copy)]
pub struct Position {
x: i32,
y: i32,
}
impl Position {
pub fn new() -> Self {
Self { x: 0, y: 0 }
}
pub fn displacement(x: i32, y: i32) -> Self {
Self { x, y }
}
}
impl AddAssign for Position {
fn add_assign(&mut self, other: Self) {
*self = Self {
x: self.x + other.x,
y: self.y + other.y,
};
}
}
Now the only thing left to do is add the component registries to the World
:
#[derive(Debug, Clone)]
struct MyWorld {
entity_manager: EntityManager,
positions: PositionRegistry,
players: PlayerRegistry,
}
Great! Let's now learn how to add Systems
in order to manipulate components.
Systems
Systems
are a way of changing Components
. Systems are very broad in this implementation, and can be pretty much defined any way you like. For this example, let's add a simple counter Component
and a System
that increments this counter.
#[derive(Debug, Clone)]
struct MyWorld {
entity_manager: EntityManager,
positions: PositionRegistry,
counters: CounterRegistry,
players: PlayerRegistry,
}
...
#[derive(ComponentRegistry, Debug, Clone)]
struct CounterRegistry {
components: HashMap<u32, u32>,
}
Creating a System
Systems have must process a specific Component
, hence their function signature must match the component:
fn counter_system(counters: &mut HashMap<u32, u32>) {
for (_, c) in counters.iter_mut() {
*c += 1;
}
}
This system will iterate all the counters inside the CounterRegistry and increment their value by one.
This system will not run if it is not added to the World
...
Adding Systems to the world
Addidng a System
is very easy, you just call the System
inside the World
's run_systems
function:
fn run_systems(&mut self, input: Input) {
// Systems to always run
counter_system(&mut self.counters.components);
}
The next step is to use the Game
trait to create your own game, which will be run on the server!
Game
The Game
is in charge of setting up the World
's initial state, defining how to update the World
, define global resources / state, setting up the input, and defining when the game should end.
To create your own Game
, it must be imported from the engine:
use blizzard_engine::game::Game;
Creating a Game
Game
is a trait. You can define your own game struct and implement this trait and also Clone:
#[derive(Clone)]
struct MyGame {
world: MyWorld,
counter: i32,
}
impl Game<SharedState, Input> for MyGame {
fn world_config(&mut self) {
}
fn update(&mut self, input: Input, shared_state: Arc<Mutex<SharedState>>) {
}
fn reset_input(&mut self, input: Arc<Mutex<Input>>) {
}
fn render(&mut self) {}
fn end_game(&self) -> bool {
false
}
}
The struct MyGame
has the world. We also defined another property called counter, just to show that you can create and customize your game however you wish.
The Game
trait has two generic types, the first is K
. K
is used for sharing information with the client, it is a way to define which data is sent off to the client. For now, we defined the generic K
as a struct called SharedState
. We will talk about the shared state later. For now, let's define it as an emtpy struct:
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct SharedState {
}
impl SharedState {
pub fn new() -> Self {
Self {
}
}
}
The shared state must implement these traits, hence we need to add Serde
to our proyect in order to serialize/deserialize the data sent. At the top of the file add the following:
#[macro_use]
extern crate serde_derive;
extern crate serde;
extern crate serde_json;
The second generic type is I
which was used before, we defined it as the Input
tuple struct.
If the function signatures are confusing, here is the generic definition:
pub trait Game<K, I> {
fn world_config(&mut self);
fn update(&mut self, input: I, shared_state: Arc<Mutex<K>>);
fn reset_input(&mut self, input: Arc<Mutex<I>>);
fn render(&mut self);
fn end_game(&self) -> bool;
}
Let's talk about each function and implement it in our game.
World Configuration
The world configuration function is the first function to be called when starting the game. It is useful to define initial states, entities and components. This function is only called once, at the beginning of the Game
lifecycle.
Let's define a basic initial entity with a counter component:
fn world_config(&mut self) {
// Create counter entity
let entities = self.world.entity_manager.create_n_entities(1);
// Add components to many entities
self.world.counters.add_many(&entities, 0);
}
Updating the world
After the world configuration is run, the game loop is started. The update function is the first function that is called in the game loop. It takes an argument of the input, however we are not using it now.
fn update(&mut self, input: Input, shared_state: Arc<Mutex<SharedState>>) {
// Update components
self.world.run_systems(input);
self.counter += 1;
}
This function should be used to tell the World
to run it's systems.
Reseting user input
After the update function is called, the reset_input function is called. For now we can leave it empty, but it is just a way to define how the input for the next loop should look like if no client input is detected.
fn reset_input(&mut self, input: Arc<Mutex<Input>>) {
}
Render
This function is called after updating and reseting the user input. It is meant to be called for rendering the game, however the engine does not have a Renderer API yet, so it is pretty much useless:
fn render(&mut self) {}
Ending the game
This function determines wheter the game should end or not. Here logic can be inserted to end the game. However, we will create an endless game:
fn end_game(&self) -> bool {
false
}
Game creator helper function
It is recommended to make a game creator helper function:
fn new_game(world: MyWorld) -> MyGame {
MyGame {
counter: 0,
world: world,
}
}
Perfect, we are almost ready to deploy our server, let's learn how to add start a server with this game!
TCP Server
For now, only TCP servers can be created. To make a server we need to add the following to the top of our server.rs
file:
use blizzard_server::server::Server;
use std::sync::mpsc::Receiver;
use std::sync::{Arc, Mutex};
The top should now look like this:
extern crate blizzard_engine;
extern crate blizzard_engine_derive;
use blizzard_engine::ecs::{ComponentRegistry, EntityManager, World};
use blizzard_engine::game::Game;
use blizzard_engine_derive::ComponentRegistry;
use blizzard_server::server::Server;
use std::collections::HashMap;
use std::sync::mpsc::Receiver;
use std::sync::{Arc, Mutex};
use std::ops::AddAssign;
Make the server
In the file's main
function (or create the function), we will configure our server. We need to define:
- Port: a port where our server will listen to incoming connections
- Max games
- Max players per game
- The game
- Shared state (to send to client)
- Client input type
- Rate at which data is sent to client (FPS or HZ)
- Rate at which the game updates (FPS or HZ)
- How to handle client input
The server's new
function has the following signature:
pub fn new<T: Game<K, I>, K, I, M>(
port: i32,
max_games: i32,
max_players: i32,
game: T,
shared_state: K,
input: I,
handle_input: &'static (dyn Fn(Receiver<(M, usize)>, Arc<Mutex<I>>) -> I + Sync),
send_data_rate: i32,
game_update_rate: i32,
) ...
The new type is M
, which is a type used for the Message
s sent from client to the game.
Let's create our basic configuration:
fn main() {
let port = 8888;
let max_games = 4;
let max_players = 2;
let world = MyWorld::new();
let shared_state = SharedState::new();
let game = new_game(world);
// The data that a client message will manipulate
let input_type = Input::default();
let hanlde_input = &handle_client_message;
// Engine speeds
let send_data_from_server_rate = 1;
let server_game_update_rate = 2; // 2 times per second
// Start server + games
Server::new(
port,
max_games,
max_players,
game,
shared_state,
input_type,
hanlde_input,
send_data_from_server_rate,
server_game_update_rate,
);
}
We have a couple of missing definitions, let's create them.
Input:
#[derive(Debug, Clone, Copy)]
struct Input(Message, usize);
impl Input {
fn default() -> Self {
Self(Message::None, 0)
}
fn from(m: Message, id: usize) -> Self {
Self(m, id)
}
}
We are changing the input to contain a Message
, and a number of type usize
. This second one is for identification, it will be passed by the client to identify the player generating the Input
.
Message:
#[derive(Serialize, Deserialize, Clone, Copy, Debug)]
pub enum Message {
None,
W,
A,
S,
D,
AddPlayer,
RemovePlayer,
}
SharedState:
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct SharedState {
pub counters: Vec<u32>,
pub registry: Vec<Position>,
}
impl SharedState {
pub fn new() -> Self {
Self {
registry: vec![],
counters: vec![],
}
}
}
The shared state will be to show the counters and the positions of players.
Handle client message:
fn handle_client_message(receiver: Receiver<(Message, usize)>, input: Arc<Mutex<Input>>) -> Input {
for (message, id) in receiver {
println!("Player {} called {:?}", id, message);
*input.lock().unwrap() = Input::from(message, id);
}
Input::default()
}
This is an interesting code bit. To handle a client message, when creating the server, an Application
is created which wraps the Game
, and is in charge for calling the game loop and also connecting all inputs and communications. Hence this function is to handle inputs. It actually handles messages. A Message
will be sent from the client. The Receiver
will receive the Message
and decide what Input
to generate based on the message. For now, we are just passing the message on to the Input
which will be used in the update_systems function inside the World
. The Input
is wrapped in a Arc and Mutex.
Adding game logic
Let's make some systems that will be used.
Adding a player entity, with a player component and position:
fn add_player_system(world: &mut MyWorld, player_id: usize) {
let ent = world.entity_manager.create_entity();
world.players.add(ent, player_id);
world.positions.add(ent, Position::new());
world.player_id_map.players.insert(player_id, ent);
}
When a player joins a game, they get a UID defined by the server. However, the World
has no way to know which entity belongs to a player. Hence we created the PlayerIdMap
:
#[derive(Debug, Clone)]
struct MyWorld {
entity_manager: EntityManager,
positions: PositionRegistry,
counters: CounterRegistry,
players: PlayerRegistry,
player_id_map: PlayerIdMap,
}
impl World<Input> for MyWorld {
fn new() -> Self {
Self {
entity_manager: EntityManager::new(),
positions: PositionRegistry::new(),
counters: CounterRegistry::new(),
players: PlayerRegistry::new(),
player_id_map: PlayerIdMap::new(),
}
}
fn run_systems(&mut self, input: Input) {
...
}
}
#[derive(Debug, Clone)]
struct PlayerIdMap {
players: HashMap<usize, u32>,
}
impl PlayerIdMap {
fn new() -> Self {
Self {
players: HashMap::new(),
}
}
}
Updating player position system:
fn update_player_pos_system(world: &mut MyWorld, player_id: usize, displacement: Position) {
if let Some(ent) = world.player_id_map.players.get(&player_id) {
*world
.positions
.components
.entry(*ent)
.or_insert(displacement) += displacement;
}
}
Removing a player system:
fn remove_player_system(world: &mut MyWorld, player_id: usize) {
if let Some(ent) = world.player_id_map.players.get(&player_id) {
world.players.components.remove(ent);
world.positions.components.remove(ent);
world.entity_manager.remove_entity(*ent);
world.player_id_map.players.remove(&player_id);
}
}
Updating games using run_systems and input
We can now do a lot of basic functionality using this. Let's add our systems to our run_systems:
fn run_systems(&mut self, input: Input) {
// Systems to run conditionally
match input.0 {
Message::AddPlayer => add_player_system(self, input.1),
Message::W => {
update_player_pos_system(self, input.1, Position::displacement(0, 1));
}
Message::A => {
update_player_pos_system(self, input.1, Position::displacement(-1, 0));
}
Message::S => {
update_player_pos_system(self, input.1, Position::displacement(0, -1));
}
Message::D => {
update_player_pos_system(self, input.1, Position::displacement(1, 0));
}
Message::RemovePlayer => {
remove_player_system(self, input.1);
}
_ => {}
}
// Systems to always run
counter_system(&mut self.counters.components);
}
Defining the data to be sent to the client
Inside the Game
's update function, we can now copy the desired data over to the SharedState
, which is sent off to the client:
fn update(&mut self, input: Input, shared_state: Arc<Mutex<SharedState>>) {
// Update states
self.world.run_systems(input);
self.counter += 1;
// Update shared state: for client reception
shared_state.lock().unwrap().counters = self
.world
.counters
.components
.iter()
.map(|(_, counter)| *counter)
.collect();
shared_state.lock().unwrap().registry = self
.world
.positions
.components
.iter()
.map(|(_, positions)| *positions)
.collect();
}
Here we are just copying the data from the counter and position components into vectors of information.
Next steps
This is everything! You have created your own multiplayer game server! You can start the server in your terminal by typing:
cargo run --bin server
You will see a couple of logs from the server. However nothing exciting really happens. This is because there are no players connecting to any games! Let's now write a client that connects to a game. We will be writing the client inside the client.rs
file. However, now we will use the library we created, my_game
. The library is a suggestion, because many of the client's data structs are the same as the game's. For example:
Message
SharedState
Position
Let's write a client that connects to our server.
Client
The client will be a terminal based client.
We will move the following on to our my_game
library:
Message
SharedState
Position
MyGame library
Inside the lib.rs file:
#[macro_use]
extern crate serde_derive;
extern crate serde;
extern crate serde_json;
use std::ops::AddAssign;
// Message definition
#[derive(Serialize, Deserialize, Clone, Copy, Debug)]
pub enum Message {
None,
W,
A,
S,
D,
AddPlayer,
RemovePlayer,
}
// Shared state definition
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct SharedState {
pub counters: Vec<u32>,
pub registry: Vec<Position>,
}
impl SharedState {
pub fn new() -> Self {
Self {
registry: vec![],
counters: vec![],
}
}
}
// Position component
#[derive(Serialize, Deserialize, Debug, Clone, Copy)]
pub struct Position {
x: i32,
y: i32,
}
impl Position {
pub fn new() -> Self {
Self { x: 0, y: 0 }
}
pub fn displacement(x: i32, y: i32) -> Self {
Self { x, y }
}
}
impl AddAssign for Position {
fn add_assign(&mut self, other: Self) {
*self = Self {
x: self.x + other.x,
y: self.y + other.y,
};
}
}
Let's update our server.rs
by importing these definitions and removing the old ones that were in the file. Inside: server.rs
:
extern crate my_game;
use example::{Message, Position, SharedState};
The beggining of your server.rs
file should now look like the following:
extern crate blizzard_engine;
extern crate blizzard_engine_derive;
extern crate my_game;
use blizzard_engine::ecs::{ComponentRegistry, EntityManager, World};
use blizzard_engine::game::Game;
use blizzard_engine_derive::ComponentRegistry;
use blizzard_server::server::Server;
use std::collections::HashMap;
use std::sync::mpsc::Receiver;
use std::sync::{Arc, Mutex};
use example::{Message, Position, SharedState};
Creating our client
Now we are ready to make our client!
In the beggining of the file write the following:
extern crate example;
use example::Message;
use example::SharedState;
use std::io::{self, BufRead, BufReader, Write};
use std::net::{Shutdown, TcpStream};
use std::str;
use std::sync::{Arc, Mutex};
use std::thread;
struct Client {}
Our Client
struct will be used to define related functionality.
The way the server works is the following:
- Server is started listening to a TCP port
- Server creates threads based on how many games were specified
- Server opens different TCP listeners (on different ports) per game
When a client connects to the server, the following happens:
- Server looks for an available game (based of player capacity)
- If it finds a game, it returns the TCP port of said game
- In none are found, the port returned is 0
Based on this we can create a basic client that tries to connect to the server and receive a port number.
impl Client {
fn start() {
let mut stream = TcpStream::connect("0.0.0.0:8888").expect("Could not connect to server");
let port: i32;
let mut input = String::new();
let mut buffer: Vec<u8> = Vec::new();
println!("Enter your username: ");
io::stdin()
.read_line(&mut input)
.expect("Failed to read from stdin");
println!("Finding an available lobby...");
stream
.write(input.as_bytes())
.expect("Failed to write to server");
let mut reader = BufReader::new(&stream);
reader
.read_until(b'\n', &mut buffer)
.expect("Could not read into buffer");
port = str::from_utf8(&buffer)
.expect("Could not write buffer as string")
.replace("\n", "")
.parse()
.expect("Could not parse port");
if port == 0 {
println!("No game available, please try again later");
return;
}
stream
.shutdown(Shutdown::Both)
.expect("Could not disconnect from original server");
let tcp = format!("0.0.0.0:{}", port);
Client::run_game(tcp);
}
}
The server needs to receive an input in order to acknowledge that a player wants to connect, hence we are defining a username
, but it will actually never be read. If no game is found, a port of 0 will be returned. We can check this to see if the client should continue to try and connect. At the end, Client::run_game(tcp)
starts the game connection. Let's see how we can define such game.
Implementing player control
fn run_game(tcp: String) {
// Try to connect to game
let mut stream = TcpStream::connect(tcp).expect("Could not connect to server");
let stream_clone = stream.try_clone().unwrap();
// Tell the server to add a player
let data = Message::AddPlayer;
let json = serde_json::to_string(&data).unwrap() + "\n";
let should_close = Arc::new(Mutex::new(false));
let should_close_copy = Arc::clone(&should_close);
// Write add player message
stream
.write(json.as_bytes())
.expect("Failed to write to server");
println!("data written");
// User Input thread
thread::spawn(move || {
let shoud_close = should_close_copy;
loop {
let mut input = String::new();
io::stdin()
.read_line(&mut input)
.expect("Failed to read from stdin");
let input = input.trim();
let mut data = Message::None;
// Player controls
if input == "w" {
data = Message::W;
} else if input == "a" {
data = Message::A;
} else if input == "s" {
data = Message::S;
} else if input == "d" {
data = Message::D;
} else if input == "close" {
data = Message::RemovePlayer;
*shoud_close.lock().unwrap() = true;
}
let json = serde_json::to_string(&data).unwrap() + "\n";
stream
.write(json.as_bytes())
.expect("Failed to write to server");
}
});
// Stream Reader thread
thread::spawn(move || {
let stream = stream_clone;
loop {
let mut buffer: Vec<u8> = Vec::new();
let mut reader = BufReader::new(&stream);
reader
.read_until(b'\n', &mut buffer)
.expect("Could not read into buffer");
let json = str::from_utf8(&buffer).unwrap();
let state: SharedState = serde_json::from_str(&json).unwrap();
// Print shared state!
println!("{:?}", state);
}
});
// Keep thread alive, so TCP connection on other threads doesn't reset
loop {
if *should_close.lock().unwrap() {
return;
}
}
}
Running everything togehter
In a terminal, start the server:
cargo run --bin server
In a new terminal, start a client:
cargo run --bin client
You should input your username, and the game will connect. You should then see positions logged to the console. Try writing to the server "w", "a", "s", "d", or "close". What happens?
Now without closing the current client, open y a 3rd terminal and connect another player!
What happens if you connect a 4th player? What happens if you keep going until all games are full? Find out!
GitHub repo
All of this code is the same code from the Example library in the official GitHub repository. You can check it out if anything is not working properly.
Congratulations!
You just made your own very basic multiplayer game! Many other languages and frameworks have TCP connections. For example C#
has a TCP class. You could connect your server to a Unity
game! The possibilities are endless.
Origin
This proyect started as my curiosity to develop a multiplayer game. I ended up deciding to write my own game server using Rust. After some succesfull connections and data passing between threads, I realised that the server needed a game engine in order to implement an authorative client-server achitecture. This gave birth to the Blizzard Game Engine and Server Engine! If you are curious about my learning process and development process, please see the development process section.
Development Process
Overview
When starting this project, I really had no idea of what I was doing. I did a lot of researching, reading, book finding, and video watching. I needed to learn the following:
- Networking
- TCP & UDP networking
- Data serialization
- Rust
- Threads
- Safe types (Arc, Mutex)
- Generics
- Traits
- Type traits
- Macros (Procedural and Derivative)
- Closures
- Iterators
- Packages and crates
- Pretty much everything from
The-Book
except lifetimes
- Game engine architectures
- Game server architectures
Recap
I first developed the TCP server. After good data passing between threads and handling connections, I realized I needed a sort of game engine running with my server, so it could be authorative. I ended up also making a small game engine.
Resources
Here are some resources I strongly recommend (they aren't even half of what I read and watched):
- Rust
- Networking & Serialization
- Book: "Network Programming with Rust" - Abhishek Chanda
- ECS
- Paper: "ECS Game Engine Design" - Daniel Hall
- Game Engine YouTube series