インスペクトされたアイテム向けの安全な game inventory system database integration の構築方法
要点まとめ
本書は、プレイヤーによるアイテム複製チートを防ぐため、Unreal Engine C++ を用いた server-authoritative な inventory system database integration の構築方法を解説しています。クライアント側のインスペクション状態とサーバー側検証用トークンのライフサイクル、PostgreSQL や MongoDB におけるデータベーススキーマ設計、Node.js Express による backend の検証コードの実装について解説します。さらに、ゲーム開発特化型クラウドデータベースである horizOn を用いて、インフラ構築のオーバーヘッドを削減する手法を紹介します。
プレイヤーはレアアイテムを複製するためにあらゆる抜け穴を悪用しようとしますが、その格好の標的となるのが client-authoritative な state transitions です。ホラーゲーム、アドベンチャーゲーム、RPGにおいて、オブジェクトをインスペクトするアクション(鍵を拾い、3Dで回転させて手がかりを見つけ、プレイヤーのインベントリに追加するなど)はクラシックなゲームメカニクスです。もしゲームクライアントが server verification なしでアイテムがインベントリに追加されるタイミングを直接決定している場合、Cheat Engine や Fiddler のようなシンプルな packet injection ツールを使用して、プレイヤーがまだ見てもいないアイテムの「アイテム追加」シグナルを送信するようクライアントを騙すことができてしまいます。これを防ぐために、開発者はクライアント側のインタラクションと server-authoritative なロジック、および安全な cloud persistence を接続する、堅牢な game inventory system database integration を実装する必要があります。
The Inspection-to-Inventory Lifecycle
安全なインベントリ同期を構築するには、まず item inspection lifecycle を分解して理解する必要があります。このシーケンスは、物理ワールドの actor、ローカライズされたクライアント側の inspection state、server-authoritative な inventory component、そしてデータベースストレージを連携させます。
- Interaction Detection: プレイヤーがゲームワールド内の物理的な actor(
AInspectableActor)に近づきます。line trace または collision volume がそのオブジェクトをインタラクティブとしてフラグ付けします。 - Inspection Mode Transition: プレイヤーがインタラクトキーを押します。クライアントはローカライズされた inspection state に入り、キャラクターの移動をロックし、オブジェクトを専用の screen-space 座標コンテナ内で回転させ、2Dの説明オーバーレイを描画します。
- Verification Stage: プレイヤーが「取る」をクリックします。クライアントが直接インベントリにアイテムを追加するのではなく、サーバーに interaction token リクエストを送信します。
- Server Validation: サーバーは、プレイヤーが actor の物理的なインタラクション半径(約250 Unreal units)内にいて、その actor がアクティブであることを確認します。
- Database Integration: サーバーはアイテムをインベントリの配列に追加し、更新内容を永続データベースに書き込み、ワールド内の actor に対して destruction event をブロードキャストします。
もし multiplayer タイトルを開発しているなら、サーバー上でこの状態を管理することは極めて重要です。カスタムの inventory component における replication と network authority を正しく設定していないと、多くのチームが Unreal Engine で multiplayer inventory nightmares with swapped actor component owners in Unreal Engine に直面することになります。
Writing the Unreal Engine C++ Inspection Logic
このフローを実装するために、interface(IInspectableInterface)、inspectable actor(AInspectableActor)、そして replicated player inventory component(UInventoryComponent)の3つのコンポーネントを作成します。
以下は、actor が inspection コマンドをどのように受け取るかを示す interface の header file です:
// 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;
};
次に、AInspectableActor クラスを実装しましょう。このクラスは、ワールド内の物理オブジェクトを処理し、その一意の識別子、最大インタラクション範囲、および状態を格納します。
// 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;
};
以下は、inspectable actor の implementation file です:
// 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();
}
次に、プレイヤーのインベントリリストを管理し、ネットワークを介してクライアントプレイヤーにこのデータを replication する UInventoryComponent を作成します。
// 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;
};
そして、RPC の replication ロジックと書き込みの validation を定義する implementation file です:
// 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
サーバーが、プレイヤーが実際にアイテムをインスペクトしたことを検証したら、この状態変化を永続化する必要があります。relational database (SQL) と document store (NoSQL) のどちらを選択するかによって、database schema の構造が変わります。
| 評価指標 | Relational (PostgreSQL) | Document Store (NoSQL / MongoDB) |
|---|---|---|
| データ構造 | Normalized tables、厳格な foreign keys | ネストされた key-value documents & arrays |
| Transaction Safety | 標準で完全な ACID-compliance をサポート | 単一の document に限定された atomic operations |
| Query Complexity | High(アイテム検索のために SQL の JOIN クエリが必要) | Low(プレイヤープロファイルの直接 lookup) |
| Scalability | Vertical(スケールするために手動の sharding が必要) | Horizontal(組み込みの sharding 機能) |
Relational Schema (PostgreSQL)
relational database では、データの冗長性を避けるために、プレイヤー、グローバルなアイテムメタデータ、およびアクティブなインベントリリストを分離する必要があります。これには3つのテーブルが必要です:
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)
document database では、プレイヤーのインベントリをメインの player document 内のネストされた配列として保存します。これにより、backend は1回のクエリでプレイヤーの全状態を取得できます:
{
"_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
この状態をどのように同期するかは、プレイヤーのゲーム体験や、チート行為に対する堅牢性に大きな影響を与えます。もしクライアント側のコードが直接データベースに保存を行う場合、プレイヤーはメモリアドレスを改ざんしてデータベースに任意の値を書き込むことができます。
これを防ぐためには、server-authoritative な検証を実装する必要があります:
- Interaction Tokens: プレイヤーがアイテムのインスペクトを開始したとき、サーバーは ephemeral な interaction token(60秒間有効)を生成し、アクティブなワールド actor インスタンスに登録します。
- Double-Spend Protection: プレイヤーがアイテムを拾い上げたとき、サーバーはそのトークンを消費(consume)します。重複した pickup パケットを受信した場合、サーバーはそれを拒否します。
- Synchronous Replication: インベントリの変更が即座にクライアントの画面にプッシュされるようにします。
さらに、接続されているゲームクライアント全体のインベントリ UI に対してリアルタイムでビジュアルアップデートをプッシュする場合、負荷の高い HTTP polling に頼るとサーバーの tick rate が低下してしまいます。代わりに、リアルタイムなインベントリ同期のために ditch HTTP polling and implement a dedicated Websockets connection を行うべきです。
Implementing the Backend Verification Code
独自のカスタムサーバーを構築することを選択した場合、データベースの更新を安全に処理する API endpoint が必要になります。以下は、PostgreSQL を使用した Node.js Express backend のコードスニペットです。このスクリプトは、item pickup を書き込むために connection pooling、transaction isolation、およびトークンの消費を処理します:
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' });
}
});
このインフラをすべて自前で構築するには、多大な開発オーバーヘッドが伴います。load balancer のセットアップ、database cluster のプロビジョニング、カスタムのクライアント側リトライマネージャーの作成、そして安全な認証プロトコルの構築が必要です。ゲームループのコードを1行も書かないうちから、簡単に4〜6週間のインフラ作業が発生してしまいます。
Simplifying State Persistence with horizOn
カスタムの Express middleware を書いたり、PostgreSQL の connection pool を管理したり、ネットワークの切断に悩まされる代わりに、この複雑な処理を horizOn にオフロードできます。horizOn を利用することで、ゲーム開発専用に設計された完全マネージドなデータベースソリューションを手に入れることができます。
クラウドデータベース機能を使用すれば、状態の検証を処理するための backend スクリプトを作成する必要はありません。client library を使用して、server-authoritative な Unreal Engine サーバーインスタンスから直接、永続的なデータベース書き込みを実行できます:
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();
}
これにより、データベースがトランザクション処理として更新され、プレイヤーがクライアント側でレコードを偽装できないようにすると同時に、プレイヤーのインターネットが一時的に切断された場合でも、ローカルでアップデートを自動的にキャッシュします。
Best Practices for Persistent Game Inventories
高パフォーマンスなインベントリシステムを構築するには、以下のベストプラクティスを実装してください。
- Enforce Distance-Based Validation on the Server: アイテムを追加する前に、pawn と inspectable actor の間の
FVector::Distをチェックします。その距離が物理的に不可能な場合は、不審なクライアントアクティビティとしてログに記録します。 - Utilize Idempotent Transaction Tokens: インスペクトが開始されたときに、サーバー上でユニークなトランザクション UUID を生成します。これにより、レイテンシの急増によるネットワークパケットの再試行時に double-increment bugs が発生するのを防ぎます。
- Use Optimistic Local Updates with Server Correction: クライアント側で即座にプレイヤーのインベントリ画面を更新して UI の応答性を高めますが、サーバーから書き込み成功の確認が返されるまでは「確認待ち(Pending Confirmation)」状態を維持します。
- Log State Anomalies: アクティブなセッションでインスペクト済みとマークされていないアイテムをクライアントが拾おうとする頻度を監視します。これは、チートインジェクションツールの主な指標となります。
multiplayer backend をスケールする準備はできましたか?horizOn を無料でお試しいただくか、開発者ドキュメントを参照して今すぐ始めましょう。
Source: How do you put an item into the inventory after inspecting it?