Skip to content
ValleTrapiellaMatiasUO300652 edited this page Mar 19, 2026 · 2 revisions

Webapp

React Router

React Router is the library we use to handle navigation between pages in the web app. Instead of the browser loading a new HTML file on every click, React Router intercepts the URL change and swaps the visible component — keeping the page fast and the app feeling native.

How it fits into the project

In App.tsx, the entire app is wrapped inside a <BrowserRouter> (imported as Router). Inside it, a <Routes> block lists every page of the app. Each <Route> maps a URL path to a React component:

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

When the user visits a URL, React Router matches it against this list and renders only the matching component. Nothing reloads — the rest of the app stays in memory.

URL parameters

The last route uses dynamic segments — note the :size and :mode parts in the path:

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

These are URL parameters. They act as variables in the URL. For example, visiting /play/8/ranked would give us size = "8" and mode = "ranked". Inside GameWindow, these values are read with the useParams hook:

// Inside GameWindow.tsx
import { useParams } from 'react-router-dom';
 
const { size, mode } = useParams();
// size === '8', mode === 'ranked'  (when URL is /play/8/ranked)

Navigating programmatically with useNavigate

Instead of plain <a href="..."> links, we navigate from code using the useNavigate hook. This is what MainMenu does when a button is clicked:

// MainMenu.tsx
import { useNavigate } from 'react-router-dom';
 
const navigate = useNavigate();
 
// On button click:
navigate("/login");
navigate("/gameSelection");

Using navigate() instead of window.location.href keeps the browser history stack intact (so the back button works) and avoids a full page reload.

Quick reference

Concept What it does
<BrowserRouter> Wraps the app and enables URL-based routing
<Routes> Container that picks the first matching <Route>
<Route path=... /> Maps a URL pattern to a component
:param in path Dynamic URL segment, readable via useParams()
useNavigate() Hook that returns a navigate() function for in-code redirects
useParams() Hook that reads :param values from the current URL

CSS Modules

CSS Modules is a technique where each component gets its own scoped stylesheet. Class names are automatically made unique at build time, so styles defined in one file never accidentally affect another component — even if you use the same class name twice across different files.

File naming and import

The convention is ComponentName.module.css. Importing it gives you a styles object whose keys are the class names you wrote:

// MainMenu.tsx
import styles from './MainMenu.module.css';
 
// Then used like:
<div className={styles.mainMenu}>
  <h2 className={styles.mainTitle}>GAMEY</h2>
  <p className={styles.subtitle}>Three sides, one goal</p>
</div>

At runtime, styles.mainMenu becomes something like MainMenu_mainMenu__xK3p2 — a unique string that prevents collisions.

Structure of MainMenu.module.css

The stylesheet is split into logical blocks that map directly to the JSX structure:

Class Purpose
.mainMenu Full-viewport flex column, centres all content vertically and horizontally
.mainTitle Fluid font sizing with clamp() so the title scales with the viewport
.subtitle Uppercase letter-spaced tagline with a dark pill background for contrast
.mainMenuButtons Flex column that groups the action buttons with responsive gap spacing

Targeting third-party components with :global

CSS Modules scopes class names automatically, which is great — but it means you cannot directly target a class that is applied by a child component using its own name. In MainMenu.module.css, the buttons rendered by MenuButtons carry a class called mainMenuOption that comes from outside this module. To style them, we use the :global() escape hatch:

/* MainMenu.module.css */
.mainMenuButtons :global(.mainMenuOption) {
  padding: clamp(0.5rem, 1.5vh, 0.8rem) 2rem;
  min-width: clamp(12rem, 30vw, 15rem);
}
 
.mainMenuButtons :global(.mainMenuOption)[name="Log In"] {
  background-color: rgba(0, 0, 0, 0.88);
  color: #ffffff;
}

The selector reads: "inside the (scoped) .mainMenuButtons container, target any element with the (global) class mainMenuOption." The [name="Log In"] attribute selector then narrows it further to just the Log In button. This pattern lets you style child components without touching their own stylesheets.

