feat(frontend): add WebSocket client service with identity and typed messages

This commit is contained in:
Gabriel Franco 2026-09-10 11:54:48 -03:00
parent 66db5b1931
commit 906fe59add
5 changed files with 361 additions and 0 deletions

1
frontend/.env.example Normal file
View file

@ -0,0 +1 @@
VITE_WS_URL=ws://localhost:8080

105
frontend/src/net/client.ts Normal file
View file

@ -0,0 +1,105 @@
// Owns the single WebSocket connection to the multiplayer server. See
// backend/src/PROTOCOL.md for the wire contract this implements.
import { getOrCreatePlayerId, getPlayerName } from './identity';
import type { ClientMessage, ServerMessage } from './messages';
const DEFAULT_WS_URL = 'ws://localhost:8080';
/** `VITE_WS_URL` (see `frontend/.env.example`), defaulting to the local
* dev backend. */
export const WS_URL = import.meta.env.VITE_WS_URL || DEFAULT_WS_URL;
/** Lifecycle of the underlying socket. `closed` covers both a clean
* close and an error — reconnecting is out of scope here, callers that
* care just need to know the connection is gone. */
export type ConnectionState = 'connecting' | 'open' | 'closed';
type ServerMessageOfType<K extends ServerMessage['type']> = Extract<ServerMessage, { type: K }>;
type MessageListener<K extends ServerMessage['type']> = (msg: ServerMessageOfType<K>) => void;
type Unsubscribe = () => void;
/** Thin wrapper around a browser `WebSocket` that speaks the theme-guess
* protocol: sends `identify` as soon as the socket opens, and gives
* callers a typed `send`/`on` API instead of hand-parsing `event.data`. */
export class GameClient {
private socket: WebSocket | null = null;
private state: ConnectionState = 'closed';
private readonly listeners = new Map<string, Set<MessageListener<any>>>();
private readonly stateListeners = new Set<(state: ConnectionState) => void>();
constructor(private readonly url: string = WS_URL) {}
/** Opens the socket and sends `identify` once it's open. */
connect(): void {
this.setState('connecting');
const socket = new WebSocket(this.url);
this.socket = socket;
socket.addEventListener('open', () => {
this.setState('open');
this.send({ type: 'identify', playerId: getOrCreatePlayerId(), name: getPlayerName() });
});
socket.addEventListener('message', (event) => this.handleMessage(event.data));
socket.addEventListener('close', () => this.setState('closed'));
socket.addEventListener('error', () => this.setState('closed'));
}
/** Closes the socket. No reconnect is attempted. */
close(): void {
this.socket?.close();
}
getState(): ConnectionState {
return this.state;
}
/** Sends a typed protocol message as JSON. Throws if the socket isn't
* open — callers should check `getState()` (or react to
* `onStateChange`) before sending. */
send<T extends ClientMessage>(msg: T): void {
if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
throw new Error('GameClient: cannot send, socket is not open');
}
this.socket.send(JSON.stringify(msg));
}
/** Subscribes to one server message type. Returns an unsubscribe
* function. */
on<K extends ServerMessage['type']>(type: K, cb: MessageListener<K>): Unsubscribe {
let set = this.listeners.get(type);
if (!set) {
set = new Set();
this.listeners.set(type, set);
}
set.add(cb);
return () => set!.delete(cb);
}
/** Subscribes to connection lifecycle changes, e.g. to surface a
* "connection lost" state in the UI. Returns an unsubscribe
* function. */
onStateChange(cb: (state: ConnectionState) => void): Unsubscribe {
this.stateListeners.add(cb);
return () => this.stateListeners.delete(cb);
}
private setState(state: ConnectionState): void {
if (this.state === state) return;
this.state = state;
for (const cb of this.stateListeners) cb(state);
}
private handleMessage(data: unknown): void {
if (typeof data !== 'string') return;
let msg: ServerMessage;
try {
msg = JSON.parse(data) as ServerMessage;
} catch {
return;
}
const set = this.listeners.get(msg.type);
if (!set) return;
for (const cb of set) cb(msg);
}
}

