Skip to content

Architectural decisions

Alejandro De San Claudio Mesa edited this page Apr 29, 2026 · 13 revisions

Architectural decisions

Microsoft Azure as VM Host

Status

Accepted

Date

02/02/2026

Context

The project yovi_en1b requires a stable, scalable environment to host the application and its services. We needed a cloud provider offering solid VM management, good availability, and smooth integration with our existing DevOps tooling. The main alternatives considered were: AWS (Amazon Web Services): Extensive feature set but a steeper learning curve for billing and permissions management. Google Cloud Platform (GCP): Strong container support, but less alignment with existing academic subscriptions available to the team.

Decision

We chose Microsoft Azure (Azure Virtual Machines) to host our infrastructure. Azure offers seamless integration with GitHub Actions for our CI/CD pipeline, provides "Azure for Students" credits which fit the scope of this academic project, and includes a straightforward web portal for monitoring VM health and resource usage.

Consequences

Positive: Streamlined deployment pipeline using Azure-specific GitHub Actions; reliable availability for the application. Negative: Some risk of vendor lock-in regarding Azure-specific CLI tooling; credit usage must be monitored to avoid unexpected service suspension. Neutral: Team members need to become familiar with Azure Resource Groups and SSH key management within the Azure portal.

Selected Database

Status

Accepted

Date

16/02/2026

Context

The project needed a persistence layer for the users service to store player accounts, game history and statistics. Given our Node.js/Express backend and the nature of the data, hierarchical, variable-length structures like game states and move histories, we had to choose between a relational and a document-oriented database.

Decision

We chose MongoDB as our database. Its document-oriented model is a natural fit for our data, as game states, move histories and player statistics map directly to JSON documents, something that would require multiple joined tables in a relational database but is a single document in MongoDB. MongoDB also integrates seamlessly with our Node.js/Express stack through Mongoose, keeping the development experience consistent. Additionally, MongoDB is a widely used technology in the industry and relevant for the careers of the team members, which also influenced the decision in order to learn more of it. Only the users service connects to MongoDB, which was a deliberate architectural decision. Gamey manages game state in memory during a match, which is sufficient for a pure game engine, and reports the result to the users service once the game ends. This keeps data ownership clear and avoids two services competing to write to the same database.

Consequences

  • Game states, statistics and move histories are stored as documents, avoiding complex relational joins.
  • The users service is the single gateway for all persistent data, making data ownership explicit.
  • Gamey remains a stateless, pure game engine with no database dependency.
  • The team gains practical experience with a widely adopted NoSQL technology.

JWT Authentication for Microservices

Status

Accepted

Date

23/02/2026

Context

Our microservices (users-service and gameyapi) need to verify user identity efficiently. Traditional session-based auth creates bottlenecks because every service must query a central database to validate a session, increasing latency and risk of a single point of failure.

Decision

We will use JSON Web Tokens (JWT) for stateless authentication.

  • Flow: The users-service issues a signed token containing userId and username upon login.
  • Security: JWT_SECRET is managed via .env (local) and GitHub Secrets (production).
  • Validation: Consumer services verify tokens locally using the shared secret, bypassing the database entirely.

Consequences

  • Performance: Local verification eliminates database round-trips.
  • Scalability: Stateless design allows services to scale horizontally with ease.
  • Decoupling: gameyapi stays independent of the user database. But also...
  • Revocation: Tokens are valid for 2 hours; there is no native way to kill a session instantly.
  • Security: Compromise of the JWT_SECRET allows full user impersonation.
  • Overhead: Token data is sent with every request, slightly increasing payload size.

Two-Call Turn Sequence for Player vs Bot Games

Status

Accepted

Date

12/03/2026

Context

When playing against a bot, a full turn involves two things: saving the player's move and getting the bot's response. We needed to decide how to orchestrate this across the frontend, the users service, and the gamey Rust engine.

Decision

We split a bot turn into two sequential HTTP calls initiated by the frontend:

  1. POST /games/:id/move -> the frontend sends the player's coordinates. The users service calls gamey /compute to get the updated board state (as the backend have the previous yen state of the game and the new coordinate selected, the logic of computing the new yen state should be in the game y logic, this is in fact another arquitectural decision itself) and check for a winner, then persists the move to MongoDB.
  2. GET /games/:id/play -> the frontend requests the bot response. The users service reads the saved yen_state, calls gamey /play with the chosen strategy, and saves the bot move automatically. The users service is the sole orchestrator between the frontend and gamey. The frontend never calls gamey directly. For more information: The sequence diagram 6.3 of the documentation ilustrate this idea: https://arquisoft.github.io/yovi_en1b/

