Retour au Blog

Comment concevoir une intégration de base de données sécurisée pour un système d'inventaire de jeu pour les objets inspectés

Publié le 25 juin 2026
Comment concevoir une intégration de base de données sécurisée pour un système d'inventaire de jeu pour les objets inspectés

En bref

Ce guide explique comment concevoir une intégration de base de données sécurisée pour un système d'inventaire de jeu afin de prévenir la triche par duplication d'objets. Il détaille l'implémentation d'un flux d'inspection d'objet server-authoritative en C++ avec Unreal Engine, soutenu par des jetons de transaction éphémères. Il compare également les schémas relationnels et NoSQL, tout en démontrant comment simplifier la persistance et les validations grâce au SDK de horizOn.

Vos joueurs exploiteront la moindre faille pour dupliquer des objets rares, et les transitions d'état client-authoritative sont leur cible favorite. Dans les jeux d'horreur, d'aventure et les RPG, l'action d'inspecter un objet — comme ramasser une clé, la faire pivoter en 3D pour trouver un indice et l'ajouter à l'inventaire du joueur — est une mécanique classique. Si votre client de jeu décide directement du moment où un objet est ajouté à l'inventaire sans vérification du serveur, un simple outil d'injection de paquets comme Cheat Engine ou Fiddler peut tromper le client pour lui faire envoyer des signaux « add item » pour des objets que le joueur n'a même pas vus. Pour éviter cela, les développeurs doivent implémenter une intégration de base de données robuste pour leur système d'inventaire de jeu, reliant les interactions côté client à une logique server-authoritative et à une persistance cloud sécurisée.

Le cycle de vie de l'inspection à l'inventaire

Pour concevoir une synchronisation d'inventaire sécurisée, nous devons d'abord décomposer le cycle de vie de l'inspection des objets. Cette séquence coordonne les acteurs du monde physique, les états d'inspection localisés côté client, les composants d'inventaire server-authoritative et le stockage en base de données.

  1. Détection de l'interaction : Le joueur s'approche d'un acteur physique (AInspectableActor) dans le monde du jeu. Un line trace ou un volume de collision marque l'objet comme interactif.
  2. Transition vers le mode d'inspection : Le joueur appuie sur la touche d'interaction. Le client entre dans un état d'inspection localisé, bloquant le mouvement du personnage, faisant pivoter l'objet dans un conteneur de coordonnées d'écran (screen-space) dédié et affichant un overlay de description en 2D.
  3. Étape de vérification : Le joueur clique sur « Prendre » (Take). Au lieu que le client ajoute l'objet à l'inventaire, il envoie une requête de jeton d'interaction (interaction token) au serveur.
  4. Validation par le serveur : Le serveur confirme que le joueur se trouve dans le rayon d'interaction physique (~250 unités Unreal) de l'acteur et que cet acteur est actif.
  5. Intégration de base de données : Le serveur ajoute l'objet au tableau d'inventaire, écrit la mise à jour dans la base de données persistante et diffuse un événement de destruction pour l'acteur dans le monde.

Si vous développez un titre multiplayer, gérer cet état sur le serveur est critique. De nombreuses équipes font face à des cauchemars d'inventaire multiplayer avec des propriétaires d'actor components intervertis dans Unreal Engine lorsqu'elles ne configurent pas correctement la replication et le network authority sur leurs composants d'inventaire personnalisés.

Écrire la logique d'inspection en C++ pour Unreal Engine

Pour implémenter ce flux, nous allons créer trois composants : une interface (IInspectableInterface), un acteur inspectable (AInspectableActor) et un composant d'inventaire joueur répliqué (UInventoryComponent).

Voici le fichier header de l'interface qui montre comment les acteurs reçoivent les commandes d'inspection :

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

Ensuite, implémentons la classe AInspectableActor. Cette classe gère l'objet physique dans le monde, en stockant son identifiant unique, sa distance d'interaction maximale et son état.

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

Voici le fichier d'implémentation pour l'acteur inspectable :

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

Créons maintenant le UInventoryComponent qui gère la liste d'inventaire du joueur et réplique ces données sur le réseau pour les joueurs clients.

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

Et le fichier d'implémentation dans lequel nous définissons la logique de réplication RPC et la validation d'écriture :

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

Conception du schéma de base de données d'inventaire

Une fois que le serveur a validé que le joueur a bel et bien inspecté l'objet, ce changement d'état doit être persisté. Choisir entre un stockage relationnel (SQL) ou document (NoSQL) modifie la façon dont vous structurez vos schémas de base de données.

