-
Notifications
You must be signed in to change notification settings - Fork 2
The GameManager Microservice
yovi_en2a/game_manager
The purpose of this microservice is to act as the central orchestrator of the game architecture: it manages game sessions in Redis, drives the game engine (gamey), persists completed matches and player scores in Firestore, and exposes an HTTP API (port 5000) that the frontend communicates with through the API Gateway.
- data.rs — Data Structures
- firebase.rs — Firestore Connection
- redis_client.rs — Redis Session Layer
- api_rest.rs — HTTP Server & Route Handlers
data.rs is the data layer of the module. It defines every structure that flows between the HTTP API, the business logic, Redis, and Firestore, as well as the shared DBData trait.
DBData is a marker trait that bundles the constraints required to work with Firestore's Fluent API:
pub trait DBData: Serialize + for<'de> Deserialize<'de> + Debug + Send + Sync {}Any struct that implements DBData can be passed to the generic read_db and insert_db functions in firebase.rs. This keeps the database layer fully generic — it never needs to know the concrete type it is reading or writing.
YEN is the canonical board-state representation used throughout the system. It is defined here in data.rs and serialized as JSON when stored in Redis or sent to the game engine.
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct YEN {
size: u32, // Length of one side of the triangular board
turn: u32, // Index of the player whose turn it is (0-indexed)
players: Vec<char>, // Character symbols for each player, e.g. ['B', 'R']
layout: String, // Compact board string, rows separated by '/'
variant: Option<String>, // None = standard rules; "why_not" = misère
}The variant field is omitted from serialization when None (skip_serializing_if), keeping the JSON compact for the common case.
The persisted representation of a completed game in Firestore.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Match {
pub player1id: String,
pub player2id: String,
pub result: String,
pub board_status: YEN, // Final board state
pub time: f32, // Match duration in seconds
pub moves: Vec<Coordinates>, // Full move history
pub created_at: u64, // Unix timestamp (seconds)
}
impl DBData for Match {}The persisted representation of a player's statistics in the Scores Firestore collection.
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Score {
pub playerid: String,
pub username: String,
pub total_matches: i32,
pub wins: i32,
pub losses: i32,
pub win_rate: f32,
pub elo: i32,
pub best_time: f32,
}
impl DBData for Score {}A three-dimensional board position used for move requests and the move history log.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct Coordinates {
pub x: u32,
pub y: u32,
pub z: u32,
}| Struct | Direction | Fields |
|---|---|---|
NewMatchRequest |
Inbound (POST /new) |
player1, player2, size, variant?
|
NewMatchResponse |
Outbound | match_id |
MoveRequest |
Inbound (POST /executeMove) |
match_id, coord_x, coord_y, coord_z
|
MoveResponse |
Outbound |
match_id, game_over
|
EngineMoveRequest |
Inbound (POST /reqBotMove) |
match_id |
EngineMoveResponse |
Outbound |
coordinates, game_over
|
SaveMatchRequest |
Inbound (POST /saveMatch) |
match_id, player1id, player2id, result, time, moves?
|
SaveMatchResponse |
Outbound | message |
LocalRankingsRequest |
Inbound (POST /localRankings) |
user_id |
LocalRankingsResponse |
Outbound |
matches (Vec<Match>) |
RankingTimeResponse |
Outbound |
rankings (Vec<Score>) |
UpdateScoreRequest |
Inbound (POST /updateScore) |
playerid, username, is_win, time
|
UpdateScoreResponse |
Outbound | message |
EngineResponse |
Internal (from gamey /engine/move) |
new_yen_json, game_over
|
PlayResponse |
Internal (from gamey /v1/ybot/play/{id}) |
api_version, bot_id, coords, position, game_over, winner?
|
firebase.rs provides generic CRUD helpers and concrete functions for the Match and Scores collections.
The firestore crate uses rustls for TLS. Since Rust 1.x, rustls no longer ships with a default crypto provider, so one must be installed at startup. A Once guard ensures this happens exactly once, no matter how many times get_connection() is called concurrently:
static INIT_CRYPTO: Once = Once::new();
INIT_CRYPTO.call_once(|| {
let provider = rustls::crypto::ring::default_provider();
let _ = provider.install_default();
});Why Ring? It is the most mature Rust crypto library, is optimized at the assembly level for common CPU architectures, supports only secure algorithms, and compiles reliably across Linux (Docker) and Windows.
The FIREBASE_PROJECT_ID environment variable is read at connection time — if it is not set, get_connection() returns an error immediately.
Fetches a single document from a Firestore collection and deserializes it into any type that implements DBData.
pub async fn read_db<T>(table_name: &str, id: &str) -> Result<T, Box<dyn Error>>
where
for<'de> T: DBDataInternally it opens a connection, queries using the Fluent API, and maps the Option<T> result into a Result, returning an error if the document does not exist:
let object: Option<T> = db.fluent()
.select()
.by_id_in(table_name)
.obj()
.one(id)
.await?;
match object {
Some(data) => Ok(data),
None => Err(format!("Document with ID {} not found into {}", id, table_name).into()),
}Inserts a document and then immediately reads it back to verify the write was durable. If the post-insertion read fails, the error is propagated to the caller.
db.fluent()
.insert()
.into(table_name)
.document_id(id)
.object(data)
.execute::<()>()
.await?;
read_db::<T>(table_name, id).await?; // Verify the write succeededConcrete shorthands for the Match collection:
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)
}
pub async fn insert_match_by_id(id: &str, match_data: Match) -> Result<(), Box<dyn Error>> {
insert_db("Match", id, &match_data).await?;
Ok(())
}Fetches all matches played by a given user. Because Firestore does not support OR queries natively via the Fluent API, two separate queries are issued — one filtering by player1id and one by player2id — then the results are merged and sorted chronologically by created_at:
pub async fn get_user_matches(user_id: &str) -> Result<Vec<Match>, Box<dyn Error>>Fetches the top 20 entries from the Scores collection ordered by best_time ascending (lowest time wins):
pub async fn get_ranking_time() -> Result<Vec<Score>, Box<dyn Error>>Updates a player's Score document at the end of a match. It searches the Scores collection by playerid field. If an entry exists it is updated in place; if not, insert_score is called to create it from scratch.
Update logic:
-
total_matchesincrements by 1 every call. - A win adds 20 elo and increments
wins; a loss subtracts 15 elo (floor 0) and incrementslosses. -
best_timeis only overwritten if the new time is lower, or if it was 0. -
win_rateis recalculated aswins / total_matchesafter every update.
Creates a brand-new Score document for a player who has just played their first match. Called internally by update_score when no existing record is found.
redis_client.rs manages all in-memory game state using a bb8 connection pool over Redis. Every active match is stored as a JSON string keyed by match:{id}, with a 1-hour TTL. Player associations are stored separately under match:{id}:players.
pub type RedisPool = bb8::Pool<RedisConnectionManager>;
pub async fn create_pool(redis_url: &str) -> RedisPoolThe pool is created at startup in api_rest.rs and injected into all route handlers via AppState.
Write operations on a match state use a Redis-level distributed lock to prevent race conditions between concurrent requests (e.g. a human move arriving while the bot is still computing).
// Acquire: SET lock:match:{id} "locked" NX EX {ttl}
// Returns true if the lock was acquired, false if already held
pub async fn acquire_lock(pool: &RedisPool, match_id: &str, ttl_secs: u64) -> Result<bool, MatchError>
// Release: DEL lock:match:{id}
pub async fn release_lock(pool: &RedisPool, match_id: &str) -> Result<(), MatchError>// Initializes the board, saves the initial YEN state, and records the players
pub async fn create_match(pool, match_id, size, player1, player2, variant) -> Result<(), MatchError>
// Reads the current YEN state as a raw JSON string
pub async fn get_match_state(pool: &RedisPool, match_id: &str) -> Result<String, MatchError>
// Acquires the distributed lock, writes the new state with a 1h TTL, then releases the lock
// Returns LockTimeout if the lock cannot be acquired after 10 retries × 50ms
pub async fn save_match_state(pool: &RedisPool, match_id: &str, state_json: String) -> Result<(), MatchError>
// Saves player IDs as "player1:player2" under match:{id}:players
pub async fn save_match_players(pool, match_id, player1, player2) -> Result<(), MatchError>
// Returns (player1, player2) by splitting the stored "player1:player2" string
pub async fn get_match_players(pool: &RedisPool, match_id: &str) -> Result<(String, String), MatchError>A typed error enum that wraps the three failure modes of the Redis layer:
| Variant | Cause |
|---|---|
Redis(RedisError) |
Network or command failure |
Serialization(serde_json::Error) |
JSON encode/decode failure |
Pool |
Could not acquire a connection from the pool |
LockTimeout |
Lock not acquired after the maximum retry count |
api_rest.rs defines the Axum HTTP server, all route handlers, and the shared application state. It binds to port 5000 and acts as the central orchestrator, connecting the frontend, Redis, the game engine (gamey), and Firestore.
All route handlers share a common state wrapped in an Arc for safe concurrent access:
#[derive(Clone)]
pub struct AppState {
pub redis_pool: redis_client::RedisPool,
pub gamey_url: String,
}gamey_url is derived from the GAMEY environment variable (defaults to localhost) and formatted as http://{host}:4000.
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:
let redis_url = format!("redis://{}:{}/", redis_host, redis_port);
let pool = redis_client::create_pool(&redis_url).await;| Variable | Default | Description |
|---|---|---|
REDIS_HOST |
127.0.0.1 |
Redis host |
REDIS_PORT |
6379 |
Redis port |
GAMEY |
localhost |
Hostname of the game engine container |
FIREBASE_PROJECT_ID |
— | Required; read by firebase::get_connection()
|
| Method | Path | Handler | Description |
|---|---|---|---|
POST |
/new |
create_match |
Creates a new game session 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 Firestore |
GET |
/bestTimes |
get_best_times |
Fetches the global top-20 time rankings from Firestore |
POST |
/updateScore |
update_user_score |
Updates a player's score in Firestore |
POST |
/saveMatch |
save_match |
Persists a finished match to Firestore |
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 build the initial YEN board state and store both the board and the player list in Redis:
let new_id = Uuid::new_v4().to_string();
let _ = redis_client::create_match(
&state.redis_pool, &new_id, &payload.size,
&payload.player1, &payload.player2, payload.variant
).await;
Json(NewMatchResponse { match_id: new_id })The core game loop handler. It follows a strict sequence:
1. Fetch the current game state JSON from Redis.
2. Deserialize it into a serde_json::Value.
3. Forward the state and move coordinates to gamey at POST /engine/move.
4. Validate the engine response — if the move is illegal, return 400 Bad Request.
5. Persist the updated YEN state back to Redis via the distributed lock.
6. Respond to the frontend with match_id and game_over status.
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 the match state from Redis, it polls for any active distributed lock (up to 20 retries × 50ms), ensuring it never reads a state that is mid-write:
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 response is a PlayResponse, which includes the bot's chosen coordinates and the updated board state (position). The new state is saved back to Redis.
Finalizes a completed match. It reads the last board state from Redis, stamps a created_at Unix timestamp, constructs a full Match struct (including the move history), and persists it to Firestore:
let match_data = Match {
player1id: payload.player1id,
player2id: payload.player2id,
result: payload.result,
board_status,
time: payload.time,
moves: payload.moves,
created_at,
};
crate::firebase::insert_match_by_id(&payload.match_id, match_data).await?;Fetches all stored matches for a given user from Firestore via get_user_matches. Errors are caught explicitly and logged to stderr, returning an empty list to the frontend rather than propagating a 500:
Err(error) => {
eprintln!("ERROR LEYENDO FIRESTORE (Usuario: {}): {:?}", payload.user_id, error);
vec![]
}Fetches the global top-20 ranking from Firestore via get_ranking_time. On error it returns an empty list silently using unwrap_or_else.
Calls firebase::update_score with the match outcome. On failure it logs the error to stderr and returns HTTP 500.
A development utility endpoint that scans all keys in Redis with KEYS * and returns their string values as a JSON object. It should be disabled or protected in production environments.