Consequences

  • Game state in MongoDB is always consistent: the player's move is persisted before the bot is ever asked to respond.
  • gamey remains stateless and independently testable.
  • A full bot turn costs two HTTP round trips instead of one.
  • The frontend is responsible for calling /play after /move when the game is still IN_PROGRESS.

Trait‐Based Bot Abstraction

Status

Accepted

Date

16.3.2026

Context

The gamey engine needs to support multiple bot strategies (random, defensive, MCTS, generative AI) that can be selected at runtime — either from the CLI, from the HTTP API, or from test code. We needed a common interface that allows each strategy to be implemented independently while being stored and called through a unified mechanism.

Decision

We defined a YBot trait with two required methods:

pub trait YBot: Send + Sync {
    fn name(&self) -> &str;
    fn choose_move(&self, board: &GameY) -> Option<Coordinates>;
}

Bots are stored and shared as Arc<dyn YBot>, enabling cheap cloning across threads without duplicating the underlying implementation. The Send + Sync bounds are required because the Axum server handles bots across async tasks and threads.

Consequences

  • Each bot is an independent struct; adding a new strategy requires only implementing the trait — no changes to the server, registry, or CLI code.
  • Arc<dyn YBot> introduces a small runtime dispatch cost (vtable lookup), which is negligible relative to the cost of any bot's choose_move computation.
  • Send + Sync requirements must be upheld by every bot implementation; blocking I/O inside choose_move (e.g. GenerativeAIBot) is safe because the caller uses tokio::task::spawn_blocking.

MCTS with UCB1 for the Hard Bot

Status

Accepted

Date

23/03/2026

Context

We needed a strong bot that could play the Game of Y competitively without requiring a large precomputed opening book or a database. Minimax with alpha-beta pruning is difficult to apply to Y because the branching factor is very high and there is no incremental evaluation function with known good heuristics for this game.

Decision

HardBot uses Monte Carlo Tree Search (MCTS) with the UCB1 (Upper Confidence Bound) selection formula. The algorithm runs a configurable number of simulations (default 800) from each candidate cell and selects the move with the highest win rate:

UCB1 = wins/visits + C * sqrt(ln(parent_visits) / visits)

where C = 1.414 (√2). Each simulation plays out a full random game from the candidate position and records whether the bot's player won. CPU-intensive computation is offloaded from the Tokio async runtime via tokio::task::spawn_blocking so concurrent API requests are not blocked.

Consequences

  • MCTS produces strong play without hand-crafted evaluation functions; quality scales with simulation count.
  • with_simulations(n) allows tuning the strength/latency trade-off in tests and benchmarks.
  • Running 800 simulations per move takes tens to hundreds of milliseconds depending on board size; this is acceptable for the bot API use case but would need reduction for real-time applications.
  • spawn_blocking ensures the async executor is not starved during computation.

DefensiveBot as the Medium Difficulty

Status

Accepted

Date

23/03/2026

Context

The game needed a bot between the fully random RandomBot and the strong MCTS-based HardBot. We wanted a difficulty level that feels reactive to the player's moves without requiring heavy computation, so it could respond quickly and give new players a fair challenge.

Decision

DefensiveBot (registered under the name "medium") implements a single rule: look at the opponent's most recent placement and pick a random empty neighbor of that cell.

if let Some(Movement::Placement { coords, .. }) = history.last() {
    let neighbors = coords.neighbors(board_size);
    let empty_neighbors: Vec<_> = neighbors
        .into_iter()
        .filter(|n| game.board().is_empty_at(n))
        .collect();

    if !empty_neighbors.is_empty() {
        return empty_neighbors.choose(&mut rand::rng()).copied();
    }
}
// Fallback: random empty cell

If no empty neighbors exist (e.g. the opponent played into a crowded area) the bot falls back to a uniformly random move, the same behavior as RandomBot. No game tree search or scoring function is needed.

Consequences

  • Response time is O(neighbors) ≈ constant; there is no noticeable latency difference from RandomBot.
  • The bot disrupts simple linear chains, which is enough to challenge players who have not yet learned to play around their opponent.
  • It has no offensive strategy of its own — an experienced player can beat it consistently by playing away from their last move, so the difficulty gap to HardBot remains large.
  • The strategy name in the registry is "medium" (not "defensive_bot"), which aligns with the difficulty labels used in the frontend and the users service routing.

Google Gemini as a Generative AI

Status

Accepted

Date

08/04/2026

Context

We wanted to explore whether a large language model could play Y at a reasonable level without any hand-coded game tree logic. The Gemini API was available, and integrating it as an optional fourth bot would let us test LLM-based game play as a proof of concept. The main technical constraints were: (1) YBot::choose_move is a synchronous trait method called from inside an async Axum handler, and (2) the API key must never be hard-coded in source or logged.

