Powrót do Bloga

Jak zbudować bezpieczną integrację bazy danych z systemem ekwipunku w grze dla badanych przedmiotów

Opublikowano 25 czerwca 2026
Jak zbudować bezpieczną integrację bazy danych z systemem ekwipunku w grze dla badanych przedmiotów

W skrócie

Artykuł omawia proces projektowania i implementacji bezpiecznej synchronizacji systemu ekwipunku z bazą danych na przykładzie Unreal Engine C++ oraz backendu Node.js. Przedstawiono w nim architekturę opartą na walidacji po stronie serwera oraz tokenach interakcji chroniących przed duplikacją przedmiotów przez złośliwych klientów. Omówiono także różnice w projektowaniu schematów baz PostgreSQL oraz MongoDB, a także zaprezentowano gotowe rozwiązanie chmurowe od horizOn upraszczające zarządzanie stanem gry.

Gracze wykorzystają każdą lukę, aby duplikować rzadkie przedmioty, a przejścia stanów typu client-authoritative są ich ulubionym celem. W horrorach, grach przygodowych i RPG klasyczną mechaniką jest interakcja polegająca na badaniu obiektu – na przykład podniesieniu klucza, obracaniu go w 3D w poszukiwaniu wskazówki i dodaniu go do ekwipunku gracza. Jeśli klient gry bezpośrednio decyduje o dodaniu przedmiotu do ekwipunku bez weryfikacji po stronie serwera, proste narzędzie do wstrzykiwania pakietów (packet injection), takie jak Cheat Engine lub Fiddler, może oszukać klienta, zmuszając go do wysłania sygnałów „dodaj przedmiot” dla rzeczy, których gracz nawet nie widział. Aby temu zapobiec, deweloperzy muszą zaimplementować solidną integrację bazy danych z systemem ekwipunku w grze, która łączy interakcje po stronie klienta (client-side) z logiką autorytatywną dla serwera (server-authoritative) i bezpiecznym zapisywaniem stanu w chmurze (cloud persistence).

Cykl życia od badania do ekwipunku

Aby zbudować bezpieczną synchronizację ekwipunku, musimy najpierw przeanalizować cykl życia badania przedmiotów. Sekwencja ta koordynuje actorów w świecie gry, lokalne stany badania po stronie klienta, autorytatywne dla serwera komponenty ekwipunku oraz zapis w bazie danych.

  1. Wykrywanie interakcji (Interaction Detection): Gracz zbliża się do fizycznego actora (AInspectableActor) w świecie gry. Line trace lub collision volume oznacza obiekt jako interaktywny.
  2. Przejście do trybu badania: Gracz naciska klawisz interakcji. Klient wchodzi w lokalny stan badania (inspection state), blokując ruch postaci, obracając obiekt w dedykowanym kontenerze współrzędnych ekranu (screen-space coordinates) i renderując dwuwymiarową nakładkę (2D description overlay) z opisem.
  3. Etap weryfikacji: Gracz klika „Weź”. Zamiast bezpośredniego dodania przedmiotu do ekwipunku przez klienta, wysyła on do serwera żądanie tokenu interakcji (interaction token request).
  4. Walidacja po stronie serwera: Serwer potwierdza, że gracz znajduje się w promieniu interakcji (~250 jednostek Unreal) od actora oraz że actor jest aktywny.
  5. Integracja z bazą danych: Serwer dodaje przedmiot do tablicy ekwipunku, zapisuje aktualizację w trwałej bazie danych i rozsyła (broadcast) zdarzenie zniszczenia actora w świecie gry.

Jeśli tworzysz grę multiplayer, zarządzanie tym stanem na serwerze jest kluczowe. Wiele zespołów napotyka na koszmary z ekwipunkiem multiplayer i podmienionymi właścicielami komponentów Actorów w Unreal Engine, gdy niepoprawnie konfigurują replikację i uprawnienia sieciowe (network authority) w niestandardowych komponentach ekwipunku.

