Skip to content
Matías Valle Trapiella edited this page Apr 13, 2026 · 2 revisions

Webapp

Table of Contents


Architecture Overview

The webapp is a React + TypeScript single-page application built with Vite. Session state is managed server-side (Redis via /api/me) and surfaced to every component through a React Context (UserContext). There are no user-data cookies — the browser only holds an opaque sessionId cookie set by the API Gateway.

App
├── UserProvider          ← global session state (fetches /api/me on mount)
└── BrowserRouter
    ├── /                 → MainMenu
    ├── /login            → LoginForm
    ├── /register         → RegisterForm
    ├── /gameSelection    → ProtectedRoute → SelectionWindow
    └── /play/:size/:mode → ProtectedRoute → GameWindow

React Router & Route Structure

In App.tsx, the entire app is wrapped in <UserProvider> (for global session state) and then <BrowserRouter>. Each <Route> maps a URL path to a React component:

// App.tsx
<UserProvider>
  <Router>
    <Routes>
      <Route path="/"                 element={<MainMenu />} />
      <Route path="/login"            element={<LoginForm />} />
      <Route path="/register"         element={<RegisterForm />} />
      <Route path="/gameSelection"    element={<ProtectedRoute><SelectionWindow /></ProtectedRoute>} />
      <Route path="/play/:size/:mode" element={<ProtectedRoute><GameWindow /></ProtectedRoute>} />
    </Routes>
  </Router>
</UserProvider>

/gameSelection and /play/:size/:mode are guarded by <ProtectedRoute> — unauthenticated users who did not explicitly choose "Play as Guest" are redirected to /login.

URL parameters

The game route uses dynamic segments:

path="/play/:size/:mode"

Inside GameWindow, these values are read with useParams():

const { size, mode } = useParams();
// e.g. size === '8', mode === 'bot'  (when URL is /play/8/bot)

Programmatic navigation

All navigation is done with useNavigate() from React Router, which keeps the browser history stack intact and avoids full page reloads.

Quick reference

Concept What it does
<BrowserRouter> Enables URL-based routing
<Routes> Picks the first matching <Route>
<Route path=... /> Maps a URL to a component
:param in path Dynamic URL segment, readable via useParams()
useNavigate() Returns a navigate() function for in-code redirects
useParams() Reads :param values from the current URL

UserContext — Session State

UserContext.tsx is the single source of truth for the current user's session. It replaces any cookie-based user storage — no user data is ever read from or written to a browser cookie by the frontend.

UserProvider

Wraps the entire app. On mount it calls GET /api/me to restore the session from the server-side Redis store. If the session is valid, user is populated; otherwise it is null.

export interface UserData {
  username: string;
  email: string;
}

Context API

Value Type Description
user UserData | null Current user, or null if not logged in
isLoggedIn boolean true when user !== null
loading boolean true while the initial /api/me check is in flight
error string | null Set if the server is unreachable on session restore
refreshUser() () => Promise<void> Re-fetches /api/me and updates user
logout() () => Promise<void> Calls POST /api/logout and sets user to null
updateUsername(u) (string) => Promise<void> Calls POST /api/update-username and updates user in place

Usage

import { useUser } from '../../contexts/UserContext';

const { user, isLoggedIn, logout } = useUser();

useUser() throws if called outside a <UserProvider>.


useCsrf — CSRF Token Hook

useCsrf.ts provides two ways to obtain a CSRF token:

// Hook: fetches on mount, returns the token string reactively.
// Use this in forms that need a token ready before submission.
export function useCsrf(): string

// Imperative helper: fetches a fresh token on demand.
// Use this in event handlers that don't render a form.
export async function fetchCsrfToken(): Promise<string>

Both hit GET /api/csrf-token with credentials: 'include' so the server can set the csrf_token cookie. The returned token is then attached to mutating requests as the X-CSRF-Token header.


ProtectedRoute

ProtectedRoute wraps the /gameSelection and /play/:size/:mode routes. It reads isLoggedIn and loading from UserContext and checks for an explicit guest flag in the navigation state:

const { isLoggedIn, loading } = useUser();
const isGuest = location.state?.guest === true;

