-
Notifications
You must be signed in to change notification settings - Fork 2
The GameManager Microservice
| yovi_en2a/gameManager
The purpose of this microservice is to connect the different components of the architecture, managing all the communication game-frontend-dabatase.
Data.rs is a module whose sole purpose is to contain the basic structures we are going to work with. It provides a common trait called DBData that requires bot Serialize and Deserialize from the serde library and 'SyncandSend`. This ensures that its implementations have this properties which are required for using firestore with FluentAPI propperly.
Match strucuture mimetizing the firestore database entries. We had to make public the YEN notation from gamey(The Game Engine) so that we could use it here.
`data.rs`
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Match{
pub player1id: String,
pub player2id: String,
pub result: String,
pub board_status:YEN
}
impl DBData for Match {}In the firebase.rsfile we detail read and write generic connections to be specified by concrete functions.
For this method we use a Generic type T which we enforce it to be an implementation of our previously defined 'DBData' with the following syntax:
`firebase.rs`
for<'de> T: DBDataThen we get a connection from a defined method get_connection() which we await given that it is asynchronous and we use ? to ensure an Ok Response and try to retrieve the data using FluentAPI.
`firebase.rs`
// Get the connection
let db = get_connection().await?;
// Use the Fluent API to find the document.
// It returns an Option because the document might not exist.
let object: Option<T> = db.fluent()
.select()
.by_id_in(table_name)
.obj()
.one(id)
.await?;Finally we return a Result with a DBData or an Error depending of if the value is found or not.
`firebase.rs`
match object {
Some(data) => Ok(data),
None => Err(format!("Document with ID {} not found into {}", id, table_name).into()),
}For this method we use a Generic type T which we enforce it to be an implementation of our previously defined 'DBData' with the following syntax:
`firebase.rs`
for<'de> T: DBDataThen we get a connection from a defined method get_connection() which we await given that it is asynchronous and we use ? to ensure an Ok Response and try to retrieve the data using FluentAPI.
`firebase.rs`
// Get the connection
let db = get_connection().await?;
// Perform the insertion using the Fluent API.
// We use .execute::<()>() because we don't really need the return object here.
db.fluent()
.insert()
.into(table_name)
.document_id(id)
.object(data)
.execute::<()>()
.await?;Finally we verify if the insertion was correct and return a Result with an Ok or an Error depending of if the value is correctly inserted or not.
`firebase.rs`
// Post-insertion check.
// I'm calling read_db here just to be 100% sure the write worked.
// If read_db returns an error, the '?' will propagate it.
read_db::<T>(table_name, id).await?;
Ok(())Here I will not include all of the implementations but only a couple of them to illustrate how all are done.
For starters we have an implementation for read_db:
pub async fn get_match_by_id(id: &str) -> Result<Match, Box<dyn Error>> {
let match_data: Match = read_db("Match", id).await?;
Ok(match_data)
}As for insert_db:
pub async fn insert_match_by_id(id: &str, match_data: Match) -> Result<(), Box<dyn Error>> {
insert_db("Match", id, match_data).await?;
}As of now, the firestore library for rust has migrated to us rustls (a rust implementation of the TLS protocol), making communications more secure. Said library works really well but it needs quite the configuration since it does not come with the "cyphering" motor (Crypto Provider) by default so we have to configure it to use the one we preffer. To do so we have the following code:
We start defining a static INIT_CRYPTO as Once, so it is called only Once during the code execution. Other ways could have been used but this was the faster approach.
`firebase.rs`
static INIT_CRYPTO: Once = Once::new();Then we check whether INIT_CRYPTO was already called or not and if it was not the case, we initialize our crypto provider for rustls
`firebase.rs`
INIT_CRYPTO.call_once(|| {
let provider = rustls::crypto::ring::default_provider();
let _ = provider.install_default();
println!("INFO: Global CryptoProvider (Ring) installed.");
});rustls has two main options for this purpose: Ring and AWS-L, but why we chose Ring? Here are its advantages:
-
The Community Standard Ring is the most mature and widely used crypto library in the Rust ecosystem. Ring is the "path of least resistance." It is battle-tested and has the most documentation available.
-
Performance & Efficiency Ring has been optimized in assembly code for common CPU architectures, meaning:
- Faster Handshakes: Connecting to Google Cloud happens in milliseconds.
- Low Overhead: It doesn't consume unnecessary CPU cycles, which is critical for cloud functions or high-traffic backends.
-
"Security by Default" Unlike older libraries (like OpenSSL) that support insecure protocols, Ring only implements secure algorithms (e.g., AES-GCM, ChaCha20, Ed25519). This prevents "downgrade attacks".
-
Cross-Platform Stability Since we will deploy our app on Linux (Docker) but code in Windows, Ring compiles and runs predictably across both of them without needing complex external C libraries installed on the host system.
In server.rs we define the Axum HTTP server, all route handlers, and the shared application state. It acts as the central orchestrator, connecting the frontend, Redis, the game engine (gamey), and Firebase.
All route handlers share a common state wrapped in an Arc (Atomic Reference Counter) for safe concurrent access:
`server.rs`
#[derive(Clone)]
pub struct AppState {
pub redis_pool: redis_client::RedisPool,
pub gamey_url: String,
}The Arc<AppState> is built at startup from environment variables and passed into the Axum router:
`server.rs`
let state = Arc::new(AppState {
redis_pool: pool,
gamey_url,
});The run() function bootstraps the entire service. It reads Redis and game engine configuration from environment variables, builds the shared state, registers all routes, and starts listening on port 5000:
`server.rs`
let redis_url = format!("redis://{}:{}/", redis_host, redis_port);
let pool = redis_client::create_pool(&redis_url).await;If environment variables REDIS_HOST, REDIS_PORT, or GAMEY are not set, they fall back to 127.0.0.1, 6379, and localhost respectively.
| Method | Path | Handler | Description |
|---|---|---|---|
POST |
/new |
create_match |
Creates a new game in Redis |
POST |
/executeMove |
execute_move |
Validates and applies a player move |
POST |
/reqBotMove |
request_bot_move |
Requests a move from the bot engine |
POST |
/localRankings |
get_local_rankings |
Fetches a user's match history from Firebase |
GET |
/bestTimes |
get_best_times |
Fetches the global time rankings from Firebase |
POST |
/updateScore |
update_user_score |
Updates a player's score in Firebase |
POST |
/saveMatch |
save_match |
Persists a finished match to Firebase |
GET |
/debug/redis |
dump_redis |
Debug endpoint dumping all Redis keys |
Generates a new UUID for the match, then delegates to redis_client::create_match to initialize the board state and player list in Redis:
`server.rs`
let new_id = Uuid::new_v4().to_string();
let _ = redis_client::create_match(&state.redis_pool, &new_id, &payload.size, &payload.player1, &payload.player2).await;
Json(NewMatchResponse { match_id: new_id })This is the core game loop handler. It follows a strict sequence:
- Fetch the current game state JSON from Redis.
-
Forward the state and move coordinates to the
gameyengine atPOST /engine/move. -
Validate the engine response — if the move is illegal, a
400 Bad Requestis returned immediately. - Persist the updated state back to Redis.
-
Respond to the frontend with
game_overstatus.
`server.rs`
let engine_payload = serde_json::json!({
"state": current_yen,
"x": payload.coord_x,
"y": payload.coord_y,
"z": payload.coord_z
});Requests the bot engine to compute and apply a move. Before reading from Redis, it waits for any active distributed lock on the match to be released (up to 20 retries × 50ms), ensuring it never reads a state mid-write:
`server.rs`
for _ in 0..20 {
let exists: bool = redis::cmd("EXISTS")
.arg(&lock_key)
.query_async(&mut *conn)
.await
.unwrap_or(false);
if !exists { break; }
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}The bot ID is retrieved from Redis via get_match_players, then the current state is forwarded to gamey at POST /v1/ybot/play/{bot_id}. The resulting state is saved back to Redis.
Finalizes a completed match by reading the last board state from Redis, constructing a Match struct, and persisting it permanently to Firebase:
`server.rs`
let match_data = Match {
player1id: payload.player1id,
player2id: payload.player2id,
result: payload.result,
board_status,
time: payload.time,
};
crate::firebase::insert_match_by_id(&payload.match_id, match_data).await?;Fetches all stored matches for a given user from Firebase. Errors are caught explicitly and logged to Docker's stderr, returning an empty list to the frontend rather than crashing:
`server.rs`
Err(error) => {
eprintln!("Error reading firestore (User: {}): {:?}", payload.user_id, error);
vec![]
}A development utility endpoint that scans all keys in Redis and returns their values as a JSON object. It should be disabled or protected in production environments.