Implementacja logiki badania w Unreal Engine C++

Aby zaimplementować ten przepływ, stworzymy trzy komponenty: interfejs (IInspectableInterface), badany actor (AInspectableActor) oraz replikowany komponent ekwipunku gracza (UInventoryComponent).

Oto plik nagłówkowy interfejsu, który określa, jak actory odbierają polecenia badania:

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

Następnie zaimplementujmy klasę AInspectableActor. Klasa ta obsługuje fizyczny obiekt w świecie gry, przechowując jego unikalny identyfikator, maksymalny zasięg interakcji oraz stan.

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

Oto plik implementacji dla badanego actora:

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

Teraz stwórzmy UInventoryComponent, który zarządza listą ekwipunku gracza i replikuje te dane przez sieć do klientów gry.

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

Oraz plik implementacji, w którym definiujemy logikę replikacji RPC oraz walidację zapisu:

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

Projektowanie schematu bazy danych ekwipunku

Gdy serwer zweryfikuje, że gracz rzeczywiście zbadał przedmiot, zmiana stanu musi zostać zapisana. Wybór między relacyjną bazą danych (SQL) a bazą dokumentową (NoSQL) zmienia sposób strukturyzacji schematów bazy danych.

Metryka oceny Relacyjna (PostgreSQL) Dokumentowa (NoSQL / MongoDB)
Struktura danych Znormalizowane tabele, rygorystyczne klucze obce Zagnieżdżone dokumenty klucz-wartość i tablice
Bezpieczeństwo transakcji Pełna zgodność z ACID po wyjęciu z pudełka Operacje atomowe ograniczone do pojedynczych dokumentów
Złożoność zapytań Wysoka (Wymaga zapytań SQL JOIN do wyszukiwania przedmiotów) Niska (Bezpośrednie wyszukiwanie profilu gracza)
Skalowalność Pionowa (Wymaga ręcznego shardingu do skalowania) Pozioma (Wbudowane możliwości shardingu)

Schemat relacyjny (PostgreSQL)

W relacyjnej bazie danych warto rozdzielić graczy, globalne metadane przedmiotów oraz aktywne listy ekwipunku, aby uniknąć nadmiarowości danych. Wymaga to trzech 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)
);

Schemat dokumentowy (NoSQL)

W bazie dokumentowej przechowujesz ekwipunek gracza jako zagnieżdżoną tablicę wewnątrz głównego dokumentu gracza. Pozwala to Twojemu backendowi pobrać cały stan gracza w pojedynczym zapytaniu:

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

Dylemat synchronizacji: zapisy po stronie klienta vs autorytatywne dla serwera

Synchronizacja tego stanu wpływa na doświadczenia graczy z rozgrywki oraz na odporność gry na próby oszukiwania. Jeśli kod po stronie klienta (client-side) zapisuje dane bezpośrednio do bazy danych, gracz może zmodyfikować adresy pamięci, aby zapisać dowolne wartości do bazy.

Aby temu zapobiec, musisz zaimplementować walidację autorytatywną dla serwera:

  1. Tokeny interakcji (Interaction Tokens): Kiedy gracz zaczyna badać przedmiot, serwer generuje tymczasowy token interakcji (ważny przez 60 sekund) i rejestruje go na instancji aktywnego actora w świecie gry.
  2. Ochrona przed podwójnym użyciem (Double-Spend Protection): Kiedy gracz podnosi przedmiot, serwer zużywa token. Jeśli zostanie odebrany zduplikowany pakiet podniesienia, serwer go odrzuca.
  3. Synchronous Replication: Upewnij się, że zmiany w ekwipunku są natychmiast wypychane na ekrany klientów.