Responsive design with clamp() and media queries

Most sizing values use the CSS clamp(min, preferred, max) function. This means the value scales fluidly with the viewport between a minimum and maximum bound — no breakpoint needed for every tiny adjustment:

/* Scales with viewport, but never below 3rem or above 10rem */
font-size: clamp(3rem, 12vmin, 10rem);
 
/* Gap compresses on short screens, expands on tall ones */
gap: clamp(0.5rem, 2vh, 1rem);

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

  • @media (max-width: 48rem) — narrow screens (phones in portrait). Buttons go full-width.
  • @media (max-height: 35rem) — very short screens (phones in landscape). Buttons switch to a horizontal row to save vertical space.

Cookie Management — CookieRetriever.ts

Throughout the app, the logged-in user's data is stored in a browser cookie named user. Its value is a URL-encoded JSON string with two fields: email and username. All cookie reads go through a shared utility file (CookieRetriever.ts) so that no component has to parse the cookie manually.

How the cookie is structured

When a user logs in or registers, the app writes:

const userData = JSON.stringify({ username: "alice", email: "alice@example.com" });
document.cookie = `user=${encodeURIComponent(userData)}; path=/; max-age=86400; SameSite=Lax`;

The cookie lives for 24 hours (max-age=86400). On logout it is cleared by setting max-age=0.

GetEmailFromCookie

Reads the user cookie and returns the stored email address. Returns an empty string "" if the cookie is absent or malformed.

import { GetEmailFromCookie } from '../../../../utils/CookieRetriever';
 
const email = GetEmailFromCookie(); // "alice@example.com"  or  ""

GetUsernameFromCookie

Reads the user cookie and returns the stored username. Returns the string "User" as a safe fallback if the cookie is absent or malformed — this lets components display a generic name rather than crashing.

import { GetUsernameFromCookie } from '../../../../utils/CookieRetriever';
 
const username = GetUsernameFromCookie(); // "alice"  or  "User"

Quick reference

Function Returns when logged in Returns when guest / error
GetEmailFromCookie() User's email string ""
GetUsernameFromCookie() User's username string "User"

Main Menu

MainMenu is the root page of the app (/). It renders the game title, a tagline, and two action buttons. It also mounts the TopRightMenu overlay in the top-right corner, which is present on every page that uses this component.

Structure

MainMenu
├── TopRightMenu          ← persistent icon bar (settings, rankings, user…)
├── .mainTitle            ← "GAMEY" heading + subtitle
└── .mainMenuButtons
    ├── Log In button     → navigate("/login")
    └── Play as Guest     → navigate("/gameSelection")

Navigation

Both buttons use useNavigate from React Router:

  • Log In — sends the user to /login to authenticate.
  • Play as Guest — skips authentication and goes straight to /gameSelection.

There is no session check here. Authenticated state is handled inside the individual destination pages.


Authentication

Login — LoginForm.tsx

LoginForm handles the /login route. On mount it checks whether a user cookie already exists via GetEmailFromCookie(); if one is found the user is redirected straight to /gameSelection without seeing the form.

CSRF protection

Before the form is usable, the component fetches a CSRF token from GET /api/csrf-token. This token is stored in state and attached to every subsequent request as the X-CSRF-Token header.

const res = await fetch(`${API_URL}/api/csrf-token`, { credentials: 'include' });
const { csrfToken } = await res.json();

Submit flow

  1. Basic client-side validation (both fields must be non-empty).
  2. POST /api/login with { email, password } and the CSRF header.
  3. On success the server returns { username, email, message }. The app writes the user cookie:
const userData = JSON.stringify({ username: data.username, email: data.email });
document.cookie = `user=${encodeURIComponent(userData)}; path=/; max-age=86400; SameSite=Lax`;
  1. After 1.5 s the user is redirected to /gameSelection.
  2. On failure, the error message from the server is displayed below the form.

State summary

State variable Purpose
email / password Controlled input values
csrfToken Token fetched on mount, 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

Register — RegisterForm.tsx

RegisterForm handles the /register route. Its structure mirrors LoginForm exactly, with the addition of a username field.

On mount it also checks for an existing user cookie via GetEmailFromCookie() and redirects to /gameSelection if one is found.

Submit flow

  1. Basic client-side validation (all three fields must be non-empty).
  2. POST /api/register with { email, username, password } and the CSRF header.
  3. On success, the app immediately creates a user cookie using the values the user just typed — no second server round-trip needed:
const userData = JSON.stringify({ username, email });
document.cookie = `user=${encodeURIComponent(userData)}; path=/; max-age=86400; SameSite=Lax`;
  1. After 1.5 s the user is redirected to /gameSelection.

The form also includes a <Link to="/login"> for users who already have an account.


Top Right Menu

TopRightMenu is a persistent icon bar rendered in the top-right corner of the main menu. It manages which overlay panel is currently open and delegates rendering to the relevant sub-component.

Icon buttons and their panels

Button Opens
Help (no panel yet — placeholder)
Rankings <Ranking />
Volume Toggles mute state locally, 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.

const [activeMenu, setActiveMenu] = useState<MenuType>(null);
const closeMenu = () => setActiveMenu(null);
 
{activeMenu === 'settings' && <SettingsMenu onClose={closeMenu} />}
{activeMenu === 'rankings' && <Ranking onClose={closeMenu} />}
{activeMenu === 'user'     && <UserMenu onClose={closeMenu} />}

Volume toggle

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


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

This means SettingsMenu never needs to know what any specific section looks like — it just calls section.render(). Adding a new tab requires only creating a new class that implements this interface.

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

The three instances are created with useMemo so they are not re-instantiated on every render:

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

AccountSettings

AccountSettings receives three constructor arguments: isLoggedIn, username, and the navigate function. It renders one of two views depending on login state:

  • Guest — shows account status as "Guest", explains cloud saving, and offers a "Log in" button that navigates to /login.
  • Logged in — shows the current username and a "Log Out" button that clears the user cookie and redirects to /.

AudioSettings

Each volume slider is a self-contained VolumeSlider functional component. It manages its own value in local state and renders a tooltip that is only visible while the slider is 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 that displays leaderboard data. Like Settings, it uses the Strategy Pattern to keep the tab logic generic.

Strategy interfaces

// RankingTypeGlobal.ts
export interface RankingTypeGlobal {
  id: string;
  label: string;
  elements: RankingElementGlobal[];
  render(): React.ReactNode;
}
 
// RankingTypeLocal.ts
export interface RankingTypeLocal {
  id: string;
  label: string;
  elements: RankingElementLocal[];
  render(): React.ReactNode;
}

Both tabs are instantiated with useMemo inside Ranking.tsx:

const rankingTypes = useMemo(() => [
  new LocalRanking(),
  new GlobalRanking()
], []);

Data shape

Global ranking entriesRankingElementGlobal:

Field Type Description
position number Rank position (1, 2, 3…)
player1Name string Player's username
metric string The formatted value shown in the metric column
metricName string Column header label (e.g. "TIME")

RankingElementTime is a concrete implementation of RankingElementGlobal that hard-codes metricName = 'TIME'. It is the class used by GlobalRanking to represent best-time records.

Local ranking entriesRankingElementLocal:

Field Type Description
player1Name string First player
player2Name string Second player
result string Match outcome

GlobalRanking

GlobalRanking.render() mounts a GlobalRankingFetcher component which calls GET /game/bestTimes via the API Gateway on port 3000. The response is an array of records with best_time (in seconds) and username / playerid. Each is mapped to a RankingElementTime, converting the raw seconds to MM:SS format:

const m = Math.floor(score.best_time / 60).toString().padStart(2, '0');
const s = (score.best_time % 60).toString().padStart(2, '0');
new RankingElementTime(index + 1, score.username || score.playerid, `${m}:${s}`);

The result is passed to RankingTableGlobal, which reads data[0].metricName to dynamically set the column header — so if a different RankingElement type is used in the future, the table header updates automatically.