View file

@ -0,0 +1,33 @@
// Per-browser player identity. There is no account system (see
// backend/src/PROTOCOL.md's "Identity" section) — just a stable UUID
// generated once and reused, plus a freely editable display name. Both
// are persisted in localStorage so they survive reloads and reconnects.
const PLAYER_ID_KEY = 'themeGuess.playerId';
const PLAYER_NAME_KEY = 'themeGuess.playerName';
/** Returns the persisted `playerId`, generating and storing a new
* `crypto.randomUUID()` the first time this browser is seen. */
export function getOrCreatePlayerId(): string {
const existing = localStorage.getItem(PLAYER_ID_KEY);
if (existing) return existing;
const created = crypto.randomUUID();
localStorage.setItem(PLAYER_ID_KEY, created);
return created;
}
/** Returns the persisted display name, falling back to (and persisting)
* a generated default derived from the player's id so `identify` never
* has to send an empty `name`. */
export function getPlayerName(): string {
const existing = localStorage.getItem(PLAYER_NAME_KEY);
if (existing) return existing;
const fallback = `Player${getOrCreatePlayerId().slice(0, 4)}`;
localStorage.setItem(PLAYER_NAME_KEY, fallback);
return fallback;
}
/** Overwrites the persisted display name. */
export function setPlayerName(name: string): void {
localStorage.setItem(PLAYER_NAME_KEY, name);
}

View file

