Wie Sie eine sichere Datenbank-Integration für ein Game-Inventory-System für inspizierte Items aufbauen
Kurz und knapp
Dieser Artikel beschreibt die Implementierung einer sicheren Datenbank-Integration für Game-Inventory-Systeme, um Item-Duplikation durch Client-Manipulation zu verhindern. Anhand von Unreal Engine C++ Beispielen wird ein server-authoritativer Inspection-Lifecycle demonstriert, der physische Distanzprüfungen und temporäre Transaktions-Tokens nutzt. Zudem werden relationale und dokumentenbasierte Datenbankschemata verglichen und bewährte Best Practices für zustandssichere Backend-Architekturen aufgezeigt.
Ihre Spieler werden jede Schwachstelle ausnutzen, um seltene Items zu duplizieren – und client-authoritative State Transitions sind dabei ihr bevorzugtes Ziel. In Horror-Games, Adventure-Spielen und RPGs ist das Inspizieren eines Objekts – etwa das Aufheben eines Schlüssels, das Drehen in 3D zur Hinweissuche und das Hinzufügen zum Player-Inventory – eine klassische Mechanik. Wenn Ihr Game-Client ohne Server-Verifizierung direkt bestimmt, wann ein Item zum Inventory hinzugefügt wird, kann ein einfaches Packet-Injection-Tool wie Cheat Engine oder Fiddler den Client austricksen, sodass er "add item"-Signale für Items sendet, die der Spieler noch nicht einmal gesehen hat. Um dies zu verhindern, müssen Entwickler eine robuste Datenbank-Integration für das Game Inventory System implementieren, die clientseitige Interaktionen mit server-authoritativer Logik und sicherer Cloud-Persistenz verbindet.
Der Inspection-to-Inventory-Lifecycle
Um einen sicheren Inventory-Sync aufzubauen, müssen wir zuerst den Lifecycle der Item-Inspektion aufschlüsseln. Diese Sequenz koordiniert Actors in der physischen Spielwelt, lokalisierte clientseitige Inspection-States, server-authoritative Inventory-Komponenten und den Datenbank-Storage.
- Interaction Detection: Der Spieler nähert sich einem physischen Actor (
AInspectableActor) in der Spielwelt. Ein Line-Trace oder ein Kollisionsvolumen markiert das Objekt als interaktiv. - Inspection Mode Transition: Der Spieler drückt die Interaktionstaste. Der Client wechselt in einen lokalisierten Inspection-State, sperrt die Charakterbewegung, rotiert das Objekt in einen dedizierten Screen-Space-Koordinaten-Container und rendert ein 2D-Beschreibungs-Overlay.
- Verification Stage: Der Spieler klickt auf „Take“. Anstatt dass der Client das Item direkt zum Inventory hinzufügt, sendet er eine Anfrage für ein Interaction-Token an den Server.
- Server Validation: Der Server bestätigt, dass sich der Spieler innerhalb des physischen Interaktionsradius (~250 Unreal-Units) des Actors befindet und dass der Actor aktiv ist.
- Database Integration: Der Server fügt das Item zum Inventory-Array hinzu, schreibt das Update in die persistente Datenbank und sendet einen Destruction-Event für den World-Actor.
Wenn Sie einen Multiplayer-Titel entwickeln, ist die Verwaltung dieses States auf dem Server von entscheidender Bedeutung. Viele Teams geraten bei Unreal Engine in Multiplayer-Inventory-Alpträume mit vertauschten Actor-Component-Ownern, wenn sie Replication und Network-Authority für eigene Inventory-Komponenten nicht korrekt konfigurieren.
Unreal Engine C++ Inspektionslogik schreiben
Um diesen Flow zu implementieren, erstellen wir drei Komponenten: ein Interface (IInspectableInterface), einen inspizierbaren Actor (AInspectableActor) und eine replicated Player-Inventory-Komponente (UInventoryComponent).
Hier ist die Interface-Header-Datei, die beschreibt, wie Actors Inspektionsbefehle empfangen:
// 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;
};
Als Nächstes implementieren wir die Klasse AInspectableActor. Diese Klasse verwaltet das physische Objekt in der Welt und speichert dessen eindeutigen Identifier, die maximale Interaktionsreichweite und den State.
// 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;
};
Hier ist die Implementierungsdatei für den inspizierbaren Actor:
// 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();
}
Erstellen wir nun die Komponente UInventoryComponent, die die Inventory-Liste des Spielers verwaltet und diese Daten über das Netzwerk an die Client-Spieler repliziert.
// 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;
};
Und die Implementierungsdatei, in der wir die RPC-Replication-Logik und die Validierung definieren:
// 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
}
Das Inventory-Datenbankschema entwerfen
Sobald der Server validiert hat, dass der Spieler das Item tatsächlich inspiziert hat, muss diese Zustandsänderung persistiert werden. Die Entscheidung zwischen einem relationalen (SQL) oder einem Dokumenten-Store (NoSQL) beeinflusst die Strukturierung Ihrer Datenbankschemata.
| Evaluationsmetrik | Relational (PostgreSQL) | Dokumenten-Store (NoSQL / MongoDB) |
|---|---|---|
| Datenstruktur | Normalisierte Tabellen, strikte Fremdschlüssel | Verschachtelte Key-Value-Dokumente & Arrays |
| Transaktionssicherheit | Volle ACID-Compliance Out-of-the-Box | Atomare Operationen beschränkt auf einzelne Dokumente |
| Abfragekomplexität | Hoch (Erfordert SQL-JOIN-Abfragen für Item-Lookups) | Niedrig (Direkter Lookup des Spielerprofils) |
| Skalierbarkeit | Vertikal (Erfordert manuelles Sharding für die Skalierung) | Horizontal (Integrierte Sharding-Fähigkeiten) |
Relationales Schema (PostgreSQL)
In einer relationalen Datenbank sollten Sie Spieler, globale Item-Metadaten und aktive Inventory-Listen entkoppeln, um Datenredundanz zu vermeiden. Dies erfordert drei Tabellen:
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)
);
Dokumenten-Schema (NoSQL)
In einer Dokumentendatenbank speichern Sie das Inventory des Spielers als verschachteltes Array innerhalb des Hauptdokuments des Spielers. Dadurch kann Ihr Backend den gesamten Status des Spielers mit einer einzigen Abfrage abrufen:
{
"_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"
}
Das Sync-Dilemma: Client-Side vs. Server-Authoritative Saves
Die Synchronisation dieses States beeinflusst sowohl die Spielerfahrung als auch die Robustheit des Spiels gegenüber Cheat-Versuchen. Wenn clientseitiger Code direkt in die Datenbank schreibt, kann ein Spieler Speicheradressen manipulieren, um beliebige Werte in die Datenbank einzutragen.
Um dies zu verhindern, müssen Sie server-authoritative Validierungen implementieren:
- Interaction Tokens: Wenn ein Spieler mit der Inspektion eines Items beginnt, generiert der Server ein temporäres (ephemeres) Interaction-Token (gültig für 60 Sekunden) und registriert es auf der aktiven World-Actor-Instanz.
- Double-Spend Protection: Wenn der Spieler das Item aufhebt, konsumiert der Server das Token. Wenn ein doppeltes Paket zum Aufheben (Pickup-Paket) empfangen wird, lehnt der Server dieses ab.
- Synchronous Replication: Stellen Sie sicher, dass Inventory-Änderungen sofort an die Displays der Clients übertragen (replicated) werden.
Zudem zwingt das Vertrauen auf schwerfälliges Polling die Tick-Rate Ihres Servers in die Knie, wenn Sie visuelle Updates in Echtzeit an die UIs der verbundenen Game-Clients pushen wollen. Stattdessen sollten Sie HTTP-Polling aufgeben und eine dedizierte WebSocket-Verbindung implementieren, um Real-Time-Inventory-Synchronisationen zu ermöglichen.
Backend-Verifizierungscode implementieren
Wenn Sie sich dafür entscheiden, einen eigenen Custom-Server zu schreiben, benötigen Sie einen API-Endpunkt, der Datenbank-Updates sicher verarbeitet. Unten finden Sie ein Node.js-Express-Backend-Snippet unter Verwendung von PostgreSQL. Dieses Skript verwaltet Connection-Pooling, Transaktionsisolation und Token-Konsumierung beim Schreiben von Item-Pickups:
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' });
}
});
Diese Infrastruktur selbst aufzubauen, bedeutet für Entwickler einen enormen Overhead. Sie müssen Load Balancer einrichten, Datenbank-Cluster bereitstellen, eigene clientseitige Retry-Manager schreiben und sichere Authentifizierungsprotokolle implementieren. Das sind locker 4–6 Wochen reine Infrastrukturarbeit, bevor Sie überhaupt die erste Zeile Code für den Game-Loop schreiben.
State-Persistenz vereinfachen mit horizOn
Anstatt eigene Express-Middleware zu schreiben, PostgreSQL-Connection-Pools zu verwalten und mit Netzwerk-Dropouts zu kämpfen, können Sie diese Komplexität an horizOn auslagern. Mit horizOn erhalten Sie eine voll verwaltete Datenbanklösung, die speziell für die Spieleentwicklung entwickelt wurde.
Dank der Cloud-Datenbank-Features müssen Sie keine Backend-Skripte schreiben, um die Zustandsvalidierung zu verarbeiten. Sie können persistente Schreibvorgänge in die Datenbank direkt aus Ihrer server-authoritativen Unreal Engine-Serverinstanz über die Client-Library triggern:
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();
}
Dies aktualisiert die Datenbank transaktional, stellt sicher, dass Spieler die Datensätze auf ihren Clients nicht manipulieren können, und cacht Updates automatisch lokal, falls der Spieler temporäre Verbindungsabbrüche hat.
Best Practices für persistente Game-Inventories
Um ein hochperformantes Inventory-System zu bauen, sollten Sie diese Best Practices umsetzen:
- Serverseitige Distanzvalidierung erzwingen: Überprüfen Sie vor dem Hinzufügen von Items das
FVector::Distzwischen dem Pawn und dem inspizierbaren Actor. Wenn die Distanz physisch unmöglich ist, protokollieren Sie dies als verdächtige Client-Aktivität. - Idempotente Transaktions-Tokens verwenden: Generieren Sie eindeutige Transaktions-UUIDs auf dem Server, sobald die Inspektion startet. Dies verhindert Double-Increment-Bugs, wenn Netzwerkpakete aufgrund von Latenzspitzen erneut gesendet werden.
- Optimistische lokale Updates mit Server-Korrektur nutzen: Sorgen Sie für eine reaktionsschnelle UI, indem Sie den Inventory-Screen des Spielers sofort auf Client-Seite aktualisieren, ihn jedoch in einem „Pending Confirmation“-Status belassen, bis der Server eine erfolgreiche Schreibbestätigung zurückgibt.
- Zustandsanomalien protokollieren: Überwachen Sie, wie oft Clients versuchen, Items aufzuheben, die in ihrer aktiven Session nicht als inspiziert markiert sind, da dies ein primärer Indicator für Cheat-Injection-Tools ist.
Bereit, Ihr Multiplayer-Backend zu skalieren? Testen Sie horizOn kostenlos oder werfen Sie einen Blick in unsere Developer-Docs, um noch heute loslegen.
Quelle: How do you put an item into the inventory after inspecting it?