Terug naar Blog

Hoe bouw je een veilige game inventory system database-integratie voor geïnspecteerde items

Gepubliceerd op 25 juni 2026
Hoe bouw je een veilige game inventory system database-integratie voor geïnspecteerde items

Kort samengevat

Dit artikel legt uit hoe je een veilige game inventory system database-integratie bouwt voor geïnspecteerde items. Het behandelt het implementeren van server-authoritative validatie in Unreal Engine C++ om cheat-tools zoals Cheat Engine te blokkeren. Daarnaast vergelijkt het relationele (PostgreSQL) en document-gebaseerde (NoSQL) databaseschema's voor state-persistentie. Tot slot worden best practices besproken om synchronisatie-latency te minimaliseren en exploits te voorkomen.

Spelers misbruiken elke exploit om zeldzame items te dupliceren, en client-authoritative state-transities zijn daarbij hun favoriete doelwit. In horrorgames, adventuregames en RPG's is het inspecteren van een object—zoals het oppakken van een sleutel, het in 3D roteren om een aanwijzing te vinden en het toevoegen aan de inventory van de speler—een klassieke mechanic. Als je gameclient direct bepaalt wanneer een item aan de inventory wordt toegevoegd zonder serververificatie, kan een eenvoudige packet-injectiontool zoals Cheat Engine of Fiddler de client misleiden om "add item"-signalen te sturen voor items die de speler nog niet eens heeft gezien. Om dit te voorkomen, moeten ontwikkelaars een robuuste game inventory system database-integratie implementeren die client-side interacties verbindt met server-authoritative logica en veilige cloudpersistentie.

De Inspection-to-Inventory Lifecycle

Om een veilige inventory-sync te bouwen, moeten we eerst de item inspection lifecycle ontleden. Deze volgorde coördineert physical world actors, gelokaliseerde client-side inspectiestates, server-authoritative inventory components en database-opslag.

  1. Interaction Detection: De speler benadert een fysieke actor (AInspectableActor) in de gamewereld. Een line trace of collision volume markeert het object als interactief.
  2. Inspection Mode Transition: De speler drukt op de interactieknop. De client gaat over naar een gelokaliseerde inspectiestate, vergrendelt de beweging van het personage, roteert het object in een speciale screen-space coördinatencontainer en rendert een 2D description-overlay.
  3. Verification Stage: De speler klikt op "Take". In plaats van dat de client het item aan de inventory toevoegt, stuurt deze een interactietoken-request naar de server.
  4. Server Validation: De server controleert of de speler zich binnen de fysieke interactiestraal (~250 Unreal units) van de actor bevindt en of de actor actief is.
  5. Database Integration: De server voegt het item toe aan de inventory-array, schrijft de update naar de persistente database en verzendt een destruction-event voor de world actor.

Als je een multiplayer-titel ontwikkelt, is het beheren van deze state op de server cruciaal. Veel teams lopen aan tegen multiplayer inventory nightmares met verwisselde actor component-owners in Unreal Engine wanneer ze replicatie en netwerkautoriteit op aangepaste inventory components niet correct configureren.

Unreal Engine C++ Inspection-logica schrijven

Om deze flow te implementeren, maken we drie componenten: een interface (IInspectableInterface), een inspectable actor (AInspectableActor) en een gerepliceerd player inventory component (UInventoryComponent).

Hier is het interface-headerbestand dat beschrijft hoe actors inspectiecommando's ontvangen:

// 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;
};

Vervolgens implementeren we de AInspectableActor-klasse. Deze klasse beheert het fysieke object in de wereld en slaat de unieke identificatie, het maximale interactiebereik en de state op.

// 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 is het implementatiebestand voor de inspectable 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();
}

Laten we nu de UInventoryComponent maken, die de inventory-lijst van de speler beheert en deze gegevens over het netwerk repliceert naar client-spelers.

// 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;
};

En het implementatiebestand waarin we de RPC-replicatielogica en schrijfvalidatie definiëren:

// 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
}

Het ontwerpen van het Inventory Database Schema

Zodra de server valideert dat de speler het item inderdaad heeft geïnspecteerd, moet deze state-wijziging worden gepersisteerd. De keuze tussen een relationele (SQL) of document-gebaseerde (NoSQL) store bepaalt hoe je de databaseschema's structureert.

Evaluatiemetriek Relationeel (PostgreSQL) Document Store (NoSQL / MongoDB)
Datastructuur Genormaliseerde tabellen, strikte foreign keys Geneste key-value-documenten & arrays
Transactieveiligheid Volledige ACID-compliance out-of-the-box Atomische operaties beperkt tot afzonderlijke documenten
Query-complexiteit Hoog (Vereist SQL JOIN-queries voor item-lookups) Laag (Directe lookup van het spelersprofiel)
Schaalbaarheid Verticaal (Vereist handmatige sharding om te schalen) Horizontaal (Ingebouwde sharding-mogelijkheden)

