Cómo crear una integración de base de datos segura para un sistema de inventario de videojuegos con objetos inspeccionados
En resumen
Esta guía técnica detalla cómo implementar un sistema de inventario seguro en Unreal Engine C++ utilizando lógica server-authoritative para evitar trampas en las transiciones de estado de inspección de objetos. Se contrastan los esquemas relacionales (PostgreSQL) y NoSQL para el diseño de la base de datos de inventario, incluyendo un ejemplo de validación en Node.js mediante tokens. Por último, se demuestra cómo horizOn simplifica la persistencia segura y la replicación del estado del juego en tiempo real.
Tus jugadores explotarán cualquier vulnerabilidad para duplicar objetos raros, y las transiciones de estado client-authoritative son su objetivo favorito. En juegos de terror, aventuras y RPGs, la acción de inspeccionar un objeto —como recoger una llave, rotarla en 3D para encontrar una pista y añadirla al inventario del jugador— es una mecánica clásica. Si el cliente de tu juego dicta directamente cuándo se añade un objeto al inventario sin verificación del servidor, una simple herramienta de inyección de paquetes como Cheat Engine o Fiddler puede engañar al cliente para que envíe señales de "add item" (añadir objeto) para ítems que el jugador ni siquiera ha visto. Para evitar esto, los desarrolladores deben implementar una integración de base de datos robusta para el sistema de inventario del juego que conecte las interacciones del lado del cliente con la lógica server-authoritative y una persistencia segura en la nube.
El ciclo de vida de inspección a inventario
Para construir una sincronización de inventario segura, primero debemos desglosar el ciclo de vida de la inspección de objetos. Esta secuencia coordina los actores del mundo físico, los estados de inspección locales del lado del cliente, los componentes de inventario server-authoritative y el almacenamiento en la base de datos.
- Detección de interacción: El jugador se acerca a un actor físico (
AInspectableActor) en el mundo del juego. Un line trace o un volumen de colisión marca el objeto como interactivo. - Transición al modo de inspección: El jugador presiona la tecla de interacción. El cliente entra en un estado de inspección local, bloqueando el movimiento del personaje, rotando el objeto dentro de un contenedor de coordenadas de screen-space dedicado y renderizando una superposición de descripción en 2D.
- Etapa de verificación: El jugador hace clic en "Tomar". En lugar de que el cliente añada el objeto al inventario, envía una solicitud de token de interacción al servidor.
- Validación del servidor: El servidor confirma que el jugador está dentro del radio de interacción física (~250 Unreal units) del actor y que este se encuentra activo.
- Integración de base de datos: El servidor añade el objeto al array de inventario, escribe la actualización en la base de datos persistente y transmite un evento de destrucción para el actor del mundo.
Si estás desarrollando un título multiplayer, gestionar este estado en el servidor es crítico. Muchos equipos se topan con pesadillas de inventario multiplayer con propietarios de componentes de actor intercambiados en Unreal Engine cuando no configuran correctamente la replicación y la autoridad de red en componentes de inventario personalizados.
Escribiendo la lógica de inspección en C++ para Unreal Engine
Para implementar este flujo, crearemos tres componentes: una interfaz (IInspectableInterface), un actor inspeccionable (AInspectableActor) y un componente de inventario de jugador replicado (UInventoryComponent).
Aquí está el archivo de cabecera (header file) de la interfaz que describe cómo los actores reciben los comandos de inspección:
// 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;
};
A continuación, implementemos la clase AInspectableActor. Esta clase maneja el objeto físico en el mundo, almacenando su identificador único, su rango máximo de interacción y su estado.
// 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;
};
Aquí está el archivo de implementación para el actor inspeccionable:
// 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();
}
Ahora, crearemos el UInventoryComponent que gestiona la lista de inventario del jugador y replica estos datos a través de la red a los clientes.
// 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;
};
Y el archivo de implementación donde definimos la lógica de replicación RPC y la validación de escritura:
// 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
}
Diseñando el esquema de base de datos del inventario
Una vez que el servidor valida que el jugador efectivamente ha inspeccionado el objeto, este cambio de estado debe persistirse. Elegir entre un almacenamiento relacional (SQL) o de documentos (NoSQL) cambia la forma de estructurar los esquemas de la base de datos.
| Métrica de evaluación | Relacional (PostgreSQL) | Almacenamiento de documentos (NoSQL / MongoDB) |
|---|---|---|
| Estructura de datos | Tablas normalizadas, claves foráneas estrictas | Documentos y arrays de clave-valor anidados |
| Seguridad de transacciones | Cumplimiento total de ACID de forma nativa (out of the box) | Operaciones atómicas limitadas a un solo documento |
| Complejidad de consultas | Alta (Requiere consultas SQL JOIN para la búsqueda de ítems) | Baja (Búsqueda directa del perfil del jugador) |
| Escalabilidad | Vertical (Requiere sharding manual para escalar) | Horizontal (Capacidades de sharding integradas) |
Esquema relacional (PostgreSQL)
En una base de datos relacional, conviene desacoplar los jugadores, los metadatos globales de los objetos y las listas de inventario activas para evitar la redundancia de datos. Esto requiere tres tablas:
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)
);
Esquema de documentos (NoSQL)
En una base de datos de documentos, almacenas el inventario del jugador como un array anidado dentro del documento principal del jugador. Esto permite que tu backend recupere el estado completo del jugador en una sola consulta:
{
"_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"
}
El dilema de la sincronización: Guardados en el lado del cliente vs Server-Authoritative
La sincronización de este estado cambia la forma en que los jugadores experimentan el juego y su robustez ante intentos de trampa (cheats). Si el código del lado del cliente guarda directamente en la base de datos, un jugador puede modificar direcciones de memoria para escribir valores arbitrarios en la base de datos.
Para evitar esto, debes implementar validaciones server-authoritative:
- Tokens de interacción: Cuando un jugador comienza a inspeccionar un objeto, el servidor genera un token de interacción efímero (válido por 60 segundos) y lo registra en la instancia activa del actor del mundo.
- Protección contra doble gasto (Double-Spend): Cuando el jugador recoge el objeto, el servidor consume el token. Si se recibe un paquete de recogida duplicado, el servidor lo rechaza.
- Replicación síncrona: Asegúrate de que los cambios en el inventario se envíen a las pantallas de los clientes inmediatamente.
Además, para enviar actualizaciones visuales a las interfaces de usuario (UI) de inventario de los clientes conectados en tiempo real, depender de mecanismos pesados de HTTP polling ahogará el tick rate de tu servidor. En su lugar, deberías dejar de usar HTTP polling e implementar una conexión Websockets dedicada para sincronizaciones de inventario en tiempo real.
Implementando el código de verificación del backend
Si decides escribir tu propio servidor personalizado, necesitarás un endpoint de API que gestione las actualizaciones de la base de datos de forma segura. A continuación, se muestra un fragmento de backend en Node.js con Express usando PostgreSQL. Este script maneja el connection pooling, el aislamiento de transacciones y el consumo de tokens para registrar la recogida de objetos:
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' });
}
});
Construir esta infraestructura por tu cuenta conlleva una alta carga de trabajo de desarrollo. Debes configurar load balancers, aprovisionar clústeres de bases de datos, escribir gestores de reintentos personalizados en el cliente y desarrollar protocolos de autenticación seguros. Eso representa fácilmente entre 4 y 6 semanas de trabajo de infraestructura antes de escribir una sola línea de código para el bucle de juego (game loop).
Simplificando la persistencia del estado con horizOn
En lugar de escribir middleware personalizado para Express, gestionar pools de conexión de PostgreSQL y luchar contra caídas de red, puedes delegar esta complejidad a horizOn. Con horizOn, obtienes una solución de base de datos completamente administrada y diseñada específicamente para el desarrollo de videojuegos.
Al usar las funciones de bases de datos en la nube, no necesitas escribir scripts de backend para manejar la validación de estado. Puedes activar escrituras de base de datos persistentes directamente desde tu instancia de servidor de Unreal Engine con autoridad de servidor (server-authoritative) usando la biblioteca de 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();
}
Esto updates the database de manera transaccional, garantiza que los jugadores no puedan falsificar los registros en sus clientes y almacena automáticamente en caché las actualizaciones de forma local en caso de que el jugador sufra caídas temporales de internet.
Mejores prácticas para inventarios de juego persistentes
Para construir un sistema de inventario de alto rendimiento, implementa estas mejores prácticas:
- Aplica validación basada en distancia en el servidor: Comprueba
FVector::Distentre el pawn y el actor inspeccionable antes de añadir objetos. Si la distancia es físicamente imposible, regístralo como actividad sospechosa del cliente. - Utiliza tokens de transacción idempotentes: Genera UUIDs de transacción únicos en el servidor cuando comience la inspección. Esto evita fallos (bugs) de doble incremento cuando los paquetes de red se reintentan debido a picos de latencia.
- Usa actualizaciones locales optimistas con corrección del servidor: Haz que la UI se sienta responsiva actualizando la pantalla de inventario del jugador de inmediato en el cliente, pero mantenla en un estado "Pending Confirmation" (Pendiente de confirmación) hasta que el servidor devuelva una confirmación de escritura exitosa.
- Registra anomalías de estado: Monitorea con qué frecuencia los clientes intentan recoger objetos que no están marcados como inspeccionados en su sesión activa, ya que este es un indicador clave del uso de herramientas de inyección de trampas (cheats).
¿Listo para escalar tu backend multiplayer? Prueba horizOn gratis o echa un vistazo a nuestra documentación para desarrolladores para comenzar hoy mismo.
Fuente: How do you put an item into the inventory after inspecting it?