Co więcej, aby przesyłać wizualne aktualizacje UI ekwipunku do połączonych klientów gry w czasie rzeczywistym, poleganie na ciężkich mechanizmach pollingu HTTP obciąży tick rate Twojego serwera. Zamiast tego powinieneś porzucić HTTP polling i wdrożyć dedykowane połączenie WebSockets do synchronizacji ekwipunku w czasie rzeczywistym.

Implementacja kodu weryfikacji na backendzie

Jeśli zdecydujesz się napisać własny, niestandardowy serwer, będziesz potrzebować endpointu API, który bezpiecznie obsługuje aktualizacje bazy danych. Poniżej znajduje się kod backendu Node.js Express korzystający z PostgreSQL. Ten skrypt zarządza pulą połączeń (connection pooling), izolacją transakcji oraz zużyciem tokenów do zapisu podniesienia przedmiotu:

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

Samodzielna budowa takiej infrastruktury wiąże się z dużym nakładem pracy deweloperskiej. Musisz skonfigurować load balancery, uruchomić klastry baz danych, napisać niestandardowe mechanizmy ponawiania prób po stronie klienta (retry managers) i zbudować bezpieczne protokoły uwierzytelniania. To z łatwością 4-6 tygodni pracy nad infrastrukturą, zanim napiszesz choćby jedną linijkę kodu pętli gry (game loop).

Uproszczenie zapisu stanu z horizOn

Zamiast pisać niestandardowe middleware w Express, zarządzać pulami połączeń PostgreSQL i walczyć z rozłączeniami sieciowymi, możesz oddelegować tę złożoność do horizOn. Z horizOn otrzymujesz w pełni zarządzane rozwiązanie bazodanowe zaprojektowane specjalnie z myślą o tworzeniu gier.

Korzystając z funkcji chmurowej bazy danych, nie musisz pisać żadnych skryptów backendowych do obsługi walidacji stanu. Możesz wyzwalać trwałe zapisy do bazy danych bezpośrednio z autorytatywnej instancji serwera Unreal Engine za pomocą biblioteki klienckiej:

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

Aktualizuje to bazę danych w sposób transakcyjny, daje pewność, że gracze nie sfałszują rekordów na swoich klientach, oraz automatycznie buforuje aktualizacje lokalnie w przypadku tymczasowych problemów z połączeniem internetowym u gracza.

Najlepsze praktyki dla trwałych ekwipunków w grach

Aby zbudować wysoce wydajny system ekwipunku, wdrożyj następujące najlepsze praktyki:

  1. Wymuś walidację odległości na serwerze: Sprawdź FVector::Dist między pawnem a badanym actorem przed dodaniem przedmiotów. Jeśli odległość jest fizycznie niemożliwa, zaloguj to jako podejrzaną aktywność klienta.
  2. Korzystaj z idempotentnych tokenów transakcji: Generuj unikalne identyfikatory UUID transakcji na serwerze w momencie rozpoczęcia badania przedmiotu. Zapobiega to błędom podwójnego zwiększenia liczby przedmiotów (double-increment), gdy pakiety sieciowe są ponawiane z powodu skoków opóźnień (latency spikes).
  3. Stosuj optymistyczne aktualizacje lokalne z korektą serwera: Zadbaj o responsywność UI, aktualizując ekran ekwipunku gracza natychmicznie po stronie klienta, ale zachowaj go w stanie „Oczekuje na potwierdzenie” (Pending Confirmation), dopóki serwer nie zwróci potwierdzenia pomyślnego zapisu.
  4. Loguj anomalie stanów: Monitoruj, jak często klienci próbują podnosić przedmioty, które nie zostały oznaczone jako zbadane w ich aktywnej sesji, ponieważ jest to główny wskaźnik używania narzędzi typu cheat injection.

Gotowy na skalowanie swojego multiplayer backendu? Wypróbuj horizOn za darmo lub zapoznaj się z naszą dokumentacją dla deweloperów, aby zacząć już dziś.


Źródło: How do you put an item into the inventory after inspecting it?