Karalama'yı yazmaya başlarken planım basitti: oda aç, link paylaş, çizime başla. Kağıt üstünde beş satır. Gerçek şu ki multiplayer yazarken hiçbir şey beş satır değil; ilk büyük kararım state'in nerede duracağıydı ve onu yanlış verip geri adım atarak öğrendim.
Bu yazı bir Socket.IO öğrenme rehberi değil. Başlayan biri için tökezlediğim noktaları işaretleyen bir harita.
Neden Oda Tabanlı Mimari
İlk refleksim her oyunu tek bir "games" objesinde tutmaktı. Server'da bir Map, client'ta Zustand store. Birkaç oda açıkken iyi görünüyor. Oda sayısı artınca her event herkese gidiyor, bir oyuncu yanlış odanın tahminini görüyor. Socket.IO rooms zaten bu sorun için var; ben önce kötü bir broadcast filtresi yazıp sonra doğru yolu bulanlardanım.
// server/rooms.ts
import type { Server, Socket } from 'socket.io'
interface RoomState {
id: string
players: Map<string, Player> // socket.id -> Player
drawer: string | null
word: string | null
round: number
status: 'lobby' | 'drawing' | 'reveal'
}
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) // <-- kritik nokta
socket.data.roomId = roomId // reverse lookup için
io.to(roomId).emit('room:update', snapshot(room)) // sadece bu odaya
}💡 İpucu
socket.data objesi beklediğimden çok faydalı çıktı. Disconnect olurken socket'i hangi odadan çıkaracağımı aramak yerine socket.data.roomId üstünden temizliyorum.
State'i Kim Tutar: Sunucu mu, Client mı
İlk versiyonda her client kendi state'ini tutuyor, server sadece mesaj iletiyordu. Sorun şu: iki kişi aynı anda "doğru tahmin" sinyali gönderdiğinde puanlar uyuşmuyor. Sonra her şeyi server'a taşıdım. Client sadece render eder, server tek doğruluk kaynağıdır.
Multiplayer'da en önemli kararlardan biri bu. Çünkü hile açığını, yarış koşullarını ve bağlantı kopunca ne olacağını hep bu belirliyor.
// Yanlış: client skorunu kendi günceller
socket.on('guess:correct', () => {
set((s) => ({ score: s.score + 10 })) // her client kendi kafasına
})
// Doğru: server hesaplar, broadcast eder
// client sadece dinler
socket.on('score:update', ({ playerId, score }) => {
set((s) => ({
players: s.players.map(p => p.id === playerId ? { ...p, score } : p),
}))
})Disconnect En Acı Kısmı
"Drawer oyunun ortasında bağlantısını kaybederse ne olur" sorusunu önceden yanıtlamazsanız üretimde bir yerde karşınıza çıkar. Benim cevabım: çizen kişi düşerse round iptal edilir, sıra bir sonraki oyuncuya geçer. Yeni oyuncu eklenmesi round sayacını değiştirmez.
io.on('connection', (socket) => {
socket.on('disconnect', () => {
const roomId = socket.data.roomId
if (!roomId) return
const room = rooms.get(roomId)
if (!room) return
room.players.delete(socket.id)
// Oda boşaldıysa tamamen sil
if (room.players.size === 0) {
rooms.delete(roomId)
return
}
// Drawer düştüyse round'u bitir
if (room.drawer === socket.id) {
endRound(io, room, { reason: 'drawer_left' })
} else {
io.to(roomId).emit('room:update', snapshot(room))
}
})
})⚠️ Dikkat
Geçici disconnect'ler için bir grace period eklemek iyi fikir. Kullanıcı 5 saniye içinde geri bağlanırsa oyundan atmayın; mobilde ağ kopmaları beklediğinizden sık oluyor.
Çizim Event'lerini Boğmamak
Her pointermove event'ini server'a göndermek zor değil, ama saniyede 60-120 event'e çıkıyor. Server üstünden 8 kişiye broadcast edince bir kişinin çizimi 800+ mesaja dönüyor. Throttle olmadan hızla pingler yükseliyor.
// client/draw.ts
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) // ~25fps, yumuşak hissediyor
}Server gelen segmenti broadcast etmeden önce saklıyor; sonradan katılan oyuncu o ana kadarki tuvali replay olarak alıyor.
Monorepo ile Ortak Tipler
Client ve server ayrı deploy ediliyor ama event şemaları ortak. İki tarafta aynı interface'i ayrı yazmak tutarsızlık kaynağı. npm workspaces ile paylaşılan bir paket açtım.
// packages/shared/src/events.ts
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
}
export interface ClientToServerEvents {
'room:join': (payload: { roomId: string; name: string }) => void
'guess:submit': (text: string) => void
'draw:segment': (points: Point[]) => void
}// Her iki tarafta:
import type { Server } from 'socket.io'
import type { ServerToClientEvents, ClientToServerEvents } from '@karalama/shared'
const io = new Server<ClientToServerEvents, ServerToClientEvents>()💡 İpucu
Tipli Socket.IO fark yaratır. Event adını yanlış yazdığınızda derleme anında yakalarsınız; production'da debug etmekten çok iyidir.
Kapanış
Socket.IO'nun kendisi 20 dakikalık bir okumayla anlaşılıyor. Zor olan protokolün söz vermediklerini tasarlamak: bağlantının kopabileceği, mesajların sırayı kaybedebileceği, iki kullanıcının aynı anda aynı şeyi iddia edebileceği bir dünya.
Karalama'nın kodu açık; özellikle round lifecycle kısmı, burada yazdıklarımın uygulamadaki halidir.