Torna al Blog

Come creare un'integrazione sicura del database per un sistema di inventario di gioco per gli oggetti ispezionati

Pubblicato il 25 giugno 2026
Come creare un'integrazione sicura del database per un sistema di inventario di gioco per gli oggetti ispezionati

In breve

Questo articolo spiega come implementare un'integrazione sicura del database per la gestione dell'inventario di gioco, focalizzandosi sulla transizione degli oggetti dall'ispezione alla raccolta. Viene illustrata la logica C++ per Unreal Engine per prevenire il cheating e la duplicazione degli oggetti tramite validazioni server-authoritative, verifiche di distanza e interaction token. Vengono inoltre messi a confronto gli schemi di database relazionali (PostgreSQL) e orientati ai documenti (NoSQL), mostrando come semplificare la persistenza dello stato delegando l'infrastruttura di rete a horizOn.

I tuoi giocatori sfrutteranno qualsiasi scappatoia per duplicare oggetti rari, e le transizioni di stato client-authoritative sono il loro bersaglio preferito. Nei giochi horror, di avventura e RPG, l'azione di ispezionare un oggetto — come raccogliere una chiave, ruotarla in 3D per trovare un indizio e aggiungerla all'inventario del giocatore — è una meccanica classica. Se il client di gioco stabilisce direttamente quando un oggetto viene aggiunto all'inventario senza una verifica da parte del server, un semplice tool di packet injection come Cheat Engine o Fiddler può ingannare il client spingendolo a inviare segnali "add item" per oggetti che il giocatore non ha nemmeno visto. Per evitare questo, gli sviluppatori devono implementare una robusta integrazione del database per il sistema di inventario del gioco, che connetta le interazioni client-side a una logica server-authoritative e a una persistenza cloud sicura.

Il ciclo di vita dall'ispezione all'inventario

Per creare una sincronizzazione sicura dell'inventario, dobbiamo prima analizzare il ciclo di vita dell'ispezione degli oggetti. Questa sequenza coordina gli actor del mondo fisico, gli stati di ispezione client-side localizzati, i componenti dell'inventario server-authoritative e l'archiviazione nel database.

  1. Rilevamento dell'interazione: Il giocatore si avvicina a un actor fisico (AInspectableActor) nel mondo di gioco. Un line trace o un collision volume contrassegna l'oggetto come interattivo.
  2. Transizione alla modalità ispezione: Il giocatore preme il tasto di interazione. Il client entra in uno stato di ispezione localizzato, bloccando il movimento del personaggio, ruotando l'oggetto in un container dedicato alle coordinate dello screen-space e renderizzando un overlay 2D con la descrizione.
  3. Fase di verifica: Il giocatore fa clic su "Take". Invece di aggiungere l'oggetto direttamente all'inventario dal client, quest'ultimo invia una richiesta di interaction token al server.
  4. Validazione lato server: Il server conferma che il giocatore si trova entro il raggio d'interazione fisica (~250 Unreal units) dell'actor e che quest'ultimo è attivo.
  5. Integrazione con il database: Il server aggiunge l'oggetto all'array dell'inventario, scrive l'aggiornamento nel database persistente e invia in broadcast un evento di distruzione per l'actor nel mondo.

Se stai sviluppando un titolo multiplayer, gestire questo stato sul server è fondamentale. Molti team si scontrano con gli incubi dell'inventario multiplayer causati dallo scambio di proprietari dei componenti actor in Unreal Engine quando non configurano correttamente la replication e la network authority su componenti di inventario personalizzati.

Scrivere la logica di ispezione in C++ per Unreal Engine

Per implementare questo flusso, creeremo tre componenti: un'interfaccia (IInspectableInterface), un actor ispezionabile (AInspectableActor) e un componente di inventario del giocatore replicato (UInventoryComponent).

Ecco il file header dell'interfaccia che definisce come gli actor ricevono i comandi di ispezione:

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

Ora implementiamo la classe AInspectableActor. Questa classe gestisce l'oggetto fisico nel mondo, memorizzando il suo identificativo unico, la distanza massima di interazione e lo stato.

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

Ecco il file di implementazione per l'actor ispezionabile:

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

Ora creiamo il componente UInventoryComponent, che gestisce l'elenco degli oggetti dell'inventario del giocatore e replica questi dati sulla rete per i client dei giocatori.

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

E il file di implementazione in cui definiamo la logica di replication delle RPC e la validazione:

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

Progettare lo schema del database dell'inventario

Una volta che il server ha validato che il giocatore ha effettivamente ispezionato l'oggetto, questo cambio di stato deve essere reso persistente. Scegliere tra un database relazionale (SQL) e un database a documenti (NoSQL) cambia il modo in cui strutturi gli schemi del database.

Metrica di valutazione Relazionale (PostgreSQL) Document Store (NoSQL / MongoDB)
Struttura dei dati Tabelle normalizzate, foreign keys rigide Documenti chiave-valore e array annidati
Sicurezza delle transazioni Piena conformità ACID nativa Operazioni atomiche limitate a singoli documenti
Complessità delle query Alta (Richiede query JOIN in SQL per recuperare gli oggetti) Bassa (Ricerca diretta del profilo del giocatore)
Scalabilità Verticale (Richiede sharding manuale per scalare) Orizzontale (Supporto nativo allo sharding)

