Karalama’yı yazarken her şey kâğıt üstünde kolay görünüyordu: oda aç, linki paylaş, biri çizsin diğerleri bilsin. Ama çok oyunculu tarafta asıl zorluk çizim değil, düzeni korumak. İlk bağlantı kopunca ya da iki kişi aynı anda bir şey yapınca her şeyin ne kadar hızlı karışabildiğini görüyorsun.
Bu yazı Socket.IO ders notu değil. Daha çok, gerçek kullanımda hangi kararların dönüp sorun çıkardığını ve hangilerinin sistemi sakin tuttuğunu toparlayan kısa bir deneyim özeti.
Neden Oda Mantığı Kullandım?
İlk fikir, bütün oyunu tek büyük state içinde toplamak oldu. Ama birkaç oda aynı anda açılınca event’lerin yanlış oyunculara gitmesi çok kolaylaşıyor. Socket.IO rooms yapısı zaten bu iş için var. O yüzden odaları baştan temel bir kavram olarak ele almak daha temiz oldu.
const rooms = new Map<string, RoomState>()
export function joinRoom(io: Server, socket: Socket, roomId: string, name: string) {
let room = rooms.get(roomId)
if (!room) {
room = { id: roomId, players: new Map(), drawer: null, word: null, round: 0, status: 'lobby' }
rooms.set(roomId, room)
}
room.players.set(socket.id, { id: socket.id, name, score: 0 })
socket.join(roomId)
socket.data.roomId = roomId
io.to(roomId).emit('room:update', snapshot(room))
}İpucu
`socket.data` burada çok hayat kurtarıyor. Bağlantı kopunca oyuncunun hangi odada olduğunu tekrar aramak zorunda kalmıyorsun.
State Kimin Elinde?
Çok oyunculu oyunda en kritik kararlardan biri bu. Client kendi skorunu kendi artırıyorsa bir yerde mutlaka tutarsızlık çıkıyor. Benim için doğru yol şuydu: client gösterir, server karar verir. Özellikle puan, round ve kelime gibi şeylerde bu yaklaşım daha güvenli.
socket.on('score:update', ({ playerId, score }) => {
set((s) => ({
players: s.players.map(p => p.id === playerId ? { ...p, score } : p),
}))
})Bağlantı Kopunca Ne Olacak?
Çizen kişi tam turun ortasında oyundan düşerse ne olacağına önceden karar vermezsen sistem askıda kalıyor. Ben burada round’u iptal edip sırayı bir sonraki oyuncuya vermeyi seçtim. Küçük kural gibi görünse de oyunun güven duygusunu doğrudan etkiliyor.
socket.on('disconnect', () => {
const roomId = socket.data.roomId
if (!roomId) return
const room = rooms.get(roomId)
if (!room) return
room.players.delete(socket.id)
if (room.players.size === 0) {
rooms.delete(roomId)
return
}
if (room.drawer === socket.id) {
endRound(io, room, { reason: 'drawer_left' })
} else {
io.to(roomId).emit('room:update', snapshot(room))
}
})Dikkat
Kısa süreli kopmalar için küçük bir tolerans süresi vermek iyi fikir. Mobilde ağ geçişleri masaüstüne göre daha sık yaşanıyor.
Çizim Olaylarını Yığmamak
Her fare hareketini sunucuya göndermek kolay ama gürültülü. Çizimi küçük paketler halinde toplamak hem ağ yükünü azaltıyor hem de beklediğimden daha pürüzsüz bir his veriyor. Burada amaç en yüksek sayı değil, düzgün akan bir deneyim.
let buffer: Point[] = []
let flushTimer: ReturnType<typeof setTimeout> | null = null
function onPointerMove(p: Point) {
buffer.push(p)
if (flushTimer) return
flushTimer = setTimeout(() => {
socket.emit('draw:segment', buffer)
buffer = []
flushTimer = null
}, 40)
}Ortak Tiplerin Rahatlığı
Client ve server ayrı yerlerde duruyor olsa da event tanımlarını tek yerden beslemek ciddi rahatlık sağlıyor. Aynı event adını iki dosyada elle yazmak başta hızlı, sonra yorucu hale geliyor.
export interface ServerToClientEvents {
'room:update': (state: RoomSnapshot) => void
'round:start': (payload: { drawerId: string; roundEndsAt: number }) => void
'draw:segment': (points: Point[]) => void
'score:update': (payload: { playerId: string; score: number }) => void
}