-
Notifications
You must be signed in to change notification settings - Fork 2
Webapp
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.
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.
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)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.
| 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 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.
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.
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 |
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.
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.
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.
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.
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 ""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"| Function | Returns when logged in | Returns when guest / error |
|---|---|---|
GetEmailFromCookie() |
User's email string | "" |
GetUsernameFromCookie() |
User's username string | "User" |
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.
MainMenu
├── TopRightMenu ← persistent icon bar (settings, rankings, user…)
├── .mainTitle ← "GAMEY" heading + subtitle
└── .mainMenuButtons
├── Log In button → navigate("/login")
└── Play as Guest → navigate("/gameSelection")
Both buttons use useNavigate from React Router:
-
Log In — sends the user to
/loginto 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.
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.
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();- Basic client-side validation (both fields must be non-empty).
-
POST /api/loginwith{ email, password }and the CSRF header. - On success the server returns
{ username, email, message }. The app writes theusercookie:
const userData = JSON.stringify({ username: data.username, email: data.email });
document.cookie = `user=${encodeURIComponent(userData)}; path=/; max-age=86400; SameSite=Lax`;- After 1.5 s the user is redirected to
/gameSelection. - On failure, the error message from the server is displayed below the form.
| 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 |
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.
- Basic client-side validation (all three fields must be non-empty).
-
POST /api/registerwith{ email, username, password }and the CSRF header. - On success, the app immediately creates a
usercookie 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`;- 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.
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.
| 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} />}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.
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;
}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.
| 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 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
usercookie and redirects to/.
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}%)`
}}Ranking is an overlay panel that displays leaderboard data. Like Settings, it uses the Strategy Pattern to keep the tab logic generic.
// 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()
], []);Global ranking entries — RankingElementGlobal:
| 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 entries — RankingElementLocal:
| Field | Type | Description |
|---|---|---|
player1Name |
string |
First player |
player2Name |
string |
Second player |
result |
string |
Match outcome |
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.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()})`} />;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.
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.
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:
- The local
userstate is updated with the new username. - The
usercookie is rewritten with the updated value (24 h expiry). - 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 /.
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.
SelectionWindow
├── TopRightMenu ← persistent icon bar
├── header "SELECT YOUR GAME MODE"
└── SelectionPanel ← scrollable carousel of mode cards
├── GameModeContainer (NormalMode)
└── GameModeContainer (LocalMode)
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'
});
};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.
| 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 is the card UI for a single GameMode. It owns the local state for the two configurable parameters:
-
Difficulty — an index into the
Difficultyvalues array, bounded between0andlength - 1. Only rendered ifmode.showDifficultyistrue. -
Size — an integer bounded between
4(minimum) and12(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.