Cara Membangun Game Inventory System Database Integration yang Aman untuk Item yang Diinspeksi
Ringkasnya
Artikel ini menjelaskan cara mengintegrasikan game inventory system dengan database yang aman untuk mencegah eksploitasi duplikasi item oleh pemain. Melalui pendekatan server-authoritative, pengembang dipandu untuk memvalidasi interaksi inspeksi item menggunakan Unreal Engine C++ dan mendesain database schema yang tepat baik pada PostgreSQL maupun NoSQL. Selain itu, artikel ini juga membahas implementasi backend verification menggunakan token ephemeral serta kemudahan integrasi cloud persistence menggunakan platform horizOn.
Pemain Anda akan mengeksploitasi celah apa pun untuk menduplikasi item langka, dan client-authoritative state transition adalah target favorit mereka. Dalam game horor, game petualangan, dan RPG, aksi menginspeksi objek—seperti mengambil kunci, memutarnya secara 3D untuk mencari petunjuk, lalu memasukkannya ke inventory pemain—adalah mekanik klasik. Jika game client Anda secara langsung menentukan kapan sebuah item ditambahkan ke inventory tanpa server verification, tool packet injection sederhana seperti Cheat Engine atau Fiddler dapat mengelabui client untuk mengirimkan sinyal "add item" untuk item yang bahkan belum pernah dilihat oleh pemain. Untuk mencegah hal ini, developer harus mengimplementasikan game inventory system database integration yang kokoh untuk menghubungkan client-side interaction dengan server-authoritative logic dan cloud persistence yang aman.
Lifecycle Inspection-to-Inventory
Untuk membangun inventory sync yang aman, pertama-tama kita harus membedah item inspection lifecycle. Sekuens ini mengoordinasikan actor di dunia fisik, localized client-side inspection state, server-authoritative inventory component, dan penyimpanan database.
- Interaction Detection: Pemain mendekati physical actor (
AInspectableActor) di dalam game world. Line trace atau collision volume akan menandai objek tersebut sebagai interaktif. - Inspection Mode Transition: Pemain menekan tombol interaksi. Client akan masuk ke localized inspection state, mengunci pergerakan karakter, memutar objek ke dalam screen-space coordinates container khusus, dan me-render overlay deskripsi 2D.
- Verification Stage: Pemain mengklik "Take". Alih-alih client langsung menambahkan item ke inventory, client akan mengirimkan request interaction token ke server.
- Server Validation: Server mengonfirmasi bahwa pemain berada dalam radius interaksi fisik (~250 Unreal unit) dari actor tersebut dan actor tersebut berstatus aktif.
- Database Integration: Server menambahkan item ke array inventory, menulis update ke persistent database, dan mem-broadcast destruction event untuk world actor tersebut.
Jika Anda sedang mengembangkan game multiplayer, mengelola state ini di server sangatlah krusial. Banyak tim menghadapi multiplayer inventory nightmares dengan swapped actor component owners di Unreal Engine ketika mereka tidak mengonfigurasi replication dan network authority dengan benar pada custom inventory component.
Menulis Logika Inspeksi C++ di Unreal Engine
Untuk mengimplementasikan flow ini, kita akan membuat tiga komponen: sebuah interface (IInspectableInterface), sebuah inspectable actor (AInspectableActor), dan sebuah replicated player inventory component (UInventoryComponent).
Berikut adalah file header interface yang menunjukkan bagaimana actor menerima perintah inspeksi:
// 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;
};
Selanjutnya, mari kita implementasikan class AInspectableActor. Class ini menangani objek fisik di dunia game, menyimpan identifier uniknya, jarak interaksi maksimum, dan state-nya.
// 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;
};
Berikut adalah file implementasi untuk inspectable actor tersebut:
// 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();
}
Sekarang, mari kita buat UInventoryComponent yang mengelola list inventory pemain dan mereplikasi data ini melalui jaringan ke client pemain.
// 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;
};
Dan file implementasi tempat kita mendefinisikan logika RPC replication dan 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
}
Merancang Database Schema Inventory
Setelah server memvalidasi bahwa pemain memang telah menginspeksi item tersebut, perubahan state ini harus disimpan secara persisten. Memilih antara relational (SQL) atau document (NoSQL) store akan memengaruhi cara Anda menstrukturkan database schema Anda.
| Metrik Evaluasi | Relational (PostgreSQL) | Document Store (NoSQL / MongoDB) |
|---|---|---|
| Struktur Data | Normalized table, strict foreign key | Nested key-value document & array |
| Transaction Safety | Full ACID-compliance secara out of the box | Atomic operation terbatas pada single document |
| Kompleksitas Query | Tinggi (Membutuhkan query SQL JOIN untuk item lookup) | Rendah (Direct lookup pada profil pemain) |
| Skalabilitas | Vertikal (Membutuhkan sharding manual untuk scaling) | Horizontal (Built-in sharding capability) |
Relational Schema (PostgreSQL)
Dalam relational database, Anda perlu melakukan decouple pada data pemain, global item metadata, dan list inventory aktif untuk menghindari redundansi data. Ini membutuhkan tiga tabel:
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)
Dalam document database, Anda menyimpan inventory pemain sebagai nested array di dalam core document milik pemain tersebut. Hal ini memungkinkan backend Anda untuk mengambil keseluruhan state pemain dalam satu query saja:
{
"_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"
}
Dilema Sinkronisasi: Client-Side vs Server-Authoritative Save
Sinkronisasi state ini akan memengaruhi pengalaman pemain dalam game dan seberapa tangguh sistem tersebut terhadap upaya cheat. Jika client-side code menyimpan data langsung ke database, pemain dapat memodifikasi memory address untuk menulis nilai sembarangan ke database.
Untuk mencegah hal ini, Anda harus mengimplementasikan server-authoritative validation:
- Interaction Token: Ketika pemain mulai menginspeksi sebuah item, server akan membuat interaction token ephemeral (berlaku selama 60 detik) dan meregistrasikannya pada instance world actor yang aktif.
- Double-Spend Protection: Ketika pemain mengambil item, server akan mengonsumsi token tersebut. Jika packet pickup duplikat diterima, server akan menolaknya.
- Synchronous Replication: Memastikan perubahan inventory langsung di-push ke tampilan client secara seketika.
Selain itu, untuk men-push visual update ke inventory UI di seluruh game client yang terhubung secara real-time, mengandalkan mekanisme polling yang berat akan membebani tick rate server Anda. Alih-alih demikian, Anda sebaiknya meninggalkan HTTP polling dan mengimplementasikan koneksi Websocket khusus untuk sinkronisasi inventory secara real-time.
Mengimplementasikan Kode Verifikasi Backend
Jika Anda memilih untuk menulis custom server sendiri, Anda memerlukan API endpoint yang menangani update database dengan aman. Di bawah ini adalah snippet backend Express Node.js yang menggunakan PostgreSQL. Script ini menangani connection pooling, transaction isolation, dan token consumption untuk menulis data item pickup:
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' });
}
});
Membangun infrastruktur ini sendiri membutuhkan developer overhead yang tinggi. Anda harus menyiapkan load balancer, memprovisikan database cluster, menulis custom client-side retry manager, dan membangun protokol autentikasi yang aman. Itu bisa dengan mudah memakan waktu 4-6 minggu pengerjaan infrastruktur sebelum Anda menulis satu baris pun kode game loop.
Menyederhanakan State Persistence dengan horizOn
Alih-alih menulis custom Express middleware, mengelola connection pool PostgreSQL, dan menghadapi network dropout, Anda dapat menyerahkan kompleksitas ini kepada horizOn. Dengan horizOn, Anda mendapatkan solusi database yang fully managed dan dirancang khusus untuk pengembangan game.
Dengan menggunakan fitur cloud database, Anda tidak perlu menulis script backend apa pun untuk menangani state validation. Anda dapat memicu persistent database write secara langsung dari instance Unreal Engine server yang server-authoritative menggunakan 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();
}
Ini akan meng-update database secara transaksional, memastikan pemain tidak dapat memanipulasi (spoof) data pada client mereka, dan secara otomatis melakukan caching update secara lokal jika pemain mengalami gangguan internet sementara.
Best Practices untuk Persistent Game Inventory
Untuk membangun inventory system berkinerja tinggi, terapkan beberapa best practices berikut:
- Enforce Distance-Based Validation pada Server: Periksa
FVector::Distantara pawn dan inspectable actor sebelum menambahkan item. Jika jaraknya tidak masuk akal secara fisik, catat hal tersebut sebagai aktivitas client yang mencurigakan (suspicious client activity). - Utilize Idempotent Transaction Token: Buat UUID transaksi unik di server saat inspeksi dimulai. Ini mencegah bug double-increment saat network packet dikirim ulang akibat adanya latency spike.
- Gunakan Optimistic Local Update dengan Server Correction: Buat UI terasa responsif dengan meng-update layar inventory pemain secara langsung di sisi client, namun pertahankan statusnya dalam state "Pending Confirmation" sampai server mengembalikan konfirmasi write yang sukses.
- Log State Anomaly: Monitor seberapa sering client mencoba mengambil item yang tidak ditandai sebagai telah diinspeksi dalam sesi aktif mereka, karena ini merupakan indikator utama penggunaan tool cheat injection.
Siap untuk men-scale backend multiplayer Anda? Coba horizOn secara gratis atau pelajari dokumentasi developer kami untuk memulainya hari ini.
Sumber: How do you put an item into the inventory after inspecting it?