Commit e1001508 authored by Mahmoud Aglan's avatar Mahmoud Aglan

feat: realtime multiplayer system for Chess and Ludo

New core module: realtime.js
- WebSocket connection to Supabase Realtime
- Auto-reconnect on disconnect (3s retry)
- Heartbeat every 30s
- Subscribe to any table row by filter
- subscribeMatch(), subscribeLudoMatch(), subscribeQueue() helpers
- Unsubscribe cleanup on leave

Chess live (logic/live.js) rewritten:
- startMatchmaking() — joins queue + subscribes for match found
- joinMatch() — subscribes to match row, fires onMove/onStateChange
- sendMove() — updates match with fen, move, move_count
- sendResign(), sendDrawOffer(), sendEmote()
- Properly increments move_count for realtime detection

Ludo live (logic/live.js) new:
- joinMatch() — subscribes to ludo_matches row
- sendRoll() — updates positions, current_turn, dice_value
- sendEnd() — marks game complete

API game.php updated:
- handleGameMove now supports: move_count, game_state, time remaining
- All fields optional (only updates what's sent)
- updated_at timestamp on every move (triggers realtime broadcast)

Architecture: Player A updates row → Supabase broadcasts → Player B receives
Co-Authored-By: 's avatarClaude Opus 4.6 <noreply@anthropic.com>
parent e26cb40f
......@@ -71,17 +71,18 @@ function handleStart($db, string $userId, array $input): void {
function handleGameMove($db, string $userId, array $input): void {
$matchId = $input['match_id'] ?? '';
$moveData = $input['move'] ?? '';
$fen = $input['fen'] ?? '';
if (!$matchId) jsonError('match_id required');
if (!$matchId || !$moveData || !$fen) {
jsonError('match_id, move and fen are required');
}
$update = ['updated_at' => date('c')];
$result = $db->update('matches', [
'current_fen' => $fen,
'moves' => $moveData
], ['id' => 'eq.' . $matchId]);
if (!empty($input['fen'])) $update['current_fen'] = $input['fen'];
if (!empty($input['move'])) $update['moves'] = $input['move'];
if (isset($input['move_count'])) $update['move_count'] = intval($input['move_count']);
if (!empty($input['game_state'])) $update['game_state'] = $input['game_state'];
if (isset($input['white_time_remaining_ms'])) $update['white_time_remaining_ms'] = intval($input['white_time_remaining_ms']);
if (isset($input['black_time_remaining_ms'])) $update['black_time_remaining_ms'] = intval($input['black_time_remaining_ms']);
$result = $db->update('matches', $update, ['id' => 'eq.' . $matchId]);
if (isset($result['error'])) jsonError($result['error']);
jsonResponse(['success' => true]);
......
// Supabase Realtime — subscribe to table row changes
// Used for live multiplayer in Chess and Ludo
const REALTIME_URL = 'wss://safe-supabase-kong.caprover.al-arcade.com/realtime/v1/websocket';
const ANON_KEY = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzM1Njg5NjAwLCJleHAiOjE4OTM0NTYwMDB9.31PF6PvP-pSrvRuQwLFptQoejR0W1A7o53lZhEbnz84';
import * as store from './store.js';
let ws = null;
let heartbeatInterval = null;
let ref = 0;
let subscriptions = {};
let reconnectTimer = null;
let connected = false;
export function connect() {
if (ws && ws.readyState === WebSocket.OPEN) return;
const token = store.get('auth.token') || ANON_KEY;
ws = new WebSocket(`${REALTIME_URL}?apikey=${ANON_KEY}&token=${token}&vsn=1.0.0`);
ws.onopen = () => {
connected = true;
clearTimeout(reconnectTimer);
heartbeatInterval = setInterval(() => {
send({ topic: 'phoenix', event: 'heartbeat', payload: {}, ref: String(++ref) });
}, 30000);
// Re-subscribe all active subscriptions
Object.keys(subscriptions).forEach(topic => joinChannel(topic));
};
ws.onmessage = (e) => {
try {
const msg = JSON.parse(e.data);
if (msg.event === 'postgres_changes') {
const payload = msg.payload;
const callbacks = subscriptions[msg.topic];
if (callbacks) {
callbacks.forEach(cb => cb({
type: payload.type, // INSERT, UPDATE, DELETE
new: payload.record,
old: payload.old_record
}));
}
}
} catch (err) {}
};
ws.onclose = () => {
connected = false;
clearInterval(heartbeatInterval);
reconnectTimer = setTimeout(() => connect(), 3000);
};
ws.onerror = () => {
ws.close();
};
}
export function disconnect() {
clearInterval(heartbeatInterval);
clearTimeout(reconnectTimer);
if (ws) { ws.close(); ws = null; }
connected = false;
subscriptions = {};
}
export function subscribe(table, filter, callback) {
const topic = `realtime:public:${table}:${filter}`;
if (!subscriptions[topic]) {
subscriptions[topic] = [];
}
subscriptions[topic].push(callback);
if (connected) joinChannel(topic);
else connect();
// Return unsubscribe function
return () => {
subscriptions[topic] = subscriptions[topic].filter(cb => cb !== callback);
if (subscriptions[topic].length === 0) {
leaveChannel(topic);
delete subscriptions[topic];
}
};
}
// Subscribe to a specific match row
export function subscribeMatch(matchId, callback) {
return subscribe('matches', `id=eq.${matchId}`, callback);
}
// Subscribe to a specific ludo match row
export function subscribeLudoMatch(matchId, callback) {
return subscribe('ludo_matches', `id=eq.${matchId}`, callback);
}
// Subscribe to matchmaking queue for match found
export function subscribeQueue(playerId, callback) {
return subscribe('matchmaking_queue', `player_id=eq.${playerId}`, callback);
}
function joinChannel(topic) {
const token = store.get('auth.token') || ANON_KEY;
send({
topic,
event: 'phx_join',
payload: {
config: {
broadcast: { self: false },
postgres_changes: [{
event: '*',
schema: 'public',
table: topic.split(':')[2],
filter: topic.split(':')[3]
}]
},
access_token: token
},
ref: String(++ref)
});
}
function leaveChannel(topic) {
send({ topic, event: 'phx_leave', payload: {}, ref: String(++ref) });
}
function send(msg) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(msg));
}
}
export function isConnected() { return connected; }
// Chess Live Multiplayer — realtime game sync via Supabase
import * as realtime from '../../../core/realtime.js';
import * as net from '../../../core/net.js';
import * as store from '../../../core/store.js';
let pollInterval = null;
let onMoveCallback = null;
let onDrawOfferCallback = null;
let unsubMatch = null;
let unsubQueue = null;
let matchId = null;
let onMoveReceived = null;
let onGameStateChange = null;
let lastMoveCount = 0;
export function startSync(matchId, callbacks) {
onMoveCallback = callbacks.onMove;
onDrawOfferCallback = callbacks.onDrawOffer;
lastMoveCount = 0;
export function startMatchmaking(gameKey, timeControl, callbacks) {
const userId = store.get('auth.userId');
pollInterval = setInterval(() => pollMatch(matchId), 2000);
}
unsubQueue = realtime.subscribeQueue(userId, (change) => {
if (change.type === 'UPDATE' && change.new?.match_id) {
stopMatchmaking();
callbacks.onMatchFound({
matchId: change.new.match_id,
color: change.new.matched_with ? 'b' : 'w',
opponentId: change.new.matched_with
});
}
});
export function stopSync() {
if (pollInterval) {
clearInterval(pollInterval);
pollInterval = null;
}
return net.post('matchmaking.php', {
action: 'queue',
game_key: gameKey,
time_control: timeControl
});
}
async function pollMatch(matchId) {
try {
const data = await net.get('game.php', { action: 'get', match_id: matchId });
if (!data || data.error) return;
export function stopMatchmaking() {
if (unsubQueue) { unsubQueue(); unsubQueue = null; }
}
if (data.move_count > lastMoveCount && data.current_fen) {
lastMoveCount = data.move_count;
if (onMoveCallback) onMoveCallback(data);
}
export function joinMatch(id, callbacks) {
matchId = id;
lastMoveCount = 0;
onMoveReceived = callbacks.onMove;
onGameStateChange = callbacks.onStateChange;
if (data.game_state?.draw_offer && onDrawOfferCallback) {
const offer = data.game_state.draw_offer;
if (offer.from !== store.get('auth.userId')) {
onDrawOfferCallback(offer);
realtime.connect();
unsubMatch = realtime.subscribeMatch(id, (change) => {
if (change.type === 'UPDATE' && change.new) {
const match = change.new;
if (match.move_count > lastMoveCount) {
lastMoveCount = match.move_count;
if (onMoveReceived) onMoveReceived(match);
}
if (match.status !== 'in_progress' && onGameStateChange) {
onGameStateChange(match);
}
}
} catch (e) {}
});
}
export function leaveMatch() {
if (unsubMatch) { unsubMatch(); unsubMatch = null; }
matchId = null;
}
export async function sendMove(matchId, fen, move, moveCount) {
export async function sendMove(fen, move, moveCount) {
if (!matchId) return;
lastMoveCount = moveCount;
return net.post('game.php', {
action: 'move',
match_id: matchId,
......@@ -50,33 +73,21 @@ export async function sendMove(matchId, fen, move, moveCount) {
});
}
export async function sendDrawOffer(matchId) {
return net.post('game.php', {
action: 'draw_offer',
match_id: matchId
});
export async function sendResign() {
if (!matchId) return;
return net.post('game.php', { action: 'resign', match_id: matchId });
}
export async function acceptDraw(matchId) {
return net.post('game.php', {
action: 'complete',
match_id: matchId,
result: 'draw'
});
export async function sendDrawOffer() {
if (!matchId) return;
const gs = JSON.stringify({ draw_offer: store.get('auth.userId') });
return net.post('game.php', { action: 'move', match_id: matchId, game_state: gs });
}
export async function sendResign(matchId) {
return net.post('game.php', {
action: 'resign',
match_id: matchId
});
export async function sendEmote(emoteKey) {
if (!matchId) return;
const gs = JSON.stringify({ emote: { key: emoteKey, from: store.get('auth.userId'), t: Date.now() } });
return net.post('game.php', { action: 'move', match_id: matchId, game_state: gs });
}
export async function requestRematch(matchId) {
return net.post('game.php', {
action: 'start',
game_key: 'chess',
mode: 'rematch',
rematch_of: matchId
});
}
export function getMatchId() { return matchId; }
// Ludo Live Multiplayer — realtime game sync via Supabase
import * as realtime from '../../../core/realtime.js';
import * as net from '../../../core/net.js';
import * as store from '../../../core/store.js';
let unsubMatch = null;
let matchId = null;
let onUpdate = null;
export function joinMatch(id, callback) {
matchId = id;
onUpdate = callback;
realtime.connect();
unsubMatch = realtime.subscribeLudoMatch(id, (change) => {
if (change.type === 'UPDATE' && change.new) {
if (onUpdate) onUpdate(change.new);
}
});
}
export function leaveMatch() {
if (unsubMatch) { unsubMatch(); unsubMatch = null; }
matchId = null;
}
export async function sendRoll(diceValue, positions, currentTurn, moves) {
if (!matchId) return;
return net.post('ludo.php', {
action: 'move',
match_id: matchId,
positions: positions,
current_turn: currentTurn,
dice_value: diceValue,
moves: moves
});
}
export async function sendEnd(winners) {
if (!matchId) return;
return net.post('ludo.php', { action: 'end', match_id: matchId, winners });
}
export function getMatchId() { return matchId; }
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment