Skip to content

Commit b906ad5

Browse files
committed
feat: multiplayer reconnection with 20s grace period on disconnect
1 parent c09c4dd commit b906ad5

15 files changed

Lines changed: 690 additions & 30 deletions

File tree

apps/backend/internal/ws/game.go

Lines changed: 296 additions & 25 deletions
Large diffs are not rendered by default.

apps/backend/internal/ws/hub.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -247,6 +247,14 @@ func (h *Hub) HandleMessage(client *Client, msg *ClientMessage) {
247247
}
248248
h.games.LeaveGame(client, data.GameID)
249249

250+
case MsgTypeGameReconnect:
251+
var data GameReconnectData
252+
if err := json.Unmarshal(msg.Data, &data); err != nil {
253+
client.SendMessage(NewErrorMessage("INVALID_DATA", "Invalid reconnect data"))
254+
return
255+
}
256+
h.games.HandleReconnect(client, data.GameID)
257+
250258
case MsgTypeLobbySubscribe:
251259
h.SubscribeLobby(client)
252260

apps/backend/internal/ws/message.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@ const (
2020
MsgTypeMove = "MOVE"
2121
MsgTypeResign = "RESIGN"
2222

23+
// Reconnection
24+
MsgTypeGameReconnect = "GAME_RECONNECT"
25+
2326
// Lobby
2427
MsgTypeLobbySubscribe = "LOBBY_SUBSCRIBE"
2528
MsgTypeLobbyUnsubscribe = "LOBBY_UNSUBSCRIBE"
@@ -49,6 +52,11 @@ const (
4952
MsgTypeOpponentLeft = "OPPONENT_LEFT"
5053
MsgTypeTimeUpdate = "TIME_UPDATE"
5154

55+
// Reconnection responses
56+
MsgTypeGameReconnected = "GAME_RECONNECTED"
57+
MsgTypeOpponentDisconnected = "OPPONENT_DISCONNECTED"
58+
MsgTypeOpponentReconnected = "OPPONENT_RECONNECTED"
59+
5260
// Lobby responses
5361
MsgTypeLobbyList = "LOBBY_LIST"
5462
MsgTypeLobbyUpdate = "LOBBY_UPDATE"
@@ -183,6 +191,24 @@ type ResignData struct {
183191
GameID string `json:"gameId"`
184192
}
185193

194+
// GameReconnectData is sent by client to reconnect to a game
195+
type GameReconnectData struct {
196+
GameID string `json:"gameId"`
197+
}
198+
199+
// GameReconnectedData is sent to client with full game state on reconnection
200+
type GameReconnectedData struct {
201+
GameID string `json:"gameId"`
202+
Color string `json:"color"`
203+
FEN string `json:"fen"`
204+
MoveHistory []string `json:"moveHistory"`
205+
WhiteTimeMs int `json:"whiteTimeMs"`
206+
BlackTimeMs int `json:"blackTimeMs"`
207+
TimeControl *TimeControl `json:"timeControl,omitempty"`
208+
Opponent PlayerInfo `json:"opponent"`
209+
Rated bool `json:"rated"`
210+
}
211+
186212
// TimeUpdateData is sent periodically to update clocks
187213
type TimeUpdateData struct {
188214
GameID string `json:"gameId"`

apps/frontend/src/components/play/PlayContainer/PlayContainer.tsx

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { useParams, useNavigate, useLocation } from '@solidjs/router';
2-
import { createEffect, on, Show, type ParentComponent } from 'solid-js';
2+
import { createEffect, on, onCleanup, Show, type ParentComponent } from 'solid-js';
3+
import { loadActiveGame, clearActiveGame } from '../../../services/sync/reconnectStore';
34
import { PlayGameProvider, usePlayGame } from '../../../store/game/PlayGameContext';
45
import { type StartGameOptions, type MultiplayerGameOptions } from '../../../types/game';
56
import ChessBoardController from '../../chess/ChessBoardController/ChessBoardController';
@@ -56,7 +57,13 @@ const PlayContainerInner: ParentComponent = () => {
5657
!multiplayer.state.isWaiting &&
5758
chess.state.lifecycle !== 'playing'
5859
) {
59-
actions.joinMultiplayerGame(gameId);
60+
const activeGame = loadActiveGame();
61+
if (activeGame && activeGame.gameId === gameId) {
62+
actions.reconnectToGame(gameId, activeGame.playerColor);
63+
} else {
64+
clearActiveGame();
65+
actions.joinMultiplayerGame(gameId);
66+
}
6067
}
6168
}
6269
)
@@ -73,6 +80,15 @@ const PlayContainerInner: ParentComponent = () => {
7380
)
7481
);
7582