if (loading) return null;           // Wait for /api/me — prevents flash redirect
if (!isLoggedIn && !isGuest) return <Navigate to="/login" replace />;
return <>{children}</>;

The guest flag is set by MainMenu when the user clicks Play as Guest:

navigate("/gameSelection", { state: { guest: true } });

This means unauthenticated users who explicitly choose to play as guest bypass the redirect, while any direct URL access without a session goes to /login.


CSS Modules & Theming

CSS Modules is used throughout the app so that class names are automatically scoped to their component and never collide across files. The convention is ComponentName.module.css.

Global design tokens (colours, spacing, fonts) are defined in styles/theme/variables.css and styles/theme/global.css, which are imported once at the top of App.tsx. Component stylesheets reference these CSS variables rather than hard-coding values.

Responsive design

Most sizing values use clamp(min, preferred, max) for fluid scaling without requiring breakpoints for every small adjustment:

font-size: clamp(3rem, 12vmin, 10rem);
gap: clamp(0.5rem, 2vh, 1rem);

Explicit breakpoints cover edge cases that clamp() alone cannot handle:

  • @media (max-width: 48rem) — narrow screens (portrait phones)
  • @media (max-height: 35rem) — very short screens (landscape phones)

Targeting third-party components with :global

When a component needs to style a class applied by a child component, the :global() escape hatch bypasses module scoping:

.mainMenuButtons :global(.mainMenuOption) {
  padding: clamp(0.5rem, 1.5vh, 0.8rem) 2rem;
}

GameApi.ts — Game API Client

