İncelenen Öğeler İçin Güvenli Bir Oyun Envanter Sistemi Veritabanı Entegrasyonu Nasıl Oluşturulur
Özet olarak
Bu makale, Unreal Engine C++ ve veritabanı entegrasyonu kullanarak güvenli ve hile korumalı (server-authoritative) bir oyun envanter sisteminin nasıl oluşturulacağını açıklamaktadır. İnceleme aşamasından envantere kaydetmeye kadar olan yaşam döngüsünü ele alarak client-side manipülasyonlarını önlemek için gerekli doğrulama adımlarını ve SQL/NoSQL şema tasarımlarını karşılaştırmalı olarak sunmaktadır. Ayrıca, geliştiricilerin altyapı iş yükünü azaltmak amacıyla express backend çözümleri ile [horizOn](https://horizon.pm) bulut veritabanı entegrasyonunun pratik kullanımlarını karşılaştırmaktadır.
Oyuncularınız nadir öğeleri duplicate etmek için her türlü açığı sömürecektir ve client-authoritative durum geçişleri onların en sevdiği hedeftir. Korku oyunlarında, macera oyunlarında ve RPG'lerde, bir nesneyi inceleme eylemi —bir anahtarı almak, ipucu bulmak için 3D döndürmek ve oyuncu envanterine eklemek gibi— klasik bir mekaniktir. Eğer oyun client'ınız, server doğrulaması olmadan bir öğenin envantere ne zaman ekleneceğine doğrudan karar veriyorsa, Cheat Engine veya Fiddler gibi basit bir packet injection aracı, client'ı oyuncunun henüz görmediği öğeler için bile "öğe ekle" sinyalleri göndermesi için kandırabilir. Bunu önlemek amacıyla geliştiriciler, client-side etkileşimleri server-authoritative mantığa ve güvenli bulut kalıcılığına (cloud persistence) bağlayan sağlam bir oyun envanter sistemi veritabanı entegrasyonu kurmalıdır.
İncelemeden Envantere Yaşam Döngüsü (Lifecycle)
Güvenli bir envanter senkronizasyonu (sync) oluşturmak için öncelikle öğe inceleme yaşam döngüsünü (item inspection lifecycle) parçalara ayırmalıyız. Bu sekans; fiziksel dünyadaki actor'leri, lokalize edilmiş client-side inceleme durumlarını (inspection states), server-authoritative envanter component'lerini ve veritabanı depolamasını koordine eder.
- Etkileşim Tespiti (Interaction Detection): Oyuncu oyun dünyasındaki fiziksel bir actor'e (
AInspectableActor) yaklaşır. Bir line trace veya collision volume, nesneyi etkileşimli olarak işaretler. - İnceleme Moduna Geçiş (Inspection Mode Transition): Oyuncu etkileşim tuşuna basar. Client lokal bir inceleme durumuna (inspection state) girerek karakter hareketini kilitler, nesneyi özel bir ekran uzayı koordinat (screen-space coordinates) kapsayıcısına döndürür ve 2D açıklama katmanını (description overlay) render eder.
- Doğrulama Aşaması (Verification Stage): Oyuncu "Al" (Take) seçeneğine tıklar. Client öğeyi doğrudan envantere eklemek yerine, server'a bir etkileşim token'ı (interaction token) isteği gönderir.
- Server Doğrulaması (Server Validation): Server, oyuncunun actor'ün fiziksel etkileşim yarıçapında (~250 Unreal birimi) olduğunu ve actor'ün aktif olduğunu doğrular.
- Veritabanı Entegrasyonu (Database Integration): Server, öğeyi envanter dizisine (inventory array) ekler, güncellemeyi kalıcı veritabanına yazar ve dünya actor'ü için bir destruction event yayınlar.
Eğer bir multiplayer oyun geliştiriyorsanız, bu durumu server üzerinde yönetmek kritik öneme sahiptir. Çoğu ekip, özel envanter component'leri üzerinde replication ve network authority ayarlarını doğru şekilde yapılandırmadıklarında Unreal Engine'de yer değiştiren actor component owner'ları yüzünden multiplayer envanter kabusları ile karşılaşır.
Unreal Engine C++ İnceleme Mantığını (Inspection Logic) Yazmak
Bu akışı uygulamak için üç bileşen oluşturacağız: bir arayüz (IInspectableInterface), incelenebilir bir actor (AInspectableActor) ve replicate edilen bir oyuncu envanter bileşeni (UInventoryComponent).
İşte actor'lerin inceleme komutlarını nasıl alacağını belirleyen interface header dosyası:
// InspectableInterface.h
#pragma once
#include "CoreMinimal.h"
#include "UObject/Interface.h"
#include "InspectableInterface.generated.h"
UINTERFACE(MinimalAPI)
class UInspectableInterface : public UInterface
{
GENERATED_BODY()
};
class HORIZON_GAME_API IInspectableInterface
{
GENERATED_BODY()
public:
virtual void OnInspectStarted(APlayerController* InspectingPlayer) = 0;
virtual void OnInspectCompleted(APlayerController* InspectingPlayer, bool bWantsToTake) = 0;
};
Sırada AInspectableActor sınıfını (class) uygulamak var. Bu sınıf, dünyadaki fiziksel nesneyi yönetir; onun benzersiz kimliğini (unique identifier), maksimum etkileşim mesafesini ve durumunu saklar.
// InspectableActor.h
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "InspectableInterface.h"
#include "InspectableActor.generated.h"
UCLASS()
class HORIZON_GAME_API AInspectableActor : public AActor, public IInspectableInterface
{
GENERATED_BODY()
public:
AInspectableActor();
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Inspection")
FName ItemID;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Inspection")
FString ItemDisplayName;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Inspection")
float MaxInteractionDistance;
virtual void OnInspectStarted(APlayerController* InspectingPlayer) override;
virtual void OnInspectCompleted(APlayerController* InspectingPlayer, bool bWantsToTake) override;
protected:
virtual void BeginPlay() override;
private:
bool bIsBeingInspected;
TWeakObjectPtr<APlayerController> CurrentInspectingPlayer;
};
İşte incelenebilir actor için implementation dosyası:
// InspectableActor.cpp
#include "InspectableActor.h"
#include "GameFramework/PlayerController.h"
AInspectableActor::AInspectableActor()
{
PrimaryActorTick.bCanEverTick = false;
bIsBeingInspected = false;
MaxInteractionDistance = 250.0f;
ItemID = "item_default";
ItemDisplayName = "Generic Item";
}
void AInspectableActor::BeginPlay()
{
Super::BeginPlay();
}
void AInspectableActor::OnInspectStarted(APlayerController* InspectingPlayer)
{
if (!InspectingPlayer || bIsBeingInspected) return;
APawn* PlayerPawn = InspectingPlayer->GetPawn();
if (!PlayerPawn) return;
float Distance = FVector::Dist(PlayerPawn->GetActorLocation(), GetActorLocation());
if (Distance > MaxInteractionDistance)
{
UE_LOG(LogTemp, Warning, TEXT("Player too far to inspect %s"), *GetName());
return;
}
bIsBeingInspected = true;
CurrentInspectingPlayer = InspectingPlayer;
}
void AInspectableActor::OnInspectCompleted(APlayerController* InspectingPlayer, bool bWantsToTake)
{
if (InspectingPlayer != CurrentInspectingPlayer.Get()) return;
if (bWantsToTake && HasAuthority())
{
UActorComponent* InvComp = InspectingPlayer->GetComponentByClass(UInventoryComponent::StaticClass());
if (InvComp)
{
UInventoryComponent* Inventory = Cast<UInventoryComponent>(InvComp);
if (Inventory)
{
Inventory->Server_TryAddInspectedItem(this);
}
}
}
bIsBeingInspected = false;
CurrentInspectingPlayer.Reset();
}
Şimdi, oyuncunun envanter listesini yöneten ve bu veriyi ağ üzerinden client oyuncularına replicate eden UInventoryComponent bileşenini oluşturalım.
// InventoryComponent.h
#pragma once
#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "InventoryComponent.generated.h"
class AInspectableActor;
USTRUCT(BlueprintType)
struct FInventoryItem
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Inventory")
FName ItemID;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Inventory")
int32 Quantity;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Inventory")
FString InspectedTimestamp;
};
UCLASS(ClassGroup=(Custom), meta=(BlueprintSpawnableComponent))
class HORIZON_GAME_API UInventoryComponent : public UActorComponent
{
GENERATED_BODY()
public:
UInventoryComponent();
UFUNCTION(Server, Reliable, WithValidation)
void Server_TryAddInspectedItem(AInspectableActor* TargetActor);
bool AddItemToLocalState(FName InItemID, int32 Quantity);
void SaveInventoryToDatabase();
protected:
UPROPERTY(Replicated, BlueprintReadOnly, Category = "Inventory")
TArray<FInventoryItem> Items;
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
};
Ve RPC replication mantığını tanımlayıp doğrulama (validation) yazdığımız implementation dosyası:
// InventoryComponent.cpp
#include "InventoryComponent.cpp"
#include "InspectableActor.h"
#include "Net/UnrealNetwork.h"
UInventoryComponent::UInventoryComponent()
{
SetIsReplicatedByDefault(true);
}
void UInventoryComponent::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(UInventoryComponent, Items);
}
bool UInventoryComponent::Server_TryAddInspectedItem_Validate(AInspectableActor* TargetActor)
{
if (!TargetActor) return false;
APawn* OwnerPawn = Cast<APawn>(GetOwner());
if (!OwnerPawn) return false;
// Server-side distance check
float Distance = FVector::Dist(OwnerPawn->GetActorLocation(), TargetActor->GetActorLocation());
return Distance <= TargetActor->MaxInteractionDistance;
}
void UInventoryComponent::Server_TryAddInspectedItem_Implementation(AInspectableActor* TargetActor)
{
if (!TargetActor) return;
AddItemToLocalState(TargetActor->ItemID, 1);
SaveInventoryToDatabase();
TargetActor->Destroy();
}
bool UInventoryComponent::AddItemToLocalState(FName InItemID, int32 Quantity)
{
for (FInventoryItem& Item : Items)
{
if (Item.ItemID == InItemID)
{
Item.Quantity += Quantity;
return true;
}
}
FInventoryItem NewItem;
NewItem.ItemID = InItemID;
NewItem.Quantity = Quantity;
NewItem.InspectedTimestamp = FDateTime::UtcNow().ToString();
Items.Add(NewItem);
return true;
}
void UInventoryComponent::SaveInventoryToDatabase()
{
// Database integration trigger goes here
}
Envanter Veritabanı Şemasını Tasarlamak
Server, oyuncunun öğeyi gerçekten incelediğini doğruladıktan sonra bu durum değişikliği kalıcı hale getirilmelidir. İlişkisel (SQL) veya döküman tabanlı (NoSQL) bir veri deposu seçmek, veritabanı şemalarınızı nasıl yapılandıracağınızı değiştirir.
| Değerlendirme Metriği | İlişkisel (PostgreSQL) | Document Store (NoSQL / MongoDB) |
|---|---|---|
| Veri Yapısı | Normalleştirilmiş tablolar, katı foreign key'ler | İç içe geçmiş key-value dökümanları ve dizileri |
| Transaction Güvenliği | Kutudan çıktığı haliyle tam ACID uyumluluğu | Tek dökümanla sınırlı atomik işlemler |
| Sorgu Karmaşıklığı | Yüksek (Öğe aramaları için SQL JOIN sorguları gerektirir) | Düşük (Oyuncu profilinin doğrudan aranması) |
| Ölçeklenebilirlik | Dikey (Ölçekleme için manuel sharding gerektirir) | Yatay (Yerleşik sharding yetenekleri) |
İlişkisel Şema (PostgreSQL)
İlişkisel bir veritabanında veri tekrarını önlemek için oyuncuları, genel öğe meta verilerini (metadata) ve aktif envanter listelerini birbirinden ayırmak istersiniz. Bu durum üç tablo gerektirir:
CREATE TABLE players (
player_id VARCHAR(64) PRIMARY KEY,
username VARCHAR(100) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE game_items (
item_id VARCHAR(64) PRIMARY KEY,
display_name VARCHAR(100) NOT NULL,
item_type VARCHAR(50) DEFAULT 'QuestItem'
);
CREATE TABLE inventory_items (
id SERIAL PRIMARY KEY,
player_id VARCHAR(64) REFERENCES players(player_id) ON DELETE CASCADE,
item_id VARCHAR(64) REFERENCES game_items(item_id),
quantity INTEGER CHECK (quantity > 0),
inspected_timestamp TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
UNIQUE(player_id, item_id)
);
Döküman Şeması (NoSQL)
Döküman tabanlı bir veritabanında, oyuncunun envanterini oyuncunun ana dökümanının içinde iç içe geçmiş bir dizi (nested array) olarak saklarsınız. Bu, backend'inizin oyuncunun tüm durumunu tek bir sorguyla çekmesini sağlar:
{
"_id": "usr_90a82b3d11ef",
"username": "SurvivorX",
"inventory": [
{
"item_id": "key_rusted_01",
"quantity": 1,
"inspected_timestamp": "2026-06-25T00:02:20Z"
},
{
"item_id": "document_diary_03",
"quantity": 1,
"inspected_timestamp": "2026-06-25T00:05:12Z"
}
],
"last_updated": "2026-06-25T00:05:12Z"
}
Senkronizasyon İkilemi: Client-Side ve Server-Authoritative Kayıt Karşılaştırması
Bu durumun senkronize edilmesi, oyuncuların oyunu nasıl deneyimleyeceğini ve hile girişimlerine karşı ne kadar dayanıklı olacağını belirler. Eğer client-side kod doğrudan veritabanına kaydederse, bir oyuncu bellek adreslerini (memory address) değiştirerek veritabanına rastgele değerler yazabilir.
Bunu önlemek için server-authoritative doğrulamalar (validation) uygulamalısınız:
- Etkileşim Token'ları (Interaction Tokens): Oyuncu bir öğeyi incelemeye başladığında, server geçici bir etkileşim token'ı (60 saniye boyunca geçerli) üretir ve bunu aktif dünya actor örneği (instance) üzerinde kaydeder.
- Çift Harcama Koruması (Double-Spend Protection): Oyuncu öğeyi aldığında, server bu token'ı tüketir. Çift öğe toplama paketi (duplicate pickup packet) alınırsa, server bunu reddeder.
- Eşzamanlı Senkronizasyon (Synchronous Replication): Envanter değişikliklerinin client ekranlarına anında iletildiğinden emin olun.
Dahası, bağlı oyun client'larındaki envanter UI'larına gerçek zamanlı olarak görsel güncellemeler göndermek için ağır polling mekanizmalarına güvenmek, server'ınızın tick rate'ini boğacaktır. Bunun yerine, gerçek zamanlı envanter senkronizasyonu için HTTP polling'i bırakmalı ve özel bir Websockets bağlantısı kurmalısınız.
Backend Doğrulama Kodunun Uygulanması
Kendi özel server'ınızı yazmayı tercih ediyorsanız, veritabanı güncellemelerini güvenle işleyen bir API endpoint'ine ihtiyacınız vardır. Aşağıda PostgreSQL kullanan bir node.js Express backend kesiti yer almaktadır. Bu betik, öğe toplamalarını kaydetmek için connection pooling, transaction isolation ve token tüketimini yönetir:
const express = require('express');
const { Pool } = require('pg');
const app = express();
app.use(express.json());
const dbPool = new Pool({
connectionString: process.env.DATABASE_URL,
});
app.post('/api/v1/inventory/add', async (req, res) => {
const { playerId, itemId, interactionToken } = req.body;
if (!playerId || !itemId || !interactionToken) {
return res.status(400).json({ error: 'Missing required parameters' });
}
try {
// 1. Verify interaction token exists and is active
const tokenResult = await dbPool.query(
'SELECT status FROM interactions WHERE token = $1 AND player_id = $2',
[interactionToken, playerId]
);
if (tokenResult.rows.length === 0 || tokenResult.rows[0].status !== 'active') {
return res.status(400).json({ error: 'Invalid or expired interaction token' });
}
// 2. Begin transaction
await dbPool.query('BEGIN');
// Consume the token to prevent double-spending
await dbPool.query(
"UPDATE interactions SET status = 'consumed' WHERE token = $1",
[interactionToken]
);
// Insert or increment inventory record
await dbPool.query(
`INSERT INTO inventory_items (player_id, item_id, quantity, inspected_timestamp)
VALUES ($1, $2, 1, NOW())
ON CONFLICT (player_id, item_id)
DO UPDATE SET quantity = inventory_items.quantity + 1`,
[playerId, itemId]
);
await dbPool.query('COMMIT');
return res.status(200).json({ success: true });
} catch (error) {
await dbPool.query('ROLLBACK');
console.error('Database transaction error:', error);
return res.status(500).json({ error: 'Internal Server Error' });
}
});
Bu altyapıyı kendiniz kurmak yüksek bir geliştirici iş yükü (developer overhead) getirir. Load balancer'lar kurmalı, database cluster'ları hazırlamalı, özel client-side retry yöneticileri yazmalı ve güvenli kimlik doğrulama (authentication) protokolleri oluşturmalısınız. Bu da tek bir satır game loop kodu yazmadan önce en az 4-6 haftalık bir altyapı çalışması demektir.
horizOn ile Durum Kalıcılığını (State Persistence) Basitleştirmek
Özel express middleware'leri yazmak, PostgreSQL connection pool'larını yönetmek ve network kesintileriyle uğraşmak yerine bu karmaşıklığı horizOn'a devredebilirsiniz. horizOn ile özellikle oyun geliştirme için tasarlanmış, tamamen yönetilen (fully managed) bir veritabanı çözümüne sahip olursunuz.
Bulut veritabanı (cloud database) özelliklerini kullanarak, durum doğrulamasını (state validation) yönetmek için herhangi bir backend betiği yazmanıza gerek kalmaz. Client kütüphanesini kullanarak doğrudan server-authoritative Unreal Engine sunucu instance'ınızdan kalıcı veritabanı yazma işlemlerini tetikleyebilirsiniz:
void UInventoryComponent::SaveInventoryToCloud(FName InItemID, int32 InQuantity)
{
TSharedPtr<FJsonObject> RequestData = MakeShareable(new FJsonObject());
RequestData->SetStringField("player_id", PlayerID);
RequestData->SetStringField("item_id", InItemID.ToString());
RequestData->SetNumberField("quantity", InQuantity);
// Single call to [horizOn](https://horizon.pm)'s secure database client
FHorizonClient::Get()->Database("inventories")
->Upsert(RequestData)
->OnSuccess(this, &UInventoryComponent::OnSaveSuccess)
->OnFailure(this, &UInventoryComponent::OnSaveFailure)
->Execute();
}
Bu işlem, veritabanını transactional olarak günceller, oyuncuların kendi client'larında kayıtları manipüle edememesini (spoof edememesini) sağlar ve oyuncunun geçici internet kesintileri yaşaması durumunda güncellemeleri otomatik olarak yerelde önbelleğe alır (cache'ler).
Kalıcı Oyun Envanterleri İçin En İyi Pratikler (Best Practices)
- Server Üzerinde Mesafe Tabanlı Doğrulamayı Zorunlu Kılın (Distance-Based Validation): Öğeleri eklemeden önce pawn ile incelenebilir actor arasındaki
FVector::Distdeğerini kontrol edin. Eğer mesafe fiziksel olarak imkansızsa, bunu şüpheli client etkinliği olarak günlüğe kaydedin (log'layın). - Idempotent Transaction Token'ları Kullanın: İnceleme başladığında server üzerinde benzersiz transaction UUID'leri oluşturun. Bu, ağ paketlerinin gecikme dalgalanmaları (latency spikes) nedeniyle yeniden denendiği durumlarda çift artırma (double-increment) hatalarını önler.
- Server Düzeltmeli İyimser Yerel Güncellemeler (Optimistic Local Updates with Server Correction) Kullanın: Client tarafında oyuncu envanter ekranını anında güncelleyerek UI'ın duyarlı hissettirmesini sağlayın, ancak server başarılı bir yazma onayı dönene kadar bunu "Pending Confirmation" (Onay Bekliyor) durumunda tutun.
- Durum Anomalilerini Log'layın (Log State Anomalies): Client'ların, aktif oturumlarında incelendi olarak işaretlenmemiş öğeleri ne sıklıkla almaya çalıştığını izleyin; çünkü bu durum, hile enjeksiyon araçlarının (cheat injection tools) birincil göstergesidir.
Multiplayer backend yapınızı ölçeklendirmeye hazır mısınız? Bugün başlamak için horizOn'u ücretsiz deneyin veya developer belgelerimize göz atın.
Kaynak: How do you put an item into the inventory after inspecting it?