Decision

GenerativeAIBot is constructed only when GEMINI_API_KEY is present in the environment (from_env() returns None otherwise), so the server starts without errors even without the key.

pub fn from_env() -> Option<Self> {
    std::env::var("GEMINI_API_KEY")
        .ok()
        .filter(|k| !k.trim().is_empty())
        .map(|api_key| Self { api_key, api_url: GEMINI_API_URL.to_string() })
}

To avoid blocking the Tokio async runtime with a synchronous HTTP call (reqwest::blocking), the request is made inside a plain OS thread spawned ad-hoc, and the result is returned via a std::sync::mpsc::sync_channel:

let (tx, rx) = mpsc::sync_channel(1);
std::thread::spawn(move || {
    let result = do_http_request(&api_key, &prompt, &api_url);
    let _ = tx.send(result);
});
rx.recv_timeout(Duration::from_secs(20))

The prompt sent to Gemini includes: a natural-language description of Y's rules, the full board in a visual triangle layout with barycentric coordinates, the list of legal moves, and explicit instructions for bomb cells when the Explosions variant is active. Gemini is instructed to reply with only x=N,y=N,z=N. If the API call fails, times out, or returns coordinates that are not a legal move, choose_move falls back silently to a random legal move so the game never crashes.

Consequences

  • The bot is entirely optional: removing GEMINI_API_KEY from the environment simply removes "gemini" from the registry without affecting any other bot or endpoint.
  • Response time is dominated by the Gemini API round-trip (~1–5 s); the OS thread approach keeps the async executor free for other requests during that wait.
  • Move quality depends on the prompt and the model version. The current prompt instructs Gemini to check for immediate wins and blocks first, which covers the most critical cases. Complex positional play is unreliable.
  • When the Explosions variant is active, the prompt includes detailed bomb rules and annotates bomb cells in the legal-moves list with [BOMB — triggers explosion], so the model does not need to infer explosion mechanics itself.
  • The api_url is injectable at construction time (via with_url_override in tests) so unit tests can point to http://127.0.0.1:1 and get an immediate connection-refused error without any network dependency or timeout.

Explosions as a Game Variant

Status

Accepted

Date

13/04/2026

Context

The team wanted to add optional rule modifications to increase replayability. "Explosions" (bomb mode) was proposed as a variant where a random bomb cell appears on the board at the start; capturing it clears all occupied neighboring cells. We needed a design that kept the base GameY logic clean, integrated cleanly with YEN serialization, and worked with all existing bots.

Decision

GameVariant::Explosions is one arm of the GameVariant enum. It is activated by calling GameY::new_with_variants(size, vec![GameVariant::Explosions]), which requires size >= 7 (a minimum enforced inside the constructor to give the bomb room to detonate meaningfully). When a player places on a bomb cell, the game engine: keeps the player's piece on the bomb cell, removes all occupied neighbors (both players' pieces), consumes the bomb, and passes the turn to the opponent immediately — even if DoubleTurn is also active. Chain detonation applies if a neighbor of the exploding bomb is itself a bomb. Bombs are placed at game start so that no two bombs are adjacent, preventing unintended chain reactions from the initial placement. The GenerativeAIBot prompt explicitly describes explosion mechanics and annotates bomb cells in the legal-moves list when this variant is active. DefensiveBot and HardBot are unaware of the variant; they treat bomb cells as ordinary empty cells and do not exploit or avoid them intentionally.

Consequences

  • The base GameY::new() path is completely unaffected; all variant logic is behind self.variants.contains(&GameVariant::Explosions) checks.
  • YEN round-trips correctly: bomb positions survive serialize → deserialize → serialize without loss, which is required because the users service persists YEN to MongoDB between moves.
  • The minimum board size of 7×7 is enforced at construction time, not in the HTTP handler, keeping the API layer free of variant-specific validation.
  • Existing bots (random, medium, hard) continue to work in Explosions games without modification; only GenerativeAIBot has explicit bomb awareness in its prompt.
  • Adding a new variant requires a new enum arm, a from_name branch, variant-specific logic inside the game loop, and optionally an additional YEN field — the HTTP router, registry, and CLI are unchanged.

Double Turn Variant Not Implemented

Status

Accepted

Date

13/04/2026

Context

The DoubleTurn variant was implemented in the gamey Rust engine and is fully functional at the backend level: the engine enforces two placements per turn and the YEN format carries the "DoubleTurn" variant field correctly. When the time came to expose this variant in the frontend alongside Explosions, the team evaluated whether it was worth building the UI flow for it.

