Back to Blog

How to Build a Secure Game Inventory System Database Integration for Inspected Items

Published on June 25, 2026
How to Build a Secure Game Inventory System Database Integration for Inspected Items

In a nutshell

Build a secure game inventory system database integration for inspected items in Unreal Engine. Learn to sync state and save progress to the cloud.

Your players will exploit any loophole to duplicate rare items, and client-authoritative state transitions are their favorite target. In horror games, adventure games, and RPGs, the action of inspecting an object—such as picking up a key, rotating it in 3D to find a clue, and adding it to the player inventory—is a classic mechanic. If your game client directly dictates when an item is added to the inventory without server verification, a simple packet injection tool like Cheat Engine or Fiddler can trick the client into sending "add item" signals for items the player hasn't even seen. To prevent this, developers must implement a robust game inventory system database integration that connects client-side interactions to server-authoritative logic and secure cloud persistence.

The Inspection-to-Inventory Lifecycle

To build a secure inventory sync, we must first break down the item inspection lifecycle. This sequence coordinates physical world actors, localized client-side inspection states, server-authoritative inventory components, and database storage.

  1. Interaction Detection: The player approaches a physical actor (AInspectableActor) in the game world. A line trace or collision volume flags the object as interactive.
  2. Inspection Mode Transition: The player presses the interact key. The client enters a localized inspection state, locking character movement, rotating the object into a dedicated screen-space coordinates container, and rendering a 2D description overlay.
  3. Verification Stage: The player clicks "Take." Instead of the client adding the item to the inventory, it sends an interaction token request to the server.
  4. Server Validation: The server confirms the player is within the physical interaction radius (~250 Unreal units) of the actor and that the actor is active.
  5. Database Integration: The server adds the item to the inventory array, writes the update to the persistent database, and broadcasts a destruction event for the world actor.

If you are developing a multiplayer title, managing this state on the server is critical. Many teams run into multiplayer inventory nightmares with swapped actor component owners in Unreal Engine when they do not correctly configure replication and network authority on custom inventory components.

Writing the Unreal Engine C++ Inspection Logic

To implement this flow, we will create three components: an interface (IInspectableInterface), an inspectable actor (AInspectableActor), and a replicated player inventory component (UInventoryComponent).

Here is the interface header file that outlines how actors receive inspection commands:

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

Next, let's implement the AInspectableActor class. This class handles the physical object in the world, storing its unique identifier, maximum interaction range, and 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;
};

Here is the implementation file for the 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();
}

Now, let's create the UInventoryComponent which manages the player's inventory list and replicates this data across the network to client players.

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

And the implementation file where we define the RPC replication logic and write validation:

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

Designing the Inventory Database Schema

Once the server validates that the player has indeed inspected the item, this state change must be persisted. Choosing between a relational (SQL) or document (NoSQL) store changes how you structure your database schemas.

Evaluation Metric Relational (PostgreSQL) Document Store (NoSQL / MongoDB)
Data Structure Normalized tables, strict foreign keys Nested key-value documents & arrays
Transaction Safety Full ACID-compliance out of the box Atomic operations limited to single documents
Query Complexity High (Requires SQL JOIN queries for item lookups) Low (Direct lookup of the player profile)
Scalability Vertical (Requires manual sharding to scale) Horizontal (Built-in sharding capabilities)

Relational Schema (PostgreSQL)

In a relational database, you want to decouple players, global item metadata, and active inventory lists to avoid data redundancy. This requires three tables:

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

Document Schema (NoSQL)

In a document database, you store the player's inventory as a nested array inside the player's core document. This allows your backend to retrieve the player's entire state in a single query:

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

The Sync Dilemma: Client-Side vs Server-Authoritative Saves

Synchronizing this state changes how players experience the game and how robust it is against cheat attempts. If client-side code saves directly to the database, a player can modify memory addresses to write arbitrary values to the database.

To prevent this, you must implement server-authoritative validations:

  1. Interaction Tokens: When a player starts inspecting an item, the server generates an ephemeral interaction token (valid for 60 seconds) and registers it on the active world actor instance.
  2. Double-Spend Protection: When the player picks up the item, the server consumes the token. If a duplicate pickup packet is received, the server rejects it.
  3. Synchronous Replication: Ensure inventory changes are pushed to client displays immediately.

Furthermore, to push visual updates to inventory UIs across connected game clients in real-time, relying on heavy polling mechanisms will choke your server's tick rate. Instead, you should ditch HTTP polling and implement a dedicated Websockets connection for real-time inventory synchronizations.

Implementing the Backend Verification Code

If you choose to write your own custom server, you need an API endpoint that handles database updates safely. Below is a node.js Express backend snippet using PostgreSQL. This script handles connection pooling, transaction isolation, and token consumption to write 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' });
  }
});

Building this infrastructure yourself comes with high developer overhead. You must set up load balancers, provision database clusters, write custom client-side retry managers, and build secure authentication protocols. That is easily 4-6 weeks of infrastructure work before you write a single line of game loop code.

Simplifying State Persistence with horizOn

Instead of writing custom express middleware, managing PostgreSQL connection pools, and fighting network dropouts, you can offload this complexity to horizOn. With horizOn, you get a fully managed database solution designed specifically for game development.

Using the cloud database features, you don't need to write any backend scripts to handle state validation. You can trigger persistent database writes directly from your server-authoritative Unreal Engine server instance using the 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();
}

This updates the database transactionally, ensures players cannot spoof the records on their clients, and automatically caches updates locally if the player has temporary internet dropouts.

Best Practices for Persistent Game Inventories

To build a high-performance inventory system, implement these best practices:

  1. Enforce Distance-Based Validation on the Server: Check FVector::Dist between the pawn and the inspectable actor prior to adding items. If the distance is physically impossible, log it as suspicious client activity.
  2. Utilize Idempotent Transaction Tokens: Generate unique transaction UUIDs on the server when inspection starts. This prevents double-increment bugs when network packets retry due to latency spikes.
  3. Use Optimistic Local Updates with Server Correction: Make the UI feel responsive by updating the player inventory screen immediately on the client side, but keep it in a "Pending Confirmation" state until the server returns a successful write confirmation.
  4. Log State Anomalies: Monitor how often clients attempt to pick up items that aren't marked as inspected in their active session, as this is a primary indicator of cheat injection tools.

Ready to scale your multiplayer backend? Try horizOn for free or check out our developer docs to get started today.


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