83+
const unsub = multiplayer.on('game:error', () => {
84+
if (chess.state.lifecycle !== 'playing' && chess.state.lifecycle !== 'ended') {
85+
clearActiveGame();
86+
actions.exitGame();
87+
navigate('/play', { replace: true });
88+
}
89+
});
90+
onCleanup(unsub);
91+
7692
const isIdle = () => chess.state.lifecycle === 'idle' && !params.gameId;
7793
const isReviewing = () => review.phase() !== 'idle';
7894

apps/frontend/src/components/play/PlayControlPanel/PlayControlPanel.module.css

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,32 @@
1+
.disconnectBanner {
2+
display: flex;
3+
align-items: center;
4+
justify-content: center;
5+
gap: var(--space-sm);
6+
width: 100%;
7+
padding: var(--space-sm) var(--space-md);
8+
background: var(--warning-bg, rgba(255, 165, 0, 0.1));
9+
border: 1px solid var(--warning, #f0a030);
10+
border-radius: var(--radius-md);
11+
color: var(--warning, #f0a030);
12+
font-family: var(--font-body);
13+
font-size: var(--text-sm);
14+
font-weight: 500;
15+
margin-bottom: var(--space-sm);
16+
}
17+
18+
.disconnectCountdown {
19+
font-variant-numeric: tabular-nums;
20+
font-weight: 700;
21+
min-width: 2.2em;
22+
text-align: center;
23+
padding: 0.1em 0.4em;
24+
background: var(--warning, #f0a030);
25+
color: var(--surface-1, #1a1a2e);
26+
border-radius: var(--radius-sm);
27+
font-size: var(--text-xs);
28+
}
29+
130
.playControlPanel {
231
display: flex;
332
width: 100%;

apps/frontend/src/components/play/PlayControlPanel/PlayControlPanel.tsx

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,48 @@
1+
import { createEffect, createSignal, on, onCleanup, Show, type ParentComponent } from 'solid-js';
12
import { usePlayGame } from '../../../store/game/PlayGameContext';
23
import ButtonPanel from '../../game/ButtonPanel/ButtonPanel';
34
import GameInfoPanel from '../../game/GameInfoPanel/GameInfoPanel';
45
import GamePanelButton from '../../game/GamePanelButton/GamePanelButton';
56
import styles from './PlayControlPanel.module.css';
6-
import type { ParentComponent } from 'solid-js';
7+
8+
const RECONNECT_GRACE_SECONDS = 20;
79

810
const PlayControlPanel: ParentComponent = () => {
9-
const { chess, engine, actions, derived } = usePlayGame();
11+
const { chess, ui, engine, actions, derived } = usePlayGame();
12+
13+
const [countdown, setCountdown] = createSignal(0);
14+
let countdownInterval: ReturnType<typeof setInterval> | undefined;
15+
16+
const clearCountdown = () => {
17+
if (countdownInterval !== undefined) {
18+
clearInterval(countdownInterval);
19+
countdownInterval = undefined;
20+
}
21+
setCountdown(0);
22+
};
23+
24+
createEffect(
25+
on(
26+
() => ui.state.opponentDisconnected,
27+
(disconnected) => {
28+
clearCountdown();
29+
if (disconnected) {
30+
setCountdown(RECONNECT_GRACE_SECONDS);
31+
countdownInterval = setInterval(() => {
32+
setCountdown((prev) => {
33+
if (prev <= 1) {
34+
clearCountdown();
35+
return 0;
36+
}
37+
return prev - 1;
38+
});
39+
}, 1000);
40+
}
41+
}
42+
)
43+
);
44+
45+
onCleanup(clearCountdown);
1046

1147
const handleResign = () => {
1248
if (!derived.isPlaying()) return;
@@ -15,6 +51,12 @@ const PlayControlPanel: ParentComponent = () => {
1551

1652
return (
1753
<div>
54+
<Show when={ui.state.opponentDisconnected}>
55+
<div class={styles.disconnectBanner}>
56+
<span>Opponent disconnected</span>
57+
<span class={styles.disconnectCountdown}>{countdown()}s</span>
58+
</div>
59+
</Show>
1860
<div class={styles.playControlPanel}>
1961
<ButtonPanel>
2062
<GamePanelButton onClick={handleResign} disabled={!derived.isPlaying()}>

apps/frontend/src/services/sync/GameSyncService.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type {
1313
MoveRejectedData,
1414
OpponentMoveData,
1515
GameEndedData,
16+
GameReconnectedData,
1617
TimeUpdateData,
1718
OpponentLeftData,
1819
ErrorData,
@@ -152,6 +153,13 @@ export class GameSyncService {
152153
}
153154
}
154155

156+
reconnectGame(gameId: string): void {
157+
this.send({
158+
type: MT.GAME_RECONNECT,
159+
data: { gameId },
160+
});
161+
}
162+
155163
sendMove(gameId: string, from: string, to: string, promotion?: string): void {
156164
this.send({
157165
type: MT.MOVE,
@@ -297,6 +305,35 @@ export class GameSyncService {
297305
break;
298306
}
299307

308+
case MT.GAME_RECONNECTED: {
309+
const payload = data as GameReconnectedData;
310+
this.currentGameId = payload.gameId;
311+
this.emitEvent({
312+
type: 'game:reconnected',
313+
data: payload,
314+
timestamp: Date.now(),
315+
});
316+
break;
317+
}
318+
319+
case MT.OPPONENT_DISCONNECTED: {
320+
this.emitEvent({
321+
type: 'game:opponent_disconnected',
322+
data,
323+
timestamp: Date.now(),
324+
});
325+
break;
326+
}
327+
328+
case MT.OPPONENT_RECONNECTED: {
329+
this.emitEvent({
330+
type: 'game:opponent_reconnected',
331+
data,
332+
timestamp: Date.now(),
333+
});
334+
break;
335+
}
336+
300337
case MT.TIME_UPDATE: {
301338
const payload = data as TimeUpdateData;
302339
this.emitEvent({
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import type { Side } from '../../types/game';
2+
3+
const RECONNECT_KEY = 'nxtchess:active_game';
4+
5+
interface ActiveGameInfo {
6+
gameId: string;
7+
playerColor: Side;
8+
}
9+
10+
export function saveActiveGame(info: ActiveGameInfo): void {
11+
try {
12+
sessionStorage.setItem(RECONNECT_KEY, JSON.stringify(info));
13+
} catch {
14+
/* noop */
15+
}
16+
}
17+
18+
export function loadActiveGame(): ActiveGameInfo | null {
19+
try {
20+
const raw = sessionStorage.getItem(RECONNECT_KEY);
21+
if (raw) return JSON.parse(raw);
22+
} catch {
23+
/* noop */
24+
}
25+
return null;
26+
}
27+
28+
export function clearActiveGame(): void {
29+
try {
30+
sessionStorage.removeItem(RECONNECT_KEY);
31+
} catch {
32+
/* noop */
33+
}
34+
}

apps/frontend/src/services/sync/types.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,26 @@ export interface OpponentLeftData {
120120
gameId: string;
121121
}
122122

123+
export interface GameReconnectedData {
124+
gameId: string;
125+
color: 'white' | 'black';
126+
fen: string;
127+
moveHistory: string[];
128+
whiteTimeMs: number;
129+
blackTimeMs: number;
130+
timeControl?: TimeControl;
131+
opponent: PlayerInfo;
132+
rated: boolean;
133+
}
134+
135+
export interface OpponentDisconnectedData {
136+
gameId: string;
137+
}
138+
139+
export interface OpponentReconnectedData {
140+
gameId: string;
141+
}
142+
123143
export interface ErrorData {
124144
code: string;
125145
message: string;
@@ -159,6 +179,9 @@ export const MsgType = {
159179
MOVE: 'MOVE',
160180
RESIGN: 'RESIGN',
161181

182+
// Client → Server (reconnection)
183+
GAME_RECONNECT: 'GAME_RECONNECT',
184+
162185
// Client → Server (lobby)
163186
LOBBY_SUBSCRIBE: 'LOBBY_SUBSCRIBE',
164187
LOBBY_UNSUBSCRIBE: 'LOBBY_UNSUBSCRIBE',
@@ -178,6 +201,11 @@ export const MsgType = {
178201
OPPONENT_LEFT: 'OPPONENT_LEFT',
179202
TIME_UPDATE: 'TIME_UPDATE',
180203

204+
// Server → Client (reconnection)
205+
GAME_RECONNECTED: 'GAME_RECONNECTED',
206+
OPPONENT_DISCONNECTED: 'OPPONENT_DISCONNECTED',
207+
OPPONENT_RECONNECTED: 'OPPONENT_RECONNECTED',
208+
181209
// Server → Client (lobby)
182210
LOBBY_LIST: 'LOBBY_LIST',
183211
LOBBY_UPDATE: 'LOBBY_UPDATE',
@@ -196,6 +224,9 @@ export type SyncEventType =
196224
| 'game:opponent_move'
197225
| 'game:opponent_left'
198226
| 'game:time_update'
227+
| 'game:reconnected'
228+
| 'game:opponent_disconnected'
229+
| 'game:opponent_reconnected'
199230
| 'lobby:list'
200231
| 'lobby:update'
201232
| 'error';

0 commit comments

Comments
 (0)