Schema relazionale (PostgreSQL)

In un database relazionale, è consigliabile disaccoppiare i giocatori, i metadati globali degli oggetti e gli elenchi degli inventari attivi per evitare la ridondanza dei dati. Ciò richiede tre tabelle:

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

Schema a documenti (NoSQL)

In un database a documenti, l'inventario del giocatore viene memorizzato come array annidato all'interno del documento principale del giocatore stesso. Questo consente al backend di recuperare l'intero stato del giocatore con una singola 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"
}

Il dilemma della sincronizzazione: salvataggi client-side vs server-authoritative

La sincronizzazione di questo stato influisce sia sull'esperienza di gioco dei giocatori, sia sulla solidità del sistema contro i tentativi di cheating. Se il codice client-side salva direttamente nel database, un giocatore potrebbe modificare gli indirizzi di memoria per scrivere valori arbitrari nel database.

Per prevenire questo scenario, è fondamentale implementare validazioni server-authoritative:

  1. Interaction Token: Quando un giocatore inizia a ispezionare un oggetto, il server genera un interaction token temporaneo (valido per 60 secondi) e lo registra sull'istanza attiva dell'actor nel mondo.
  2. Protezione da double-spend: Quando il giocatore raccoglie l'oggetto, il server consuma il token. Se viene ricevuto un pacchetto di raccolta duplicato, il server lo rifiuta.
  3. Replication sincrona: Garantisci che le modifiche all'inventario vengano inviate immediatamente al display del client.

Inoltre, per inviare aggiornamenti visivi all'interfaccia utente (UI) dell'inventario su tutti i client di gioco connessi in tempo reale, affidarsi a pesanti meccanismi di polling finirà per intasare il tick rate del server. Dovresti invece abbandonare il polling HTTP e implementare una connessione WebSocket dedicata per la sincronizzazione dell'inventario in tempo reale.

Implementare il codice di verifica lato backend

Se scegli di scrivere il tuo server personalizzato, avrai bisogno di un endpoint API che gestisca gli aggiornamenti del database in modo sicuro. Di seguito è riportato uno snippet di backend in Node.js ed Express che utilizza PostgreSQL. Questo script gestisce il connection pooling, l'isolamento delle transazioni e il consumo dei token per registrare la raccolta degli oggetti:

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

Costruire autonomamente questa infrastruttura comporta un notevole carico di lavoro per gli sviluppatori. Devi configurare load balancer, effettuare il provisioning di cluster di database, scrivere client-side retry manager personalizzati e sviluppare protocolli di autenticazione sicuri. Si tratta facilmente di 4-6 settimane di lavoro sull'infrastruttura ancora prima di poter scrivere una singola riga del game loop.

Semplificare la persistenza dello stato con horizOn

Invece di scrivere middleware Express personalizzati, gestire i connection pool di PostgreSQL e combattere con le disconnessioni di rete, puoi delegare tutta questa complessità a horizOn. Con horizOn, ottieni una soluzione di database completamente gestita, progettata specificamente per il game development.

Utilizzando le funzionalità di database cloud, non avrai bisogno di scrivere script di backend per gestire la validazione dello stato. Puoi attivare scritture persistenti nel database direttamente dalla tua istanza server Unreal Engine server-authoritative usando la libreria client:

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

Questo aggiorna il database in modo transazionale, garantisce che i giocatori non possano fare lo spoof dei record sui loro client e memorizza automaticamente gli aggiornamenti in una cache locale in caso di disconnessioni temporanee da internet.

Best Practice per inventari di gioco persistenti

Per creare un sistema di inventario ad alte prestazioni, implementa queste best practice:

  1. Applicare la validazione basata sulla distanza sul server: Controlla la distanza FVector::Dist tra il pawn e l'actor ispezionabile prima di aggiungere gli oggetti. Se la distanza è fisicamente impossibile, registrala nei log come attività sospetta del client.
  2. Utilizzare transaction token idempotenti: Genera UUID di transazione univoci sul server all'inizio dell'ispezione. Questo previene bug di doppio incremento quando i pacchetti di rete vengono reinviati a causa di picchi di latenza.
  3. Utilizzare aggiornamenti locali ottimistici con correzione dal server: Rendi la UI reattiva aggiornando la schermata dell'inventario del giocatore immediatamente sul client, ma mantienila in uno stato "In attesa di conferma" finché il server non restituisce una conferma di scrittura andata a buon fine.
  4. Registrare le anomalie di stato: Monitora la frequenza con cui i client tentano di raccogliere oggetti che non sono contrassegnati come ispezionati nella loro sessione attiva, poiché questo è un indicatore primario dell'uso di tool di cheat injection.

Vuoi scalare il tuo backend multiplayer? Prova horizOn gratuitamente o consulta la nostra documentazione per sviluppatori per iniziare oggi stesso.


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