-
Notifications
You must be signed in to change notification settings - Fork 2
Architectural decisions
Accepted
02/02/2026
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.
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.
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.
Accepted
16/02/2026
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.
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.
- 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.
Accepted
23/02/2026
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.
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.
- 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.
Accepted
12/03/2026
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.
We split a bot turn into two sequential HTTP calls initiated by the frontend:
-
POST /games/:id/move-> the frontend sends the player's coordinates. Theusersservice callsgamey /computeto 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. -
GET /games/:id/play-> the frontend requests the bot response. Theusersservice reads the savedyen_state, callsgamey /playwith the chosen strategy, and saves the bot move automatically. Theusersservice is the sole orchestrator between the frontend andgamey. The frontend never callsgameydirectly. For more information: The sequence diagram 6.3 of the documentation ilustrate this idea: https://arquisoft.github.io/yovi_en1b/
- Game state in MongoDB is always consistent: the player's move is persisted before the bot is ever asked to respond.
-
gameyremains stateless and independently testable. - A full bot turn costs two HTTP round trips instead of one.
- The frontend is responsible for calling
/playafter/movewhen the game is stillIN_PROGRESS.
Accepted
16.3.2026
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.
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.
- 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'schoose_movecomputation. -
Send + Syncrequirements must be upheld by every bot implementation; blocking I/O insidechoose_move(e.g.GenerativeAIBot) is safe because the caller usestokio::task::spawn_blocking.
Accepted
23/03/2026
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.
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.
- 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_blockingensures the async executor is not starved during computation.
Accepted
23/03/2026
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.
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 cellIf 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.
- 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
HardBotremains large. - The strategy name in the registry is
"medium"(not"defensive_bot"), which aligns with the difficulty labels used in the frontend and theusersservice routing.
Accepted
08/04/2026
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.
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.
- The bot is entirely optional: removing
GEMINI_API_KEYfrom 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_urlis injectable at construction time (viawith_url_overridein tests) so unit tests can point tohttp://127.0.0.1:1and get an immediate connection-refused error without any network dependency or timeout.
Accepted
13/04/2026
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.
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.
- The base
GameY::new()path is completely unaffected; all variant logic is behindself.variants.contains(&GameVariant::Explosions)checks. - YEN round-trips correctly: bomb positions survive serialize → deserialize → serialize without loss, which is required because the
usersservice 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; onlyGenerativeAIBothas explicit bomb awareness in its prompt. - Adding a new variant requires a new enum arm, a
from_namebranch, variant-specific logic inside the game loop, and optionally an additional YEN field — the HTTP router, registry, and CLI are unchanged.
Accepted
13/04/2026
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.
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.
- The
gameyengine 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.
Accepted
20/04/2026
- 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.
- 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.
- A winrate calculation has to be modified
- A several names has to be unified all over the application
Accepted
20/04/2026
- We need a reliable way to route incoming internet traffic to the correct internal Docker containers and secure all connections with HTTPS.
- 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.
- 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.
Accepted
20/04/2026
- 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.
- 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.
- 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.
Accepted
20/04/2026
- 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.
- 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.
- 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.