Skip to content

Commit 85f5a43

Browse files
feat: implement player status updates and online/offline indicators in game
1 parent c23ee77 commit 85f5a43

5 files changed

Lines changed: 166 additions & 11 deletions

File tree

backend/game/game.go

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,11 +53,13 @@ func (g *Game) Start() {
5353
Username: g.Player1.Username,
5454
Symbol: 1,
5555
Type: getPlayerType(g.Player1),
56+
IsOnline: true,
5657
},
5758
Opponent: PlayerInfo{
5859
Username: g.Player2.Username,
5960
Symbol: 2,
6061
Type: getPlayerType(g.Player2),
62+
IsOnline: true,
6163
},
6264
YourTurn: true,
6365
},
@@ -74,11 +76,13 @@ func (g *Game) Start() {
7476
Username: g.Player2.Username,
7577
Symbol: 2,
7678
Type: getPlayerType(g.Player2),
79+
IsOnline: true,
7780
},
7881
Opponent: PlayerInfo{
7982
Username: g.Player1.Username,
8083
Symbol: 1,
8184
Type: getPlayerType(g.Player1),
85+
IsOnline: true,
8286
},
8387
YourTurn: false,
8488
},
@@ -264,8 +268,55 @@ func (g *Game) HandleDisconnect(player *Player) {
264268
player.IsConnected = false
265269
player.DisconnectedAt = time.Now()
266270

271+
// Notify opponent about disconnect
272+
opponent := GetOpponent(g, player)
273+
opponent.SendMessage(Message{
274+
Type: MsgPlayerStatus,
275+
Payload: PlayerStatusPayload{
276+
PlayerSymbol: player.Symbol,
277+
IsOnline: false,
278+
TimeLeft: 30,
279+
},
280+
})
281+
282+
// Start countdown timer with updates
267283
go func() {
268-
time.Sleep(30 * time.Second)
284+
ticker := time.NewTicker(1 * time.Second)
285+
defer ticker.Stop()
286+
287+
startTime := time.Now()
288+
for i := 29; i >= 0; i-- {
289+
<-ticker.C
290+
291+
g.Mutex.Lock()
292+
if g.State != "active" || player.IsConnected {
293+
g.Mutex.Unlock()
294+
return
295+
}
296+
297+
elapsed := int(time.Since(startTime).Seconds())
298+
timeLeft := 30 - elapsed
299+
if timeLeft < 0 {
300+
timeLeft = 0
301+
}
302+
303+
opponent := GetOpponent(g, player)
304+
opponent.SendMessage(Message{
305+
Type: MsgPlayerStatus,
306+
Payload: PlayerStatusPayload{
307+
PlayerSymbol: player.Symbol,
308+
IsOnline: false,
309+
TimeLeft: timeLeft,
310+
},
311+
})
312+
g.Mutex.Unlock()
313+
314+
if timeLeft == 0 {
315+
break
316+
}
317+
}
318+
319+
// Final check after 30 seconds
269320
g.Mutex.Lock()
270321
defer g.Mutex.Unlock()
271322

@@ -311,6 +362,16 @@ func (g *Game) HandleReconnect(playerID string, conn *websocket.Conn) (*Player,
311362

312363
opponent := GetOpponent(g, p)
313364

365+
// Notify opponent about reconnect
366+
opponent.SendMessage(Message{
367+
Type: MsgPlayerStatus,
368+
Payload: PlayerStatusPayload{
369+
PlayerSymbol: p.Symbol,
370+
IsOnline: true,
371+
TimeLeft: 0,
372+
},
373+
})
374+
314375
reconnectMsg := Message{
315376
Type: MsgReconnect,
316377
Payload: ReconnectPayload{
@@ -320,11 +381,13 @@ func (g *Game) HandleReconnect(playerID string, conn *websocket.Conn) (*Player,
320381
Username: p.Username,
321382
Symbol: p.Symbol,
322383
Type: getPlayerType(p),
384+
IsOnline: true,
323385
},
324386
Opponent: PlayerInfo{
325387
Username: opponent.Username,
326388
Symbol: opponent.Symbol,
327389
Type: getPlayerType(opponent),
390+
IsOnline: opponent.IsConnected,
328391
},
329392
Grid: g.Board.Grid,
330393
CurrentTurn: g.Turn,

backend/game/messages.go

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,14 @@ package game
22

33
// Message Types
44
const (
5-
MsgJoinQueue = "JOIN_QUEUE"
6-
MsgGameStart = "GAME_START"
7-
MsgMove = "MOVE"
8-
MsgUpdate = "GAME_UPDATE"
9-
MsgGameOver = "GAME_OVER"
10-
MsgError = "ERROR"
11-
MsgReconnect = "RECONNECT"
5+
MsgJoinQueue = "JOIN_QUEUE"
6+
MsgGameStart = "GAME_START"
7+
MsgMove = "MOVE"
8+
MsgUpdate = "GAME_UPDATE"
9+
MsgGameOver = "GAME_OVER"
10+
MsgError = "ERROR"
11+
MsgReconnect = "RECONNECT"
12+
MsgPlayerStatus = "PLAYER_STATUS"
1213
)
1314

1415
type Message struct {
@@ -21,6 +22,7 @@ type PlayerInfo struct {
2122
Username string `json:"username"`
2223
Symbol int `json:"symbol"`
2324
Type string `json:"type"` // "human" or "bot"
25+
IsOnline bool `json:"isOnline"`
2426
}
2527

2628
type GameStartPayload struct {
@@ -56,3 +58,9 @@ type ReconnectPayload struct {
5658
type GameOverPayload struct {
5759
Winner string `json:"winner"` // "1", "2", or "draw"
5860
}
61+
62+
type PlayerStatusPayload struct {
63+
PlayerSymbol int `json:"playerSymbol"`
64+
IsOnline bool `json:"isOnline"`
65+
TimeLeft int `json:"timeLeft"` // Seconds left before forfeit (0 if online)
66+
}

frontend/app/page.tsx

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,28 @@ export default function HomePage() {
151151
setError(null);
152152
break;
153153

154+
case 'PLAYER_STATUS':
155+
setGameState((prev: GameState | null) => {
156+
if (!prev) return prev;
157+
158+
// Update the status of the player whose symbol matches
159+
const updatedYou =
160+
prev.you.symbol === message.payload.playerSymbol
161+
? { ...prev.you, isOnline: message.payload.isOnline }
162+
: prev.you;
163+
const updatedOpponent =
164+
prev.opponent.symbol === message.payload.playerSymbol
165+
? { ...prev.opponent, isOnline: message.payload.isOnline }
166+
: prev.opponent;
167+
168+
return {
169+
...prev,
170+
you: updatedYou,
171+
opponent: updatedOpponent,
172+
};
173+
});
174+
break;
175+
154176
case 'ERROR':
155177
setError(message.payload);
156178
setIsWaiting(false);

frontend/components/game-header.tsx

Lines changed: 54 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
'use client';
22

3-
import type { PlayerInfo, PlayerSymbol } from '@/lib/types';
3+
import { useState, useEffect } from 'react';
4+
import type {
5+
PlayerInfo,
6+
PlayerSymbol,
7+
PlayerStatusMessage,
8+
} from '@/lib/types';
49
import { Badge } from '@/components/ui/badge';
510
import { User, Bot } from 'lucide-react';
611
import { cn } from '@/lib/utils';
12+
import { useWebSocketContext } from '@/lib/contexts';
713

814
interface GameHeaderProps {
915
you: PlayerInfo;
@@ -19,6 +25,28 @@ export function GameHeader({
1925
yourTurn,
2026
moveNumber,
2127
}: GameHeaderProps) {
28+
const { subscribeToMessages } = useWebSocketContext();
29+
const [opponentTimeLeft, setOpponentTimeLeft] = useState<number>(0);
30+
31+
useEffect(() => {
32+
const unsubscribe = subscribeToMessages((message) => {
33+
if (message.type === 'PLAYER_STATUS') {
34+
const payload = (message as PlayerStatusMessage).payload;
35+
// Only track countdown if opponent goes offline
36+
if (payload.playerSymbol === opponent.symbol && !payload.isOnline) {
37+
setOpponentTimeLeft(payload.timeLeft);
38+
} else if (
39+
payload.playerSymbol === opponent.symbol &&
40+
payload.isOnline
41+
) {
42+
setOpponentTimeLeft(0);
43+
}
44+
}
45+
});
46+
47+
return unsubscribe;
48+
}, [subscribeToMessages, opponent.symbol]);
49+
2250
return (
2351
<div className="flex flex-col gap-4 w-full max-w-md mx-auto">
2452
<div className="flex items-center justify-between gap-4">
@@ -39,7 +67,16 @@ export function GameHeader({
3967
)}
4068
/>
4169
<div className="flex-1 min-w-0">
42-
<p className="font-medium truncate">{you.username}</p>
70+
<div className="flex items-center gap-2">
71+
<p className="font-medium truncate">{you.username}</p>
72+
<div
73+
className={cn(
74+
'w-2 h-2 rounded-full',
75+
you.isOnline ? 'bg-green-500' : 'bg-gray-400'
76+
)}
77+
title={you.isOnline ? 'Online' : 'Offline'}
78+
/>
79+
</div>
4380
<div className="flex items-center gap-1 text-xs text-muted-foreground">
4481
{you.type === 'bot' ? (
4582
<Bot className="h-3 w-3" />
@@ -77,7 +114,16 @@ export function GameHeader({
77114
)}
78115
/>
79116
<div className="flex-1 min-w-0">
80-
<p className="font-medium truncate">{opponent.username}</p>
117+
<div className="flex items-center gap-2">
118+
<p className="font-medium truncate">{opponent.username}</p>
119+
<div
120+
className={cn(
121+
'w-2 h-2 rounded-full',
122+
opponent.isOnline ? 'bg-green-500' : 'bg-gray-400'
123+
)}
124+
title={opponent.isOnline ? 'Online' : 'Offline'}
125+
/>
126+
</div>
81127
<div className="flex items-center gap-1 text-xs text-muted-foreground">
82128
{opponent.type === 'bot' ? (
83129
<Bot className="h-3 w-3" />
@@ -93,6 +139,11 @@ export function GameHeader({
93139
Their turn
94140
</Badge>
95141
)}
142+
{!opponent.isOnline && opponentTimeLeft > 0 && (
143+
<Badge variant="destructive" className="mt-2 text-xs">
144+
Reconnecting... {opponentTimeLeft}s
145+
</Badge>
146+
)}
96147
</div>
97148
</div>
98149

frontend/lib/types.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ export interface PlayerInfo {
1010
username: string;
1111
symbol: PlayerSymbol;
1212
type: PlayerType;
13+
isOnline: boolean;
1314
}
1415

1516
// ============= Game State =============
@@ -49,6 +50,7 @@ export type WSMessage =
4950
| GameUpdateMessage
5051
| GameOverMessage
5152
| ReconnectMessage
53+
| PlayerStatusMessage
5254
| ErrorMessage;
5355

5456
export interface GameStartMessage {
@@ -96,6 +98,15 @@ export interface ErrorMessage {
9698
payload: string;
9799
}
98100

101+
export interface PlayerStatusMessage {
102+
type: 'PLAYER_STATUS';
103+
payload: {
104+
playerSymbol: PlayerSymbol;
105+
isOnline: boolean;
106+
timeLeft: number;
107+
};
108+
}
109+
99110
// ============= API Responses =============
100111

101112
export interface LeaderboardEntry {

0 commit comments

Comments
 (0)