GameApi.ts is the centralized HTTP client for all game-related requests. Every function routes through the /game/* proxy prefix and returns null on error (logged to the console) instead of throwing, so callers do not need try/catch.

Function Method Path Description
createMatch(p1, p2, size, variant?) POST /game/new Creates a new match in Redis, returns { match_id }
sendMove(matchId, x, y, z) POST /game/executeMove Applies a move, returns { match_id, game_over }
requestBotMove(matchId) POST /game/reqBotMove Asks the bot engine for its move, returns coordinates + game_over
updateScore(playerid, username, is_win, time) POST /game/updateScore Updates the player's Firestore score
saveMatch(matchId, p1id, p2id, result, time, moves) POST /game/saveMatch Persists the finished match to Firestore

All functions include credentials: 'include' so the session cookie is forwarded. The VITE_API_URL environment variable controls the base URL (defaults to http://localhost:3000).


Main Menu

MainMenu is the root page (/). It renders the game title, tagline, two action buttons, and mounts TopRightMenu in the top-right corner.

Structure

MainMenu
├── TopRightMenu          ← persistent icon bar
├── .mainTitle            ← "GAMEY" heading + subtitle
└── .mainMenuButtons
├── Log In            → navigate("/login")
└── Play as Guest     → navigate("/gameSelection", { state: { guest: true } })

Navigation

  • Log In — sends the user to /login.
  • Play as Guest — navigates to /gameSelection with state: { guest: true }, which ProtectedRoute reads to allow access without a session.

Authentication

LoginForm.tsx

Handles the /login route. On mount, useEffect checks isLoggedIn from UserContext; if already logged in and the login was not just performed via this form, the user is redirected to /gameSelection.

A justLoggedIn ref prevents a double redirect: when the user submits the form successfully, the ref is set to true so the useEffect watcher does not also fire.

CSRF

The form uses the useCsrf() hook, which fetches the token on mount and returns it synchronously to be attached to the submit request.

Submit flow

  1. Client-side validation (both fields non-empty).
  2. POST /api/login with { email, password } and X-CSRF-Token header.
  3. On success: calls refreshUser() (fetches /api/me to populate UserContext), shows a success message, and redirects to /gameSelection after 1.2s.
  4. On failure: displays the error string returned by the server.

The form also includes a <Link to="/register"> for new users.

State summary

Variable Purpose
email / password Controlled input values
csrfToken Token from useCsrf(), sent with every request
loading Disables the submit button while the request is in flight
responseMessage Success text shown after login
error Error text shown on failure
justLoggedIn (ref) Prevents the isLoggedIn watcher from double-redirecting

RegisterForm.tsx

Handles the /register route. Its structure mirrors LoginForm exactly, with the addition of a username field. On mount it also watches isLoggedIn and redirects to /gameSelection if already authenticated.

Submit flow

  1. Client-side validation (all three fields non-empty).
  2. POST /api/register with { email, username, password } and X-CSRF-Token header.
  3. On success: calls refreshUser() to populate UserContext, shows a message, and redirects after 1.5s.
  4. On failure: displays the error string returned by the server.

The form includes a <Link to="/login"> for existing users.


Top Right Menu

TopRightMenu is a persistent icon bar rendered in the top-right corner of every page. It manages which overlay panel is currently open.

Icon buttons and their panels

Button Opens
Help <HelpMenu />
Rankings <Ranking />
Volume Toggles local mute state, no panel
Settings <SettingsMenu />
User <UserMenu />

Only one panel can be open at a time. The active panel is tracked with a single activeMenu state variable of type 'settings' | 'rankings' | 'help' | 'user' | null. Clicking any icon sets it to the corresponding value; each panel receives an onClose prop that resets it to null.

Volume toggle

The mute state is kept locally in TopRightMenu and does not persist between sessions. The button swaps between volumeUnmuteIcon and volumeMuteIcon based on the isMuted boolean.


Help Menu

HelpMenu is an overlay panel with a sidebar tab layout. It has three tabs, each rendering a static help article:

Tab Component Content
Main Menu MainMenuHelp Explains the main menu navigation
Account AccountHelp Explains login, registration, and guest play
Game Rules GameRulesHelp Explains the rules of Y and the WhY not variant

On desktop, the sidebar is always visible. On mobile, the sidebar is replaced by a hamburger toggle (menuOpen state) that reveals the navigation inline above the content.

The active tab is tracked by activeTab (string ID). Clicking a tab sets it and collapses the mobile nav:

const [activeTab, setActiveTab] = useState('mainMenu');
const current = tabs.find(t => t.id === activeTab)!;

Clicking outside the panel (on the overlay) or pressing Escape calls onClose.


Settings Menu

SettingsMenu is an overlay panel with a sidebar tab layout. Each tab renders an independent settings section.

Strategy Pattern — SettingsStrategy.ts

All settings sections implement the SettingsSection interface:

export interface SettingsSection {
  id: string;
  label: string;
  render: () => React.ReactNode;
}

SettingsMenu never needs to know what any section looks like — it calls section.render(). The three section instances are created with useMemo:

const sections = useMemo(() => [
  new AudioSettings(),
  new GameSettings(),
  new AccountSettings(isLoggedIn, username, navigate, logout)
], [isLoggedIn, username, navigate, logout]);

Available sections

Class Tab label What it does
AudioSettings Audio Two volume sliders (Master, Music) with a live tooltip
GameSettings Game Checkboxes for move hints and move confirmation
AccountSettings Account Shows login status; navigates to /login or logs out

AccountSettings

Takes four constructor arguments: isLoggedIn, username, navigate, and logout. It renders one of two views:

  • Guest — shows a "?" avatar, "Guest / Not logged in" profile card, a hint about cloud saving, and a "Log In" button.
  • Logged in — shows an avatar with the username initial, the username, an "Active session" badge, and a "Log Out" button that calls navigate('/') then logout().

AudioSettings

Each volume slider is a self-contained VolumeSlider component. It manages its own value in local state and renders a tooltip that is only visible while being dragged (onMouseDown/onTouchStart set isActive = true). The filled track is drawn with a CSS linear-gradient applied inline:

style={{
  background: `linear-gradient(to right, var(--primary-color) ${value}%, rgba(255,255,255,0.1) ${value}%)`
}}

Rankings

Ranking is an overlay panel with two top-level tabs — Local and Global — each rendering their own component.

const RANKING_TYPES = [
  { id: 'local',  label: 'Local',  Component: LocalRanking  },
  { id: 'global', label: 'Global', Component: GlobalRanking },
];

The active tab is tracked by activeTabId; the matching component is rendered dynamically.

Data shapes

RankingElementLocal — used for personal history and the Time global tab:

Field Type Description
position number Row position in the table
player1Name string Display name of player 1
player2Name string Display name of player 2
result string Match outcome (e.g. "Win", "Loss")
time number Match duration in seconds
moves Coordinates[] Full move history (for replay)
boardSize number Board size used in the match

RankingElementGlobal — used for Wins, Loses, and Elo tabs:

Field Type Description
position number Rank position
player1Name string Player's username
metric string Formatted value (wins count, elo, etc.)
metricName string Column header (e.g. "WINS", "ELO")

RankingElementTime is a concrete implementation of RankingElementGlobal that hard-codes metricName = 'TIME'.


LocalRanking

Fetches the current user's full match history from POST /game/localRankings using user?.email as the user_id. Uses useUser() to determine the session state.

If the user is not logged in (!user), a "not logged in" message with a login button is shown instead of making the request.

Displays five sub-tabs that filter and sort the same underlying dataset:

Sub-tab Display logic
Historial All matches, most recent first
Time All matches, sorted shortest duration first
Wins Only wins, most recent first
Loses Only losses, most recent first
Statistics <StatisticsPanel> — computed stats from all match data

Each row in the table has a Replay button that opens <GameReplayWindow> for that match.


GlobalRanking

Fetches the top-20 scores from GET /game/bestTimes on mount. The response is an array of full Score objects (playerid, username, wins, losses, elo, best_time, etc.).

Displays four sub-tabs:

Sub-tab Data source Table type
Time Fetches each player's best match via POST /game/localRankings RankingTableLocal + Replay button
Wins Sorted by wins descending RankingTableGlobal
Loses Sorted by losses ascending RankingTableGlobal
Elo Sorted by elo descending RankingTableGlobal

The Time tab fetches each top-20 player's fastest match individually and caches the results in state (timeFetched ref prevents re-fetching on tab switch). Ties are given the same position number using a dense ranking algorithm.


StatisticsPanel

Computed from the full local match history. Calculates and renders:

  • Total games, win/loss count, win rate (pie chart)
  • Average game duration and fastest win
  • Current streak (type + length, walking backwards through history)
  • Top 5 opponents by total games played, with win/loss breakdown per opponent
  • Elo evolution chart (applying the same +20 win / -15 loss / floor 0 formula as the backend)
  • Recent form — last N results displayed as coloured dots

All calculations are done in a single useMemo block. The view is rendered by StatisticsPanelView, which receives the computed data as props.


GameReplayWindow

An overlay that replays a stored match step by step on an interactive board.

Layout:

  • Left area: <Board> component rendered in blocked mode (no interaction), scaled to fit the container via ResizeObserver
  • Right panel: player names, result, duration, move counter, progress bar, and playback controls

Controls: ⏮ First / ◀ Previous / ▶▐▐ Play-Pause / ▶ Next / ⏭ Last

Auto-play advances one move every 1200ms; stops one interval after the last move so it stays visible before playback halts.

Coordinate conversion: move coordinates stored as {x, y, z} are converted back to {row, col} via fromXYZ() from Game.ts before being passed to <Board>.

If a match has no stored moves (played before move logging was added), a message is shown instead of the board.


User Menu

UserMenu is a small overlay panel accessible from the user icon in TopRightMenu. It reads session state from UserContext via useUser().

Guest view

If user is null, the panel shows a "Guest User / Not logged in" profile card, a message about saving progress, and a "Go to Login" button.

Logged-in view

Shows a profile card with the user's first-letter avatar, username, and email. Below it, an editable username field:

  • Display mode — shows user.username and an Edit button.
  • Edit mode — replaces the display with a text input plus Save / Cancel buttons. On save, calls updateUsername(newUsername) from UserContext, which persists the change via POST /api/update-username and updates the Redis session server-side. If the call fails, the error message is shown inline below the input.

The Log Out button navigates to / and then calls logout() from UserContext, which calls POST /api/logout.


Game Selection

SelectionWindow is the /gameSelection route. It lets the player pick a game mode and configure its options. Like the main menu, it mounts TopRightMenu in the top-right corner.

Structure

SelectionWindow
├── TopRightMenu              ← persistent icon bar
├── header "SELECT YOUR GAME MODE"
└── SelectionPanel            ← scrollable carousel of mode cards
├── GameModeContainer (NormalMode)
├── GameModeContainer (LocalMode)
└── GameModeContainer (WhyNotMode)

SelectionPanel — the carousel

Holds the three game mode instances, rendered as a horizontally scrollable carousel. The modes are instantiated once with useMemo. Arrow buttons call scrollRef.current.scrollBy() with behavior: 'smooth', advancing 400px per click.

GameMode interface

Every game mode implements:

Field Type Purpose
id string Unique identifier for React keys
label string Display name in the card header
description string Tooltip text shown on ? click
mode string URL segment passed to the game route
size number Default board size
currentLevel Difficulty Selected difficulty level
showDifficulty boolean Whether the difficulty selector is shown

Difficulty is a string union: Very Easy, Easy, Normal, Hard, Very Hard.

Available game modes

Class mode value Difficulty Default size Description
NormalMode "bot" ✅ shown 8 Single-player vs. a bot
LocalMode "multi" ❌ hidden 8 Local two-player on the same device
WhyNotMode "why_not" ❌ hidden 8 Misère variant — first to connect all three edges loses

GameModeContainer — the mode card

Owns local state for the two configurable parameters:

  • Difficulty — an index bounded between 0 and length - 1. Only rendered when mode.showDifficulty is true.
  • Size — an integer bounded between 4 and 12.

Both selectors use arrow buttons that are hidden (visibility: hidden) when at their limit. When PLAY is clicked:

mode.currentLevel = currentDifficulty;
mode.size = currentSize;
navigate(`/play/${currentSize}/${mode.mode}`);

The ? button reveals a tooltip. Its visibility is controlled purely by a CSS hover rule — no JS state needed.


Game Window

GameWindow is the /play/:size/:mode route. It is the main game screen and orchestrates all game logic, server communication, and UI.

Layout

GameWindow
├── TopRightMenu          ← persistent icon bar
├── TopLeftHeader         ← back navigation
├── Board                 ← the triangular hex grid
└── RightPanel            ← current turn + timer
└── (mobile toggle button)

On mobile, RightPanel is hidden by default and toggled with a ☰ button.

Game.ts — client-side state

The Game class holds the local representation of the match:

Property Type Description
size number Board size
matchId string | null Server-assigned ID for the Redis session
player1 / player2 string Display names
moves Move[] Ordered list of placed pieces { row, col, player }
turn 0 | 1 Index of the player to move
gameOver boolean Set to true when the engine reports a win

Key methods:

addMove(row, col)        // Appends the move and flips turn
setGameOver(value)       // Sets gameOver flag
isOccupied(row, col)     // Returns true if the cell is already taken

Coordinate conversion

The board uses two coordinate systems. UI uses (row, col). The server and game engine use (x, y, z). Conversion is handled by:

toXYZ(row, col, size)    // UI → server
fromXYZ(x, y, z, size)  // server → UI

Game flow

  1. On mount, createGame() calls GameApi.createMatch() to initialize a Redis session and obtain a match_id.
  2. When the player clicks a cell, handlePlace() calls GameApi.sendMove(). If the move is valid, the local Game state is updated.
  3. If the engine returns game_over: true, handleGameOver() is called.
  4. In bot mode (!isMultiplayer), handleBotPlace() is called immediately after the player's move. The bot response has a minimum artificial delay to avoid instant responses (random 500–1000ms if the real response was faster than 500ms).

Win condition

const moverIsP1 = game.turn === 0;
const isP1Winner = mode === "why_not" ? !moverIsP1 : moverIsP1;

In standard Y, the player who completes the connection wins. In WhY not (misère variant), the player who completes the connection loses.

Game over handling

When a game ends, handleGameOver():

  1. Shows a modal with the winner's name and total time.
  2. If a user is logged in, calls updateScore() and saveMatch() (fire-and-forget — errors are logged but do not block the UI).

The modal has a Return to game Selection button that navigates to /gameSelection.

Board blocking

The <Board> receives a blocked prop that disables all interaction:

blocked={loading || game.gameOver || isBotTurn}

This prevents the player from clicking while a request is in flight, the game is over, or it is the bot's turn.

Clone this wiki locally