LocalRanking

LocalRanking.render() mounts a LocalRankingFetcher component which calls POST /game/localRankings, sending the current user's email (read with GetEmailFromCookie()) in the request body.

Before rendering the table it checks GetUsernameFromCookie(). If the value is "User" (the fallback returned when no session exists), it shows a "not logged in" message with a login button instead of the table. This avoids making a request with an empty user ID and avoids showing meaningless data to guests.

if (GetUsernameFromCookie() === "User") {
  return <NotLoggedInMessage />;
}
return <RankingTableLocal data={data} title={`Personal Records (${GetUsernameFromCookie()})`} />;

User Menu

UserMenu is a small overlay panel accessible from the user icon in TopRightMenu. It reads the current session from the user cookie via GetEmailFromCookie() / GetUsernameFromCookie() and renders one of two views.

Guest view

If no user cookie is present, the panel shows a message explaining the user is not logged in and a "Go to Login" button that closes the panel and navigates to /login.

Logged-in view

Shows the user's email (read-only) and username (editable inline). Clicking Edit replaces the username display with a text input plus Save / Cancel buttons. On save:

  1. The local user state is updated with the new username.
  2. The user cookie is rewritten with the updated value (24 h expiry).
  3. The edit row is replaced with the display row again.

Note: the save currently only updates the cookie locally. A call to an API endpoint (e.g. /api/updateUsername) would be needed to persist the change server-side.

The Log Out button clears the user cookie and redirects to /.


Game Selection

SelectionWindow is the /gameSelection route. It lets the player pick a game mode and configure its options before starting a match. 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)

SelectionPanel — the carousel

SelectionPanel holds the list of available game modes and renders them as a horizontally scrollable carousel. The modes are instantiated once with useMemo so they are not recreated on every render:

const gameModes = useMemo<GameMode[]>(() => [
  new NormalMode(),
  new LocalMode(),
], []);

Scrolling is handled programmatically via a ref on the viewport <div>. The left and right arrow buttons call scrollRef.current.scrollBy() with behavior: 'smooth', advancing 400 px per click:

const scroll = (direction: 'left' | 'right') => {
  scrollRef.current?.scrollBy({
    left: direction === 'left' ? -400 : 400,
    behavior: 'smooth'
  });
};

GameMode interface

Every game mode is a class that implements the GameMode interface defined in GameMode.ts:

Field Type Purpose
id string Unique identifier for React keys
label string Display name shown in the card header
description string Tooltip text shown when the user clicks ?
mode string URL segment passed to the game route (e.g. "bot", "multi")
size number Default board size
currentLevel Difficulty Selected difficulty level
showDifficulty boolean Whether the difficulty selector is shown on the card
start() () => ReactNode Renders the game container (currently used for structure; navigation is handled by GameModeContainer)

The Difficulty type is a string union with five values: Very Easy, Easy, Normal, Hard, Very Hard.


Available game modes

Class mode value Difficulty selector Default size Description
NormalMode "bot" ✅ shown 8 Single-player vs. a bot
LocalMode "multi" ❌ hidden 8 Local two-player on the same device

GameModeContainer — the mode card

GameModeContainer is the card UI for a single GameMode. It owns the local state for the two configurable parameters:

  • Difficulty — an index into the Difficulty values array, bounded between 0 and length - 1. Only rendered if mode.showDifficulty is true.
  • Size — an integer bounded between 4 (minimum) and 12 (maximum).

Both selectors use the same arrow-button pattern: the arrow is hidden (via visibility: hidden) when the value is already at its limit, so the user never sees a disabled button.

When the player clicks PLAY, the container writes the final values back onto the mode object and navigates to the game route:

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

This produces URLs like /play/8/bot or /play/10/multi, which the <GameWindow> route reads via useParams().

The ? button reveals a tooltip <div> positioned with CSS. It displays mode.description and requires no JS state — visibility is controlled purely with a CSS hover rule.

Clone this wiki locally