From 6ed99ea8031f8d0aa660bc51a56d91d974d4b5cd Mon Sep 17 00:00:00 2001
From: AyoubBenziza
Date: Thu, 11 Jun 2026 17:26:22 +0200
Subject: [PATCH 1/2] =?UTF-8?q?feat:=20reconnection=20=E2=80=94=20keep=20y?=
=?UTF-8?q?our=20place=20across=20refresh=20/=20network=20drop?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- each player gets a reconnectToken; room:create/join return it
- new room:rejoin event: re-attaches the existing player (by id+token) to the
new socket, broadcasts player:rejoined + room:state, and resyncs the current
round to that socket (engine.resync re-emits round:start with their view)
- client persists the session (roomCode/playerId/token/name) in localStorage;
bootstraps the store from it and auto-rejoins on every socket connect;
clears it on reset/quit or failed rejoin
Verified e2e: wrong token rejected, correct token re-attaches (player present
+ connected).
Co-Authored-By: Claude Opus 4.8 (1M context)
---
apps/server/src/game/engine.ts | 19 ++++++++++++++
apps/server/src/game/round.ts | 2 ++
apps/server/src/handlers.ts | 37 ++++++++++++++++++++++++--
apps/server/src/rooms.ts | 10 +++++++-
apps/web/src/lib/session.ts | 34 ++++++++++++++++++++++++
apps/web/src/store/room.ts | 47 +++++++++++++++++++++++++++++-----
packages/shared/src/events.ts | 9 +++++++
7 files changed, 149 insertions(+), 9 deletions(-)
create mode 100644 apps/web/src/lib/session.ts
diff --git a/apps/server/src/game/engine.ts b/apps/server/src/game/engine.ts
index c0097b0..66137f8 100644
--- a/apps/server/src/game/engine.ts
+++ b/apps/server/src/game/engine.ts
@@ -24,6 +24,8 @@ interface RoundRuntime {
startedAt: number
endsAt: number
data: unknown
+ /** Payload public de la manche (pour la reconnexion). */
+ payload: unknown
}
export interface GameEngineOptions {
@@ -137,6 +139,22 @@ export class GameEngine implements RoomGameController {
})
}
+ /** Reconnexion : re-pousse la manche en cours au socket d'un joueur. */
+ resync(socketId: string, playerId: string): void {
+ if (!this.runtime || this.room.status !== "in_round") {
+ return
+ }
+ const r = this.runtime
+ this.io.to(socketId).emit("round:start", {
+ type: r.round.type,
+ djId: r.djId === playerId ? r.djId : undefined,
+ mine: r.secretPlayerId === playerId ? true : undefined,
+ startsAt: r.startedAt,
+ endsAt: r.endsAt,
+ payload: r.payload,
+ })
+ }
+
private async playRound(config: RoundConfig): Promise {
const round = this.createRound(config.type)
const start = round.start(this.room)
@@ -154,6 +172,7 @@ export class GameEngine implements RoomGameController {
startedAt,
endsAt,
data: start.data,
+ payload: start.payload,
}
this.room.status = "in_round"
this.broadcastState()
diff --git a/apps/server/src/game/round.ts b/apps/server/src/game/round.ts
index 46be5e9..e9a59a6 100644
--- a/apps/server/src/game/round.ts
+++ b/apps/server/src/game/round.ts
@@ -73,4 +73,6 @@ export interface RoomGameController {
handleVote(playerId: string, answer: Answer): void
/** Relais média du DJ (blindtest) : rebroadcast media:sync à toute la room. */
handleMediaControl(playerId: string, payload: MediaControlPayload): void
+ /** Reconnexion : re-pousse la manche en cours au socket d'un joueur. */
+ resync(socketId: string, playerId: string): void
}
diff --git a/apps/server/src/handlers.ts b/apps/server/src/handlers.ts
index 2a39854..8aa2cd4 100644
--- a/apps/server/src/handlers.ts
+++ b/apps/server/src/handlers.ts
@@ -60,7 +60,7 @@ export function registerRoomHandlers(
socket.data.playerId = player.id
socket.data.roomCode = room.code
socket.join(room.code)
- ack({ ok: true, data: { roomCode: room.code, playerId: player.id } })
+ ack({ ok: true, data: { roomCode: room.code, playerId: player.id, token: player.reconnectToken } })
broadcastState(room)
} catch (err) {
ack({ ok: false, error: toErrorPayload(err) })
@@ -75,13 +75,46 @@ export function registerRoomHandlers(
socket.data.playerId = player.id
socket.data.roomCode = room.code
socket.join(room.code)
- ack({ ok: true, data: { roomCode: room.code, playerId: player.id } })
+ ack({ ok: true, data: { roomCode: room.code, playerId: player.id, token: player.reconnectToken } })
broadcastState(room)
} catch (err) {
ack({ ok: false, error: toErrorPayload(err) })
}
})
+ socket.on("room:rejoin", (payload, ack) => {
+ const code =
+ typeof payload?.roomCode === "string"
+ ? payload.roomCode.trim().toUpperCase()
+ : ""
+ const room = rooms.get(code)
+ const player = room?.players.get(payload?.playerId ?? "")
+ if (!room || !player || player.reconnectToken !== payload?.token) {
+ ack({
+ ok: false,
+ error: { code: "REJOIN_FAILED", message: "Reconnexion impossible." },
+ })
+ return
+ }
+ player.connected = true
+ player.socketId = socket.id
+ socket.data.playerId = player.id
+ socket.data.roomCode = room.code
+ socket.join(room.code)
+ ack({
+ ok: true,
+ data: {
+ roomCode: room.code,
+ playerId: player.id,
+ token: player.reconnectToken,
+ },
+ })
+ io.to(room.code).emit("player:rejoined", { playerId: player.id })
+ broadcastState(room)
+ // Re-pousse la manche en cours au socket reconnecté (si partie en cours).
+ room.game?.resync(socket.id, player.id)
+ })
+
socket.on("player:setName", (payload) => {
const code = socket.data.roomCode
const playerId = socket.data.playerId
diff --git a/apps/server/src/rooms.ts b/apps/server/src/rooms.ts
index 96be50b..1aa79f3 100644
--- a/apps/server/src/rooms.ts
+++ b/apps/server/src/rooms.ts
@@ -16,6 +16,8 @@ export interface ServerPlayer {
name: string
connected: boolean
socketId: string | null
+ /** Secret de reconnexion (refresh/coupure). */
+ reconnectToken: string
}
/** Titre soumis pour le blindtest. `submittedBy` reste secret jusqu'au reveal. */
@@ -138,7 +140,13 @@ export class RoomManager {
}
private makePlayer(name: string, socketId: string): ServerPlayer {
- return { id: randomUUID(), name, connected: true, socketId }
+ return {
+ id: randomUUID(),
+ name,
+ connected: true,
+ socketId,
+ reconnectToken: randomUUID(),
+ }
}
private generateCode(): string {
diff --git a/apps/web/src/lib/session.ts b/apps/web/src/lib/session.ts
new file mode 100644
index 0000000..f707914
--- /dev/null
+++ b/apps/web/src/lib/session.ts
@@ -0,0 +1,34 @@
+// Session locale pour la reconnexion (refresh / coupure réseau).
+
+const KEY = "nerdware-session"
+
+export interface Session {
+ roomCode: string
+ playerId: string
+ token: string
+ name: string
+}
+
+export function getSession(): Session | null {
+ try {
+ const raw = localStorage.getItem(KEY)
+ return raw ? (JSON.parse(raw) as Session) : null
+ } catch {
+ return null
+ }
+}
+
+export function setSession(session: Session): void {
+ localStorage.setItem(KEY, JSON.stringify(session))
+}
+
+export function patchSession(patch: Partial): void {
+ const current = getSession()
+ if (current) {
+ setSession({ ...current, ...patch })
+ }
+}
+
+export function clearSession(): void {
+ localStorage.removeItem(KEY)
+}
diff --git a/apps/web/src/store/room.ts b/apps/web/src/store/room.ts
index c8ecbfe..5cb55f0 100644
--- a/apps/web/src/store/room.ts
+++ b/apps/web/src/store/room.ts
@@ -17,6 +17,14 @@ import {
type VoteAckPayload,
} from "@nerdware/shared"
import { socket } from "@/lib/socket"
+import {
+ clearSession,
+ getSession,
+ patchSession,
+ setSession,
+} from "@/lib/session"
+
+const saved = getSession()
/** Manche en cours côté client (depuis round:start). */
export interface ActiveRound {
@@ -80,9 +88,9 @@ interface RoomState {
export const useRoomStore = create((set, get) => ({
connected: socket.connected,
- playerId: null,
- roomCode: null,
- pseudoSet: false,
+ playerId: saved?.playerId ?? null,
+ roomCode: saved?.roomCode ?? null,
+ pseudoSet: !!saved?.name,
snapshot: null,
lastError: null,
round: null,
@@ -103,6 +111,7 @@ export const useRoomStore = create((set, get) => ({
new Promise((resolve, reject) => {
socket.emit("room:create", {}, (res) => {
if (res.ok) {
+ setSession({ ...res.data, name: "" })
set({
playerId: res.data.playerId,
roomCode: res.data.roomCode,
@@ -120,6 +129,7 @@ export const useRoomStore = create((set, get) => ({
new Promise((resolve, reject) => {
socket.emit("room:join", { roomCode }, (res) => {
if (res.ok) {
+ setSession({ ...res.data, name: "" })
set({
playerId: res.data.playerId,
roomCode: res.data.roomCode,
@@ -138,6 +148,7 @@ export const useRoomStore = create((set, get) => ({
return
}
socket.emit("player:setName", { name: name.trim() })
+ patchSession({ name: name.trim() })
set({ pseudoSet: true })
},
@@ -212,7 +223,8 @@ export const useRoomStore = create((set, get) => ({
boom: () => set((s) => ({ boomKey: s.boomKey + 1 })),
clearError: () => set({ lastError: null }),
- reset: () =>
+ reset: () => {
+ clearSession()
set({
roomCode: null,
pseudoSet: false,
@@ -231,11 +243,34 @@ export const useRoomStore = create((set, get) => ({
roundKey: 0,
roundKind: "quiz",
roundModeChanged: false,
- }),
+ })
+ },
}))
// Listeners socket → on pousse l'état serveur dans le store (source de vérité).
-socket.on("connect", () => useRoomStore.setState({ connected: true }))
+socket.on("connect", () => {
+ useRoomStore.setState({ connected: true })
+ // Reconnexion : on retente de réintégrer la room mémorisée (refresh/coupure).
+ const s = getSession()
+ if (!s) {
+ return
+ }
+ socket.emit(
+ "room:rejoin",
+ { roomCode: s.roomCode, playerId: s.playerId, token: s.token },
+ (res) => {
+ if (res.ok) {
+ useRoomStore.setState({
+ roomCode: res.data.roomCode,
+ playerId: res.data.playerId,
+ pseudoSet: !!s.name,
+ })
+ } else {
+ useRoomStore.getState().reset()
+ }
+ }
+ )
+})
socket.on("disconnect", () => useRoomStore.setState({ connected: false }))
socket.on("room:state", (snapshot) =>
useRoomStore.setState(
diff --git a/packages/shared/src/events.ts b/packages/shared/src/events.ts
index 62cd0df..eabf5d3 100644
--- a/packages/shared/src/events.ts
+++ b/packages/shared/src/events.ts
@@ -23,6 +23,14 @@ export interface RoomCreatePayload {
export interface RoomCreatedPayload {
roomCode: string
playerId: string
+ /** Jeton secret pour se reconnecter après un refresh/coupure. */
+ token: string
+}
+
+export interface RejoinPayload {
+ roomCode: string
+ playerId: string
+ token: string
}
export interface RoomJoinPayload {
@@ -139,6 +147,7 @@ export type Ack = (result: { ok: true; data: T } | { ok: false; error: ErrorP
export interface ClientToServerEvents {
"room:create": (payload: RoomCreatePayload, ack: Ack) => void
"room:join": (payload: RoomJoinPayload, ack: Ack) => void
+ "room:rejoin": (payload: RejoinPayload, ack: Ack) => void
"player:setName": (payload: SetNamePayload) => void
"lobby:updateSettings": (payload: UpdateSettingsPayload) => void
"lobby:return": () => void
From f0c327617b120b676ff7b3a9cf437e60c3827dfa Mon Sep 17 00:00:00 2001
From: AyoubBenziza
Date: Thu, 11 Jun 2026 17:33:10 +0200
Subject: [PATCH 2/2] feat: leave-room button
- room:leave event: removes the player from the room (not just disconnected),
reassigns host if the host leaves, deletes the room when empty
- store leaveRoom() emits it and clears the session (reset)
- room header gets a "Quitter" button (always available); end-screen "Quitter"
now also leaves cleanly
Verified e2e: leaver removed from snapshot, host reassigned to the next player.
Co-Authored-By: Claude Opus 4.8 (1M context)
---
apps/server/src/handlers.ts | 16 +++++++++++++++
apps/server/src/rooms.ts | 22 ++++++++++++++++++++
apps/web/src/components/game-end-view.tsx | 4 ++--
apps/web/src/pages/room.tsx | 25 ++++++++++++++++++-----
apps/web/src/store/room.ts | 6 ++++++
packages/shared/src/events.ts | 1 +
6 files changed, 67 insertions(+), 7 deletions(-)
diff --git a/apps/server/src/handlers.ts b/apps/server/src/handlers.ts
index 8aa2cd4..8ab1faf 100644
--- a/apps/server/src/handlers.ts
+++ b/apps/server/src/handlers.ts
@@ -324,6 +324,22 @@ export function registerRoomHandlers(
broadcastState(room)
})
+ socket.on("room:leave", () => {
+ const code = socket.data.roomCode
+ const playerId = socket.data.playerId
+ if (!code || !playerId) {
+ return
+ }
+ socket.leave(code)
+ socket.data.roomCode = undefined
+ socket.data.playerId = undefined
+ const room = rooms.leave(code, playerId)
+ if (room) {
+ io.to(room.code).emit("player:left", { playerId })
+ broadcastState(room)
+ }
+ })
+
socket.on("disconnect", () => {
const affected = rooms.handleDisconnect(socket.id)
if (!affected) {
diff --git a/apps/server/src/rooms.ts b/apps/server/src/rooms.ts
index 1aa79f3..d32dd67 100644
--- a/apps/server/src/rooms.ts
+++ b/apps/server/src/rooms.ts
@@ -94,6 +94,28 @@ export class RoomManager {
return this.rooms.get(code)
}
+ /**
+ * Retire un joueur d'une room (départ volontaire). Réassigne l'hôte si besoin,
+ * supprime la room si plus personne. Renvoie la room (ou null si supprimée).
+ */
+ leave(code: string, playerId: string): ServerRoom | null {
+ const room = this.rooms.get(code)
+ if (!room) {
+ return null
+ }
+ room.players.delete(playerId)
+ room.scores.delete(playerId)
+ if (room.players.size === 0) {
+ this.rooms.delete(code)
+ return null
+ }
+ if (room.hostId === playerId) {
+ const named = [...room.players.values()].find((p) => p.name !== "")
+ room.hostId = (named ?? [...room.players.values()][0]).id
+ }
+ return room
+ }
+
/**
* Marque le joueur d'un socket comme déconnecté.
* Retourne la room affectée et le playerId, ou null si non rattaché.
diff --git a/apps/web/src/components/game-end-view.tsx b/apps/web/src/components/game-end-view.tsx
index 5e5bd39..ce64d4d 100644
--- a/apps/web/src/components/game-end-view.tsx
+++ b/apps/web/src/components/game-end-view.tsx
@@ -133,7 +133,7 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) {
const finalScores = useRoomStore((s) => s.finalScores)
const gameTracks = useRoomStore((s) => s.gameTracks)
const gameRecap = useRoomStore((s) => s.gameRecap)
- const reset = useRoomStore((s) => s.reset)
+ const leaveRoom = useRoomStore((s) => s.leaveRoom)
const returnToLobby = useRoomStore((s) => s.returnToLobby)
const isHost = snapshot.hostId === playerId
@@ -284,7 +284,7 @@ export function GameEndView({ snapshot }: { snapshot: RoomSnapshot }) {
)}
-