Relationeel schema (PostgreSQL)

In een relationele database wil je spelers, globale item-metadata en actieve inventory-lijsten ontkoppelen om dataredundantie te voorkomen. Hiervoor zijn drie tabellen nodig:

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)
);

Documentschema (NoSQL)

In een document-database sla je de inventory van de speler op als een geneste array binnen het hoofddocument van de speler. Hierdoor kan je backend de volledige state van de speler in één query ophalen:

{
  "_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"
}

Het synchronisatiedilemma: Client-Side vs Server-Authoritative Saves

Hoe je deze state synchroniseert, beïnvloedt de manier waarop spelers de game ervaren en hoe robuust deze is tegen cheatpogingen. Als client-side code rechtstreeks naar de database schrijft, kan een speler geheugenadressen aanpassen om willekeurige waarden naar de database te schrijven.

Om dit te voorkomen, moet je server-authoritative validaties implementeren:

  1. Interaction Tokens: Wanneer een speler begint met het inspecteren van een item, genereert de server een tijdelijk interactietoken (60 seconden geldig) en registreert dit op de actieve world actor-instantie.
  2. Double-Spend Protection: Wanneer de speler het item oppakt, verbruikt de server het token. Als er een dubbel pickup-packet wordt ontvangen, weigert de server dit.
  3. Synchronous Replication: Zorg ervoor dat inventory-wijzigingen direct naar de schermen van de clients worden gepusht.

Bovendien zal het vertrouwen op zware pollingmechanismen om visuele updates in real-time naar de inventory-UI's op verbonden gameclients te pushen, de tick rate van je server verstikken. In plaats daarvan kun je beter HTTP-polling achterwege laten en een dedicated Websockets-verbinding implementeren voor real-time inventory-synchronisaties.

De Backend-verificatiecode implementeren

Als je ervoor kiest om je eigen aangepaste server te schrijven, heb je een API-endpoint nodig die database-updates veilig afhandelt. Hieronder vind je een Node.js Express-backend-snippet die gebruikmaakt van PostgreSQL. Dit script beheert connection pooling, transactie-isolatie en het verbruiken van tokens om item-pickups weg te schrijven:

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' });
  }
});

Het zelf bouwen van deze infrastructuur brengt veel developer overhead met zich mee. Je moet load balancers opzetten, database-clusters inrichten, aangepaste client-side retry-managers schrijven en beveiligde authenticatieprotocollen bouwen. Dat is al snel 4 tot 6 weken aan infrastructuurwerk voordat je ook maar één regel game-loop-code schrijft.

State-persistentie vereenvoudigen met horizOn

In plaats van het schrijven van aangepaste Express-middleware, het beheren van PostgreSQL-connection pools en het oplossen van netwerkstoringen, kun je deze complexiteit uitbesteden aan horizOn. Met horizOn krijg je een volledig beheerde database-oplossing die specifiek is ontworpen voor game development.

Dankzij de cloud database-functionaliteiten hoef je geen backend-scripts te schrijven om state-validatie af te handelen. Je kunt persistente database-writes rechtstreeks triggeren vanaf je server-authoritative Unreal Engine-serverinstantie met behulp van de client library:

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();
}

Dit updatet de database transactioneel, zorgt ervoor dat spelers de records op hun clients niet kunnen spoofen, en cachet updates automatisch lokaal als de speler te maken heeft met tijdelijke internetstoringen.

Best Practices voor Persistente Game Inventories

Implementeer deze best practices om een high-performance inventory system te bouwen:

  1. Enforce Distance-Based Validation on the Server: Controleer FVector::Dist tussen de pawn en de inspectable actor voordat je items toevoegt. Als de afstand fysiek onmogelijk is, log dit dan als verdachte clientactiviteit.
  2. Utilize Idempotent Transaction Tokens: Genereer unieke transactie-UUID's op de server wanneer de inspectie start. Dit voorkomt double-increment bugs wanneer netwerkpackets opnieuw worden verzonden door latency-spikes.
  3. Use Optimistic Local Updates with Server Correction: Maak de UI responsief door het inventory-scherm van de speler direct aan de client-side bij te werken, maar houd dit in een "Pending Confirmation"-state totdat de server een succesvolle write-bevestiging retourneert.
  4. Log State Anomalies: Monitor hoe vaak clients proberen items op te pakken die niet als geïnspecteerd zijn gemarkeerd in hun actieve sessie, aangezien dit een belangrijke indicator is voor cheat-injectiontools.

Klaar om je multiplayer backend te schalen? Probeer horizOn gratis of bekijk onze developer docs om vandaag nog aan de slag te gaan.


Bron: How do you put an item into the inventory after inspecting it?