İlk gerçek testte dört kişiydik. Arkadaşım bir ev çizdi, ben sohbete "ev" yazdım, oyun beni doğru saydı. Sonra başka biri "EV" yazdı ve oyun onu saymadı. O an anladım ki bu oyunun asıl zorluğu WebSocket değil, Türkçe.
Karalama, kayıt olmadan arkadaşlarla oynanan bir çizim-tahmin oyunu. Bir kişi çiziyor, diğerleri tahmin ediyor, süre doluyor, sıra değişiyor. Basit görünüyor. Bir monorepo, üç paket ve epey bir hata sonra yayında.
Önce Mimari: Neden Monorepo
Oyun iki ayrı yerde çalışıyor: Next.js istemcisi Vercel'de, Socket.io sunucusu Railway'de. İkisinin de aynı tiplere ihtiyacı var — oda durumu, oyuncu, çizim verisi, olay adları.
İlk sürümde tipleri iki yere kopyalamıştım. Bir hafta sonra sunucuda `roundEndsAt` alanını `endsAt` yapıp istemcide unuttum. Oyun sessizce çalıştı, sadece geri sayım hep sıfır gösterdi. Ondan sonra `packages/shared` açtım.
- 1
apps/web
Next.js istemcisi — çizim tuvali, sohbet, oda ekranları. Vercel.
- 2
apps/server
Socket.io sunucusu — oda yönetimi, tur akışı, puanlama. Railway.
- 3
packages/shared
Tipler, sabitler, puanlama fonksiyonları. İki tarafın da import ettiği tek doğruluk kaynağı.
Türkçe Küçük Harf Tuzağı
Tahmin karşılaştırması normalde tek satırlıktır: `a.toLowerCase() === b.toLowerCase()`. Türkçe'de değil.
JavaScript'in `toLowerCase()` fonksiyonu varsayılan olarak İngilizce kurallarını uygular. Yani büyük "İ" harfini "i̇" yapıyor — arkasında birleştirici bir nokta bırakarak. Ve büyük "I" harfini "i" yapıyor, oysa Türkçe'de "ı" olmalı.
Kullanıcı "İSTANBUL" yazdı, cevap "istanbul"
toLowerCase()
eşleşmiyor
önce harf değişimi
eşleşiyor
"İ".toLowerCase() sonucu "i" değil, "i" + U+0307 birleştirici noktası. Gözle aynı görünüyor, karşılaştırmada eşit değil.
export function normalizeGuess(text: string): string {
// Türkçe harfleri toLowerCase'DEN ÖNCE değiştirmek zorunlu:
// "İ".toLowerCase() → "i" + U+0307, "I".toLowerCase() → "i" (ı değil)
return text
.replace(/İ/g, 'i')
.replace(/I/g, 'ı')
.toLowerCase()
.replace(/[^a-zçğıöşüâîû\s]/g, '')
.trim()
}Sondaki `replace` de bir hata düzeltmesi. Biri "ev!!!" yazdığında oyun saymıyordu. Noktalama ve rakamları düşürünce bitti. Şapkalı harfleri (â, î, û) listede tuttum çünkü kelime havuzunda "kâğıt" gibi kelimeler var.
Yakın Tahmin
Bir de şu vardı: insanlar hızlı yazarken harf atlıyor. "bisiklet" yerine "bisklet". Doğru cevabı vermiş sayılmalı mı? Hayır — ama "çok yaklaştın" demek oyunu belirgin şekilde eğlenceli yapıyor.
// Kısa kelimelerde bu kontrol yapılmıyor: "el" ile "at" arasındaki
// Levenshtein uzaklığı da 2 ve ikisi de kelime havuzunda.
if (answer.length > 3 && levenshtein(normalized, answer) <= 2) {
// "Çok yakınsın!" — sadece yazan kişiye
}O `answer.length > 3` kontrolü olmadan oyun "el" yazan birine "çok yakınsın" diyordu, oysa cevap "at" idi. Kısa kelimelerde uzaklık eşiği anlamsızlaşıyor.
Çizimi Nasıl Gönderiyorum
İlk denemem her `mousemove` olayında bir paket göndermekti. Saniyede 120 mesaj. Sunucu dayandı ama mobilde tuval takılmaya başladı.
İki şey düzeltti. Birincisi hız sınırı: çizim olayları 33 milisaniyede bir, yani yaklaşık 30 kare. Gözle fark edilmiyor, trafik dörtte bire iniyor.
export const DRAW_RATE_LIMIT_MS = 33 // ~30fps
export const CHAT_RATE_LIMIT_MS = 500
export const MAX_CHAT_LENGTH = 100İkincisi koordinatları 0-1 aralığında normalize etmek. Bunu baştan yapmadım ve pişman oldum: telefonda çizilen bir şey masaüstünde bambaşka yere düşüyordu.
export interface DrawPoint {
x: number // 0-1 arası normalize — tuval boyutundan bağımsız
y: number
pressure?: number
}
export interface DrawStroke {
id: string
tool: DrawTool
color: string
size: number
points: DrawPoint[]
}Normalize etmenin ikinci faydası sonradan çıktı: tuvali yeniden boyutlandırınca çizim bozulmuyor. Telefonu yatay çevirdiğinde her şey orantılı büyüyor.
Puanı Kim Hesaplıyor
Bu soruya ilk sürümde yanlış cevap verdim: istemci. Tahmin doğruysa istemci puanı hesaplayıp sunucuya gönderiyordu.
Test ederken bir arkadaşım konsolu açtı, elle bir puan olayı gönderdi ve tek turda oyunu bitirdi. O akşam bütün puan mantığını sunucuya taşıdım.
Şimdi puan formülü `packages/shared` içinde ama YALNIZCA sunucu çağırıyor. İstemci aynı fonksiyonu import edebiliyor — sadece sonucu önceden göstermek için, otorite değil.
export function calculateGuesserScore(p: GuesserScoreParams): number {
const base = 100
const timeBonus = Math.round(150 * (p.timeLeft / p.totalTime))
// Kaçıncı bildiğin de önemli: ilk bilen 50, ikinci 35, dördüncüden
// sonrası 0. Herkesin aynı puanı alması yarışı öldürüyordu.
const speedBonus = Math.max(0, 50 - p.guessOrder * 15)
const multiplier = DIFFICULTY_MULTIPLIER[p.wordDifficulty] ?? 1
return Math.round((base + timeBonus + speedBonus) * multiplier)
}Çizen kişinin puanı ayrı: kaç kişinin bildiğine oranlı, tavanı 200. Kimse bilemezse sıfır alıyor. Bu da bilerek — çok zor bir şey çizip herkesi elemek strateji olmasın diye.
Odalar Bellekte
Veritabanı yok. Oda kodu altı karakter, oda nesnesi sunucunun belleğinde bir `Map` içinde, 30 dakika hareketsiz kalırsa siliniyor.
| Ayar | Değer | Neden |
|---|---|---|
| Oyuncu sayısı | 2 – 12 | 12'den sonra sohbet okunamaz hâle geliyor |
| Çizim süresi | 30 – 120 sn | Alt sınır 15'ti, kimse yetişemedi |
| Kelime seçme | 15 sn | Seçmezsen rastgele atanıyor |
| İlk harf ipucu | sürenin %66'sı | Tur ölmesin diye |
| İkinci harf | sürenin %33'ü | |
| Oda ömrü | 30 dk | Bellek şişmesin |
Bu kararın bedeli var: sunucu yeniden başlarsa bütün odalar gidiyor. Kabul ettim. Oyun ortalama 10 dakika sürüyor ve kimse yarım kalan bir Karalama oturumuna geri dönmek istemiyor.
Bilgi
Bot desteğini de bu yüzden ekledim. İki kişi başlamak isterse boş yerleri bot dolduruyor — isimleri Türkçe: Fırça, Kalem, Palet, Tuval. Bot çizmiyor, sadece tahmin ediyor ve arada yanlış bilerek insanı rahatlatıyor.
Kelime Havuzu
Havuzda 1071 kelime var ve üç zorluk seviyesine ayrılmış. Zorluk hem puan çarpanını hem seçenekleri etkiliyor: her turda çizecek kişiye üç kelime sunuluyor, üçü de farklı zorlukta.
Kelimeleri seçerken en çok uğraştığım şey çizilebilirlikti. "Özgürlük" güzel bir kelime ama kimse çizemiyor. "Merdiven" iyi. Havuzu üç kere baştan geçtim ve her seferinde soyut kelimeleri attım.
Bugün Olsa Neyi Değiştirirdim
Socket.io yerine düz WebSocket denerdim. Socket.io'nun otomatik yeniden bağlanması ve oda yönetimi işimi çok kolaylaştırdı, ama istemci paketi 45 KB ve ben özelliklerinin belki üçte birini kullanıyorum.
Bir de tur akışını en baştan bir durum makinesi olarak yazardım. Şu an `Room.ts` içinde `if (this.phase === ...)` kontrolleri var ve yeni bir faz eklerken hep bir yeri unutuyorum. Çalışıyor ama zarif değil.
Emin olamadığım tek karar odaların bellekte olması. Bir gün biri "10 dakika önce oynadığımız oda nerede" diye sorarsa cevabım olmayacak. O gün gelene kadar bu sadelik bana daha değerli.