Skip to content

The GameManager Microservice

Matías Valle Trapiella edited this page Apr 13, 2026 · 4 revisions

The Game Manager Module

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.


Table of Contents


data.rs — Data Structures

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 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

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.

Match

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 {}

Score

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 {}

Coordinates

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,
}

API Request / Response Models

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 — Firestore Connection

firebase.rs provides generic CRUD helpers and concrete functions for the Match and Scores collections.

Crypto provider setup

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.

read_db<T>

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: DBData

Internally 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()),
}

insert_db<T>

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 succeeded

get_match_by_id / insert_match_by_id

Concrete 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(())
}

get_user_matches

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>>

get_ranking_time

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>>

update_score

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_matches increments by 1 every call.
  • A win adds 20 elo and increments wins; a loss subtracts 15 elo (floor 0) and increments losses.
  • best_time is only overwritten if the new time is lower, or if it was 0.
  • win_rate is recalculated as wins / total_matches after every update.

insert_score

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 — Redis Session Layer

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.

Connection pool

pub type RedisPool = bb8::Pool<RedisConnectionManager>;

pub async fn create_pool(redis_url: &str) -> RedisPool

The pool is created at startup in api_rest.rs and injected into all route handlers via AppState.

Distributed lock

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>

Match state

// 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>

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 — HTTP Server & Route Handlers

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.

Application State — AppState

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.

Server Startup — run()

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()

Route Overview

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

POST /newcreate_match

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 })

POST /executeMoveexecute_move

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
});

POST /reqBotMoverequest_bot_move

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.


POST /saveMatchsave_match

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?;

POST /localRankingsget_local_rankings

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![]
}

GET /bestTimesget_best_times

Fetches the global top-20 ranking from Firestore via get_ranking_time. On error it returns an empty list silently using unwrap_or_else.


POST /updateScoreupdate_user_score

Calls firebase::update_score with the match outcome. On failure it logs the error to stderr and returns HTTP 500.


GET /debug/redisdump_redis

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.

Clone this wiki locally