@ -0,0 +1,212 @@
// Hand-maintained TypeScript twin of the WebSocket JSON contract
// described in `backend/src/PROTOCOL.md`. There is no shared
// package/codegen between frontend and backend (see AGENTS.md), so this
// file only exists to give the frontend compile-time safety around
// message shapes — `backend/src/PROTOCOL.md` is the source of truth;
// keep this in sync with it by hand whenever the protocol changes.
import type { CategoryId, ThemeId } from '../types';
export type TimeMode = 1 | 2;
export type ThemeMode = 'random' | 'chosen';
export type RoomErrorReason = 'not_found' | 'full';
export type RoomClosedReason = 'quit' | 'matchLimit' | 'finished';
// -- Identity -----------------------------------------------------------
/** Client -> server, always the first message on a connection. */
export interface IdentifyMessage {
type: 'identify';
playerId: string;
name: string;
}
/** Server -> client, acknowledges `identify`. */
export interface IdentifyAckMessage {
type: 'identify:ack';
}
// -- Round lifecycle (shared by matchmaking and rooms) -------------------
/** Server -> both players, start of a round. */
export interface RoundStartMessage {
type: 'round:start';
snippetIndex: number;
themeId: ThemeId;
timeMode: TimeMode;
endsAt: number;
}
/** Client -> server -> relayed to opponent; fired per category painted.
* Carries only which category changed, never the color value. */
export interface RoundProgressMessage {
type: 'round:progress';
categoryId: CategoryId;
}
/** Server -> both players, fired once, 20s before `endsAt`. */
export interface RoundTimeWarningMessage {
type: 'round:timeWarning';
}
/** Client -> server, a player's final full guess. */
export interface RoundSubmitMessage {
type: 'round:submit';
colors: Record<CategoryId, string>;
}
/** Server -> both players, sent once `endsAt` has passed. Keyed by both
* players' `playerId`; a player who never submitted may be omitted. */
export interface RoundRevealMessage {
type: 'round:reveal';
colors: Record<string, Record<CategoryId, string>>;
}
/** Server -> both players, private rooms only: next match in the series
* is about to start. */
export interface RoundNextMatchMessage {
type: 'round:nextMatch';
match: number;
}
// -- Matchmaking ----------------------------------------------------------
/** Client -> server, join the matchmaking queue. */
export interface QueueJoinMessage {
type: 'queue:join';
}
/** Client -> server, leave the matchmaking queue. */
export interface QueueLeaveMessage {
type: 'queue:leave';
}
/** Server -> client, sent instead of queuing while matchmaking-banned. */
export interface QueueBannedMessage {
type: 'queue:banned';
bannedUntil: number;
}
/** Server -> both matched players, transition out of the queue. */
export interface MatchFoundMessage {
type: 'match:found';
opponentName: string;
timeMode: TimeMode;
}
// -- Private rooms ----------------------------------------------------------
/** Client -> server, create a private room. */
export interface RoomCreateMessage {
type: 'room:create';
timeMode: TimeMode;
themeMode: ThemeMode;
}
/** Server -> creator, with the room's shareable code. */
export interface RoomCreatedMessage {
type: 'room:created';
code: number;
}
/** Client -> server, attempt to join an existing room by code. */
export interface RoomJoinMessage {
type: 'room:join';
code: number;
}
/** Server -> both players, once a second player joins a room. */
export interface RoomJoinedMessage {
type: 'room:joined';
code: number;
opponentName: string;
timeMode: TimeMode;
themeMode: ThemeMode;
}
/** Server -> the joining client only, when `room:join` fails. */
export interface RoomErrorMessage {
type: 'room:error';
reason: RoomErrorReason;
}
/** Server -> the remaining player, opponent is gone. */
export interface RoomPlayerLeftMessage {
type: 'room:playerLeft';
}
/** Server -> whichever player(s) are still connected, terminal room
* message. */
export interface RoomClosedMessage {
type: 'room:closed';
reason: RoomClosedReason;
}
// -- Theme voting (private rooms with `themeMode: 'chosen'`) --------------
/** Server -> both players, a 10s countdown to pick a theme. */
export interface VoteStartMessage {
type: 'vote:start';
themeIds: ThemeId[];
endsAt: number;
}
/** Client -> server, cast (or re-cast) a vote. */
export interface VoteCastMessage {
type: 'vote:cast';
themeId: ThemeId;
}
/** Server -> the other player, live relay of an accepted vote. */
export interface VoteOpponentChoiceMessage {
type: 'vote:opponentChoice';
themeId: ThemeId;
}
/** Server -> both players, once voting's `endsAt` elapses. */
export interface VoteSettledMessage {
type: 'vote:settled';
themeId: ThemeId;
agreed: boolean;
}
// -- Quit -------------------------------------------------------------------
/** Client -> server, explicit leave (also synthesized server-side on an
* unexpected socket close). */
export interface PlayerQuitMessage {
type: 'player:quit';
}
// -- Unions -----------------------------------------------------------------
/** Messages this client ever sends to the server. */
export type ClientMessage =
| IdentifyMessage
| QueueJoinMessage
| QueueLeaveMessage
| RoomCreateMessage
| RoomJoinMessage
| RoundProgressMessage
| RoundSubmitMessage
| VoteCastMessage
| PlayerQuitMessage;
/** Messages this client ever receives from the server. */
export type ServerMessage =
| IdentifyAckMessage
| QueueBannedMessage
| MatchFoundMessage
| RoundStartMessage
| RoundProgressMessage
| RoundTimeWarningMessage
| RoundRevealMessage
| RoundNextMatchMessage
| RoomCreatedMessage
| RoomJoinedMessage
| RoomErrorMessage
| RoomPlayerLeftMessage
| RoomClosedMessage
| VoteStartMessage
| VoteOpponentChoiceMessage
| VoteSettledMessage;

10
frontend/src/vite-env.d.ts vendored Normal file
View file

@ -0,0 +1,10 @@
/// <reference types="vite/client" />
interface ImportMetaEnv {
/** Multiplayer WebSocket server URL. See `.env.example`. */
readonly VITE_WS_URL?: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}