-
Notifications
You must be signed in to change notification settings - Fork 2
Webapp
- Architecture Overview
- React Router & Route Structure
- UserContext — Session State
- useCsrf — CSRF Token Hook
- ProtectedRoute
- CSS Modules & Theming
- GameApi.ts — Game API Client
- Main Menu
- Authentication
- Top Right Menu
- Help Menu
- Settings Menu
- Rankings
- User Menu
- Game Selection
- Game Window
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
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.
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)All navigation is done with useNavigate() from React Router, which keeps the browser history stack intact and avoids full page reloads.
| 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.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.
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;
}| 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 |
import { useUser } from '../../contexts/UserContext';
const { user, isLoggedIn, logout } = useUser();useUser() throws if called outside a <UserProvider>.
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 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 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.
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)
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 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).
MainMenu is the root page (/). It renders the game title, tagline, two action buttons, and mounts TopRightMenu in the top-right corner.
MainMenu
├── TopRightMenu ← persistent icon bar
├── .mainTitle ← "GAMEY" heading + subtitle
└── .mainMenuButtons
├── Log In → navigate("/login")
└── Play as Guest → navigate("/gameSelection", { state: { guest: true } })
-
Log In — sends the user to
/login. -
Play as Guest — navigates to
/gameSelectionwithstate: { guest: true }, whichProtectedRoutereads to allow access without a session.
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.
The form uses the useCsrf() hook, which fetches the token on mount and returns it synchronously to be attached to the submit request.
- Client-side validation (both fields non-empty).
-
POST /api/loginwith{ email, password }andX-CSRF-Tokenheader. - On success: calls
refreshUser()(fetches/api/meto populateUserContext), shows a success message, and redirects to/gameSelectionafter 1.2s. - On failure: displays the error string returned by the server.
The form also includes a <Link to="/register"> for new users.
| 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 |
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.
- Client-side validation (all three fields non-empty).
-
POST /api/registerwith{ email, username, password }andX-CSRF-Tokenheader. - On success: calls
refreshUser()to populateUserContext, shows a message, and redirects after 1.5s. - On failure: displays the error string returned by the server.
The form includes a <Link to="/login"> for existing users.
TopRightMenu is a persistent icon bar rendered in the top-right corner of every page. It manages which overlay panel is currently open.
| 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.
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.
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.
SettingsMenu is an overlay panel with a sidebar tab layout. Each tab renders an independent settings section.
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]);| 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 |
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('/')thenlogout().
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}%)`
}}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.
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'.
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.
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.
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.
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 viaResizeObserver - 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.
UserMenu is a small overlay panel accessible from the user icon in TopRightMenu. It reads session state from UserContext via useUser().
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.
Shows a profile card with the user's first-letter avatar, username, and email. Below it, an editable username field:
-
Display mode — shows
user.usernameand an Edit button. -
Edit mode — replaces the display with a text input plus Save / Cancel buttons. On save, calls
updateUsername(newUsername)fromUserContext, which persists the change viaPOST /api/update-usernameand 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.
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.
SelectionWindow
├── TopRightMenu ← persistent icon bar
├── header "SELECT YOUR GAME MODE"
└── SelectionPanel ← scrollable carousel of mode cards
├── GameModeContainer (NormalMode)
├── GameModeContainer (LocalMode)
└── GameModeContainer (WhyNotMode)
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.
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.
| 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 |
Owns local state for the two configurable parameters:
-
Difficulty — an index bounded between 0 and
length - 1. Only rendered whenmode.showDifficultyistrue. - 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.
GameWindow is the /play/:size/:mode route. It is the main game screen and orchestrates all game logic, server communication, and UI.
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.
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 takenThe 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- On mount,
createGame()callsGameApi.createMatch()to initialize a Redis session and obtain amatch_id. - When the player clicks a cell,
handlePlace()callsGameApi.sendMove(). If the move is valid, the localGamestate is updated. - If the engine returns
game_over: true,handleGameOver()is called. - 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).
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.
When a game ends, handleGameOver():
- Shows a modal with the winner's name and total time.
- If a user is logged in, calls
updateScore()andsaveMatch()(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.
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.