Métrique d'évaluation Relationnel (PostgreSQL) Stockage Document (NoSQL / MongoDB)
Structure des données Tables normalisées, clés étrangères strictes Documents clés-valeurs et tableaux imbriqués
Sécurité des transactions Conformité ACID complète native Opérations atomiques limitées à un seul document
Complexité des requêtes Élevée (Requiert des requêtes SQL JOIN pour la recherche d'objets) Faible (Recherche directe du profil du joueur)
Scalabilité Verticale (Requiert un sharding manuel pour monter en charge) Horizontale (Fonctionnalités de sharding intégrées)

Relational Schema (PostgreSQL)

Dans une base de données relationnelle, vous voulez découpler les joueurs, les métadonnées globales des objets et les listes d'inventaire actives pour éviter la redondance des données. Cela nécessite trois 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)

Dans une base de données document, vous stockez l'inventaire du joueur sous forme de tableau imbriqué dans le document principal du joueur. Cela permet à votre backend de récupérer l'intégralité de l'état du joueur en une seule requête :

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

Le dilemme de la synchronisation : sauvegardes côté client vs server-authoritative

La synchronisation de cet état modifie l'expérience de jeu des joueurs et sa robustesse contre les tentatives de triche. Si le code côté client sauvegarde directement dans la base de données, un joueur peut modifier des adresses mémoire pour écrire des valeurs arbitraires dans la base de données.

Pour éviter cela, vous devez implémenter des validations server-authoritative :

  1. Jetons d'interaction (Interaction Tokens) : Quand un joueur commence à inspecter un objet, le serveur génère un jeton d'interaction éphémère (valide pendant 60 secondes) et l'enregistre sur l'instance d'acteur active dans le monde.
  2. Protection contre la double dépense (Double-Spend Protection) : Lorsque le joueur ramasse l'objet, le serveur consomme le jeton. Si un paquet de ramassage doublon est reçu, le serveur le rejette.
  3. Réplication synchrone : Assurez-vous que les modifications d'inventaire sont poussées immédiatement vers les écrans des clients.

De plus, pour pousser en temps réel les mises à jour visuelles vers les UI d'inventaire sur les clients de jeu connectés, le recours à de lourds mécanismes de polling HTTP va saturer le tick rate de votre serveur. À la place, vous devriez abandonner le polling HTTP et implémenter une connexion Websockets dédiée pour les synchronisations d'inventaire en temps réel.

Implémentation du code de vérification backend

Si vous choisissez de coder votre propre serveur personnalisé, vous avez besoin d'un endpoint d'API qui gère les mises à jour de base de données de manière sécurisée. Ci-dessous se trouve un extrait de backend Express en Node.js utilisant PostgreSQL. Ce script gère le pooling de connexions, l'isolation des transactions et la consommation de jetons pour enregistrer les ramassages d'objets :

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

Construire cette infrastructure vous-même implique une charge de travail importante. Vous devez configurer des load balancers, provisionner des clusters de bases de données, coder des retry managers personnalisés côté client et concevoir des protocoles d'authentification sécurisés. Cela représente facilement 4 à 6 semaines de travail d'infrastructure avant d'écrire la moindre ligne de code pour votre game loop.

Simplifier la persistance de l'état avec horizOn

Au lieu d'écrire du middleware Express personnalisé, de gérer des pools de connexions PostgreSQL et de lutter contre les déconnexions réseau, vous pouvez déléguer cette complexité à horizOn. Avec horizOn, vous bénéficiez d'une solution de base de données entièrement managée, conçue spécifiquement pour le développement de jeux.

Grâce aux fonctionnalités de base de données cloud, vous n'avez pas besoin d'écrire de scripts backend pour gérer la validation d'état. Vous pouvez déclencher des écritures persistantes en base de données directement depuis votre instance de serveur Unreal Engine server-authoritative à l'aide de la bibliothèque cliente :

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

Cela met à jour la base de données de manière transactionnelle, garantit que les joueurs ne peuvent pas falsifier (spoof) les données sur leurs clients et met automatiquement en cache les mises à jour localement en cas de déconnexions internet temporaires du joueur.

Bonnes pratiques pour les inventaires de jeu persistants

Pour concevoir un système d'inventaire performant, appliquez ces bonnes pratiques :

  1. Imposer une validation de distance sur le serveur : Vérifiez la distance (FVector::Dist) entre le pawn et l'acteur inspectable avant d'ajouter des objets. Si la distance est physiquement impossible, enregistrez-la comme une activité client suspecte.
  2. Utiliser des jetons de transaction idempotents : Générez des UUID de transaction uniques sur le serveur lorsque l'inspection commence. Cela évite les bugs de double incrémentation lorsque des paquets réseau sont renvoyés en raison de pics de latence.
  3. Utiliser des mises à jour locales optimistes avec correction du serveur : Pour rendre l'UI réactive, mettez à jour l'écran d'inventaire du joueur immédiatement côté client, mais conservez-le dans un état « En attente de confirmation » (Pending Confirmation) jusqu'à ce que le serveur renvoie une confirmation d'écriture réussie.
  4. Enregistrer les anomalies d'état (Log State Anomalies) : Surveillez la fréquence à laquelle les clients tentent de ramasser des objets qui ne sont pas marqués comme inspectés dans leur session active, car c'est un indicateur majeur d'utilisation d'outils d'injection de triche.

Prêt à faire évoluer votre backend multiplayer ? Essayez horizOn gratuitement ou consultez notre documentation développeur pour commencer dès aujourd'hui.


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