Decision

We decided not to implement DoubleTurn on the frontend. The decision was not driven by technical difficulty but by two more fundamental concerns. The variant does not add meaningful game enjoyment. Y is a game about gradually building a connected path across three sides of a triangle. Its strategic depth comes from the tension between advancing your own chain and disrupting the opponent's. Playing two moves per turn does not change this tension — it simply accelerates the game's pace. Both players gain the same advantage, so the relative balance is preserved but the game ends faster and with less room for recovery after a mistake. Rather than creating new strategic decisions, DoubleTurn compresses the existing ones, which makes each individual move feel less consequential. The enjoyment of Y comes from the slow, deliberate accumulation of position; DoubleTurn works against that feeling. The variant conflicts with Y's underlying logic. The Game of Y is, at its core, a single-connection game — each turn is one atomic decision about where to extend or block a chain. The rule that one piece connects to its neighbors and that the first complete three-sided connection wins is elegant precisely because each placement carries full weight. Giving a player two placements per turn breaks the symmetry between "a move" and "a strategic intention": a player can place one piece to open a threat and a second piece to seal it in the same turn, collapsing what would naturally be two separate decision points into one. This is not a rule that stretches the game's possibilities; it is a rule that shortens the meaningful space between turns and, in doing so, undermines the purity of the connection game format that makes Y interesting.

Consequences

  • The gamey engine retains full DoubleTurn support; it remains available via the bot server API and the CLI for testing or future reconsideration.
  • No frontend UI, route guard, or game-mode selector code was written for DoubleTurn, keeping the frontend simpler and the user-facing game modes unambiguous.
  • If the team revisits this decision in the future, the backend work is already done and only a frontend implementation would be needed.

Change politics of canceled games to surrendered

Status

Accepted

Date

20/04/2026

Context

  • Until now the user could end game whenever he want and that way he could avoid losing tha game, as the game was marked as canceled/draw/unfinished. That way he could influence the statistics.

Decision

  • We change the game cancelation politics to surrender so everytime a user cancel a game, it is marked as surrender and it count as lost in the winrate etc.

Consequences

  • A winrate calculation has to be modified
  • A several names has to be unified all over the application

Use Caddy as Reverse Proxy and Edge Router

Status

Accepted

Date

20/04/2026

Context

  • We need a reliable way to route incoming internet traffic to the correct internal Docker containers and secure all connections with HTTPS.

Decision

  • We decided to use Caddy as our reverse proxy and web server. A single "Caddyfile" is used to intercept paths (e.g., "/api/users/*") and forward them to the correct internal ports within the Docker network.

Consequences

  • Caddy automatically provisions and renews SSL/TLS certificates via Let's Encrypt ("Zero-config HTTPS"), completely removing the need for manual certificate management (like Nginx + Certbot).
  • Only ports 80 and 443 need to be exposed to the internet, increasing security.

Implement Observability with Prometheus and Grafana

Status

Accepted

Date

20/04/2026

Context

  • Monitoring is essential to observe the system's behavior and performance at runtime. Without it, we are blind to CPU/Memory usage, API response times, or malicious network traffic. Following the teachers' advice, we need to actively collect and visualize system metrics.

Decision

  • We implement an observability stack using Prometheus and Grafana. A "prometheus.yml" configuration tells the Prometheus container to scrape this data every 5 seconds. Grafana is then deployed via "docker-compose.yml" to read the Prometheus database and plot the data.

Consequences

  • We gain real-time visibility into the system's health, allowing us to detect unusual traffic (like bots trying to find vulnerabilities in our domain) and monitor server load on load tests.
  • Deployment complexity increases slightly: the "monitoring" directory configuration files must be copied to the production server, and Grafana must be securely routed behind Caddy.

Use Artillery for Automated Load Testing

Status

Accepted

Date

20/04/2026

Context

  • We need to validate the scalability and fault tolerance of our architecture under heavy traffic. Simple endpoint pinging is not enough; we need to simulate realistic user behavior at scale.

Decision

  • We decided to use Artillery to perform stress tests. We designed a "scenario-flow.yml" that reproduces a complete player lifecycle (registration, login, creating a game, playing against a bot, and deleting the account). Furthermore, we integrated this into GitHub Actions so we can trigger the load test against the production domain directly from the cloud anytime.

Consequences

  • We can confidently measure our system's peak capacity (handling up to 15 concurrent full-lifecycle users per second).
  • By combining this with Grafana, we can observe the impact of the load test on the server in real-time.
  • The load test scenarios must be maintained and updated if the API contracts change.

Clone this wiki locally