· 5 dk okuma
SignalR ile Gerçek Zamanlı Oyunda Gizli Rolü Sunucuda Tutmak
Gerçek zamanlı çok oyunculu bir oyunda bazı bilgiler bazı oyunculardan gizli kalmalı: kimin casus olduğu, kartta ne yazdığı, rakibin elindeki taşlar. Bu bilgiler tarayıcıya bir kez ulaştıysa artık gizli değildir, çünkü tarayıcının geliştirici araçlarını açan herkes onları okuyabilir. Bu yazıda telefonla oynanan parti oyunum Casus'ta gizli rolü nasıl sunucuda tuttuğumu, SignalR ile her oyuncuya sadece kendi görünümünü nasıl gönderdiğimi ve bunu gerçek bağlantılarla nasıl test ettiğimi anlatıyorum.
Oyun ve gizli bilgi
Casus'ta 3 ile 12 kişi aynı odaya kendi telefonlarından katılıyor. Tur başlayınca herkes gizli bir kart görüyor. Siviller aynı mekânı görüyor, örneğin "Hastane". Casus mekânı görmüyor; kartında sadece casus olduğu ve odanın olası mekân listesi yazıyor. Oyuncular birbirine soru soruyor, sonra casusu oyluyor. Yakalanan casusun mekânı tahmin ederek son bir şansı var.
Oyunun bütün eğlencesi iki gizli bilgiye dayanıyor:
- Sivillerin bildiği mekân, casusa gitmemeli.
- Casusun kim olduğu, tur bitene kadar kimseye gitmemeli.
Eski sürümde ne yanlıştı?
Casus'un ilk sürümünü 2025 yazında yazmıştım. Oyunu baştan yazarken eski koda baktığımda iki ayrı sızıntı buldum.
Birinci sızıntı: casusun kimliği sayfanın kaynağındaydı. Oyun sayfası açılırken sunucu, oturumun durumunu bir nesne olarak sayfaya gömüyordu:
// kısaltıldı
ViewBag.GameSession = new {
gameSession?.Id,
gameSession?.StartedAt,
gameSession?.CurrentQuestion,
gameSession?.SpyParticipantId, // casusun kimliği
gameSession?.Winner
};
let gameSession = @Html.Raw(Json.Serialize(ViewBag.GameSession));
Bu nesne her oyuncuya aynıydı. Sayfanın kaynağını açan herhangi bir oyuncu SpyParticipantId alanını okuyup casusun kim olduğunu öğrenebiliyordu. Arayüz bu alanı göstermiyordu, ama göstermemek gizlemek demek değil.
İkinci sızıntı: rol duyurusu bütün odaya gidiyordu. SignalR hub'ında rolü bildirmek için yazılmış bir metot vardı:
public async Task PlayerRoleAssigned(string roomCode, string playerName, bool isSpy, string assignedWord)
{
await Clients.Group(roomCode).SendAsync("RoleAssigned", playerName, isSpy, assignedWord);
}
Bu metot rolü ve gizli kelimeyi tek bir oyuncuya değil, odanın bütün grubuna gönderiyordu. Üstelik hub'da yetki kontrolü yoktu: oda kodunu bilen herkes bu metodu kendi tarayıcısından istediği parametrelerle çağırabilirdi. İstemci kodu bu metodu kullanmıyordu, ama sunucuda açık durması yeterliydi.
İki hatanın kökü aynı: oyun durumu herkese aynı şekilde gönderiliyordu ve neyin gizli kalacağına istemci karar veriyordu.
Yeni tasarım: herkese kendi görünümü
Oyunu yeniden yazarken kuralı tersine çevirdim: istemci hiçbir şeyi gizlemez, çünkü gizlemesi gereken hiçbir şey ona gelmez.
1. Her oyuncunun kendi kanalı
SignalR'da bir bağlantı gruplara eklenebiliyor. Eski sürümde grup odanın koduydu, yani bütün oda tek bir gruptu. Yeni sürümde her oyuncunun kendine ait bir grubu var:
public static string PlayerGroup(string code, string playerId) => $"{code}:{playerId}";
Aynı oyuncunun iki sekmesi açıksa ikisi de bu gruba giriyor ve aynı görünümü alıyor. Odanın tamamına giden bir mesaj ise hiç yok.
2. Kimlik bağlantı açılırken oturumdan alınıyor
Oyuncunun kim olduğu tarayıcının gönderdiği bir parametreden değil, bağlantı açılırken oturum çerezinden okunuyor. Oturumu olmayan ya da bu odanın oyuncusu olmayan biri bağlanmaya çalışırsa bağlantı hemen kapatılıyor:
public override async Task OnConnectedAsync()
{
var http = Context.GetHttpContext()!;
var code = RoomCode.Normalize(http.Request.Query["room"]);
var session = await sessions.FindAsync(http);
if (session is null || !RoomCode.IsValid(code) || await rooms.ViewAsync(code, session.PlayerId) is null)
{
Context.Abort(); // oturum yok ya da bu odanın oyuncusu değil
return;
}
Context.Items["player"] = session.PlayerId;
Context.Items["room"] = code;
await Groups.AddToGroupAsync(Context.ConnectionId, PlayerGroup(code, session.PlayerId));
// ...
}
Hub metotları ise oyuncu kimliğini parametre olarak almıyor. Kimlik Context.Items'tan geliyor, yani kimse başkası adına oy veremiyor:
public Task<string?> Vote(string suspectId) =>
suspectId is { Length: > 0 and <= 40 } ? rooms.VoteAsync(PlayerId, Code, suspectId) : Task.FromResult<string?>("unknown_player");
Oyuncu sadece hamlesini gönderiyor: tur başlat, oy ver, tahmin et. Hamlenin kurallara uyup uymadığına sunucu karar veriyor; sıra kimde, oylama başladı mı, bu oyuncu ev sahibi mi.
3. Görünüm sunucuda, oyuncuya göre hazırlanıyor
Odada bir şey değiştiğinde sunucu her oyuncu için ayrı bir görünüm hazırlıyor ve sadece o oyuncunun grubuna gönderiyor:
public Task RoomChangedAsync(Room room)
{
var now = clock.GetUtcNow();
return Task.WhenAll(room.Players
.Where(p => !p.Left)
.Select(p => hub.Clients.Group(GameHub.PlayerGroup(room.Code, p.Id))
.SendAsync("room", RoomView.For(room, p.Id, now))));
}
Gizli bilginin nereye gideceğine karar veren yer RoomView.For. Kart kısmı şöyle:
card = round!.Deal.IsSpy(playerId)
? new CardView(true, Place: null, places, round.Deal.SpyIds.Count)
: new CardView(false, round.Deal.Word, places, round.Deal.SpyIds.Count);
Casusun kartında mekân alanı null. Siviller mekânı görüyor ama kimin casus olduğunu görmüyor; kartta sadece kaç casus olduğu yazıyor. Casusların listesi ancak tur bitince, sonuç görünümünde herkese gidiyor.
Mekân listesi ise herkese aynı gidiyor. Bu bilinçli bir karar: liste odanın ayarı, herkes için aynı ve kimin casus olduğunu ele vermiyor. Son tahmin aşamasında tahmin seçenekleri sadece suçlanan oyuncuya gönderiliyor.
Bu tasarımın bir faydası daha var: görünüm bir C# kaydı (record) olduğu için tarayıcıya gidebilecek her alan tek bir dosyada, gözle okunabilir hâlde duruyor. Yeni bir alan eklemek, "bu alan kime gidecek" sorusunu baştan cevaplamayı gerektiriyor.
Gerçek bağlantılarla test
Bu tür bir kuralı sadece RoomView.For fonksiyonunu çağırarak test etmek yetmez, çünkü sızıntı başka bir yoldan da olabilir: yanlış gruba giden bir mesaj ya da unutulan bir alan. Bu yüzden testi uçtan uca yazdım.
Test, uygulamayı WebApplicationFactory ile bellekte ayağa kaldırıyor ve dört gerçek SignalR bağlantısıyla bir tur oynuyor: oda kuruluyor, üç kişi katılıyor, tur başlıyor, herkes casusa oy veriyor, casus yanlış tahmin ediyor. Test, casusun bağlantısına gelen her ham JSON mesajını saklıyor ve sonuç ekranından önce hiçbirinde mekânın ya da casus listesinin geçmediğini kontrol ediyor:
// Sonuçlardan önce casusun bağlantısına gelen hiçbir mesaj mekânı ele vermedi.
foreach (var raw in spy.Raw)
{
Assert.DoesNotContain("wordIndex", raw, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("spyIds", raw, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("\"place\":{", raw);
}
Ham mesaja bakmak önemli. Görünüm nesnesine bakan bir test, nesneye sonradan eklenmiş ama testin kontrol etmediği bir alanı kaçırabilir. Ham metinde ise alan nerede olursa olsun bulunur.
Testin gerçekten çalıştığını görmek için repoyu geçici bir klasöre kopyalayıp casusun kartına bilerek mekânı ekledim:
? new CardView(true, Place: round.Deal.Word, places, round.Deal.SpyIds.Count)
Test hemen kırıldı:
başarısız A_whole_round_over_real_connections_and_the_spy_never_receives_the_place
Assert.DoesNotContain() Failure: Sub-string found
Found: ""place":{"
Değişiklik geri alınınca altı oyun akışı testinin hepsi geçti.
Aynı test dosyasında kanalın kendisini koruyan testler de var:
- Oturumu olan ama bu odada olmayan biri bağlanmaya çalışınca bağlantı kapanıyor ve ona hiçbir durum gönderilmiyor.
- Başka bir sitenin sayfası oyuncunun çereziyle kanalı açmaya çalışınca sunucu 403 dönüyor.
- Ev sahibi olmayan biri turu başlatamıyor, oylama başlamadan oy verilemiyor.
- Bir bağlantı 10 saniyede 20'den fazla çağrı yaparsa fazlası oyuna ulaşmadan reddediliyor.
Özet
- Tarayıcıya giden her bilgi okunabilir. Gizli kalması gereken bilgi istemciye hiç gönderilmemeli; arayüzde göstermemek yetmez.
- Eski Casus'ta casusun kimliği sayfanın kaynağındaydı ve rol duyurusu SignalR'da bütün odaya gidiyordu.
- Yeni sürümde her oyuncunun kendi SignalR grubu var. Sunucu her değişiklikten sonra her oyuncu için ayrı bir görünüm hazırlıyor; casusa mekân, sivillere casusun kimliği gitmiyor.
- Oyuncunun kimliği bağlantı açılırken oturumdan alınıyor, hub metotları kimlik parametresi almıyor.
- Dört gerçek bağlantıyla oynanan bir tur, casusa giden her ham mesajı kontrol ediyor. Bilerek sızıntı eklediğimde test kırıldı.
Casus canlı olarak spygame.miracdeprem.com adresinde. Kaynak kodu GitHub'da: github.com/MrcDprm/spy-game.