Inventory System Guide
The True Life inventory system provides a Tarkov-like grid-based inventory with equipment slots, nested containers, weight management, and a fully server-authoritative design. This guide covers the architecture, data models, and how to extend the system.
Overview
Key Features
- Grid-based storage: Items occupy cells with width/height, supporting 0°, 90°, 180°, and 270° rotations
- Equipment slots: Eight fixed slots (Head, Body, UpperBody, Backpack, Frontpack, Arms, Legs, Shoes)
- Nested containers: Backpacks, vests, and cases can contain other items
- Weight system: Per-container and total player weight limits
- Quick slots: Hotbar slots (1-9) for fast item access
- Item stacking: Ammo, consumables can stack up to a max quantity
- Foldable items: Weapons and items can fold to smaller sizes
- Examinable items: Items can require examination before use
- Server-authoritative: All mutations validated and executed on server
- Atomic operations: MongoDB transactions ensure data consistency
Module Structure
The inventory is split into two modules:
| Module | Path | Purpose |
|---|---|---|
inventory | src/modules/core/inventory/ | Core logic, repositories, item definitions |
inventory-ui | src/modules/features/inventory-ui/ | React UI, drag-and-drop, keybinds |
src/modules/core/inventory/
├── module.ts # Module registration
├── inventory.service.ts # Main service with @Server RPC methods
├── equipment.repository.ts # MongoDB ops for equipment containers
├── item.repository.ts # MongoDB ops for item instances
├── types.ts # Type definitions
├── config.ts # Weight limits configuration
├── definitions/
│ ├── schema.ts # Zod schema for ItemDefinition
│ ├── schema.json # JSON Schema for IDE autocomplete
│ ├── registry.ts # Runtime item definition cache
│ ├── loader.ts # Loads JSON definitions at startup
│ └── items/ # Item definition JSON files
│ ├── gear/
│ │ ├── backpacks.json
│ │ ├── armor.json
│ │ └── clothing.json
│ └── consumables.json
├── utils/
│ ├── grid.ts # Grid collision detection
│ └── weight.ts # Weight calculations
├── actions/
│ ├── registry.ts # Item action registry
│ └── builtin.ts # Built-in actions (discard, split, etc.)
└── __tests__/
├── grid.shared.test.ts # Grid utility tests
└── weight.shared.test.ts # Weight utility testsItem Definitions
Item definitions are stored as JSON files and loaded at server startup. They define the static properties of items.
JSON Structure
{
"$schema": "../schema.json",
"items": [
{
"kind": "gear/backpack/alpha",
"version": 1,
"name": "Alpha Backpack",
"description": "A sturdy tactical backpack with ample storage",
"icon": "items/backpacks/alpha.png",
"weight": 1500,
"size": { "w": 3, "h": 4 },
"tags": {
"equipment/slot": {
"slots": ["Backpack"]
},
"inventory/storage": {
"grid": { "w": 6, "h": 8 },
"maxWeight": 15000,
"accepts": ["item/"]
}
},
"sounds": {
"pickup": "backpack_pickup",
"equip": "backpack_equip"
}
}
]
}Item Tags
Tags define special behaviors for items:
| Tag | Schema | Purpose |
|---|---|---|
inventory/storage | StorageTagSchema | Makes item a container with grid storage |
equipment/slot | EquipmentSlotTagSchema | Defines which equipment slots item fits |
inventory/stackable | StackableTagSchema | Allows stacking with maxStack limit |
item/foldable | FoldableTagSchema | Item can fold to foldedSize |
item/consumable | ConsumableTagSchema | Item can be used, triggers effect handler |
item/examinable | ExaminableTagSchema | Requires examination before use |
Storage Tag
{
"inventory/storage": {
"grid": { "w": 6, "h": 8 },
"texture": "containers/backpack_grid.png",
"accepts": ["ammo/", "item/magazine"],
"maxWeight": 15000
}
}grid: Container dimensions in cellstexture: Optional background texture for UIaccepts: Item kind prefixes this container accepts (empty = all)maxWeight: Maximum weight in grams (null = unlimited)
Equipment Slot Tag
{
"equipment/slot": {
"slots": ["Backpack", "Frontpack"]
}
}Items can fit multiple slots. Valid slots: Head, Body, UpperBody, Backpack, Frontpack, Arms, Legs, Shoes.
Stackable Tag
{
"inventory/stackable": {
"maxStack": 60
}
}Foldable Tag
{
"item/foldable": {
"foldedSize": { "w": 2, "h": 1 }
}
}Consumable Tag
{
"item/consumable": {
"uses": 1,
"effect": "healing:bandage",
"effectParams": { "healAmount": 25 },
"useDuration": 3000
}
}Helper Functions
Use these functions to check item capabilities:
import {
isContainer,
isStackable,
isFoldable,
isConsumable,
isExaminable,
canEquipToSlot,
containerAcceptsItem,
getEffectiveSize,
} from "@modules/core/inventory/definitions/schema";
// Check item capabilities
if (isContainer(definition)) {
const storage = definition.tags["inventory/storage"];
console.log(`Container has ${storage.grid.w}x${storage.grid.h} grid`);
}
// Check if item can equip to slot
if (canEquipToSlot(definition, "Backpack")) {
// Can equip
}
// Check container accepts item kind
if (containerAcceptsItem(containerDef, "ammo/9mm")) {
// Can place in container
}
// Get size (considers folded state)
const size = getEffectiveSize(definition, item.state.folded ?? false);Data Model
ItemInstance
Items in the database:
type ItemInstance = {
id: string;
characterId: string;
kind: string; // References ItemDefinition.kind
definitionVersion: number; // For future migrations
containerVersion: number | null; // OCC version if this is a container
location: ItemLocation;
quantity: number | null; // For stackable items
state: ItemState;
createdAt: Date;
updatedAt: Date;
};
type ItemLocation = {
containerId: string; // Parent container or equipment ID
slotKey: EquipmentSlotValue | null; // If in equipment slot
pos: GridPosition | null; // Grid position {x, y}
rot: ItemRotation; // 0, 90, 180, or 270 degrees
};
type ItemState = {
durability?: number; // 0-100
folded?: boolean;
usesRemaining?: number;
[key: string]: unknown; // Extensible by modules
};EquipmentContainer
Per-character equipment container:
type EquipmentContainer = {
id: string;
characterId: string;
type: "equipment";
version: number; // OCC version for concurrency
slots: Record<EquipmentSlotValue, string | null>;
quickSlots: Record<QuickSlotNumber, string | null>;
examinedItems: string[]; // Kinds that have been examined
createdAt: Date;
updatedAt: Date;
};InventoryState
State synchronized to client:
type InventoryState = {
equipment: EquipmentContainer | null;
items: ItemInstance[];
openContainers: string[];
totalWeight: number; // In grams
maxWeight: number; // In grams
loading: boolean;
};Service Architecture
InventoryService
The main service handles all inventory operations with @Server RPC methods:
export class InventoryService {
// Per-player observable for UI sync
public readonly inventoryState = serverOwned<InventoryState>({
id: "inventory:state",
initialValue: DEFAULT_INVENTORY_STATE,
perPlayer: true,
});
@Server
async loadInventory(ctx: EventContext<[characterId: string]>): Promise<void>;
@Server
async clearInventory(ctx: EventContext<[]>): Promise<void>;
@Server
async moveItem(ctx: EventContext<[request: MoveItemRequest]>): Promise<{ success: boolean; error?: string }>;
@Server
async splitStack(ctx: EventContext<[request: SplitStackRequest]>): Promise<{ success: boolean; error?: string }>;
@Server
async executeAction(ctx: EventContext<[request: ExecuteActionRequest]>): Promise<{ success: boolean; error?: string }>;
@Server
async assignQuickSlot(ctx: EventContext<[request: AssignQuickSlotRequest]>): Promise<{ success: boolean; error?: string }>;
}Per-Player Observable Pattern
Each player has their own observable instance on the server:
// Server-side map of player observables
private playerObservables = new Map<number, ServerOwnedObservable<InventoryState>>();
@Server
async loadInventory(ctx: EventContext<[characterId: string]>): Promise<void> {
const [characterId] = ctx.args;
const playerId = ctx.source;
// Load from database
const equipment = await this.equipmentRepository.findByCharacterId(characterId);
const items = await this.itemRepository.findByCharacterId(characterId);
// Create player-specific observable
const observable = serverOwned<InventoryState>({
id: "inventory:state",
initialValue: { equipment, items, ... },
perPlayer: true,
});
// Subscribe and store
this.disposables.add(observable.subscribePlayer(playerId));
this.playerObservables.set(playerId, observable);
}Grid System
Collision Detection
import {
getOccupiedCells,
checkCollision,
fitsInBounds,
findFreePosition,
getItemEffectiveSize,
} from "@modules/core/inventory/utils/grid";
// Get all occupied cells in a container
const occupied = getOccupiedCells(items, containerDef, (kind) => registry.get(kind));
// Check if new item would collide
const size = getItemEffectiveSize(definition, item.state.folded ?? false);
const hasCollision = checkCollision(
{ x: 2, y: 3 }, // Position
size, // {w, h}
0, // Rotation (0, 90, 180, or 270)
occupied, // Set<"x,y">
);
// Find first available position
const storage = containerDef.tags["inventory/storage"]!;
const freePos = findFreePosition(storage.grid, size, occupied);
if (freePos) {
// Place at freePos.x, freePos.y
}Grid Coordinate System
- Origin
(0, 0)is top-left - X increases to the right
- Y increases downward
- Cells are stored as
"x,y"strings in a Set
Weight System
Configuration
// config.ts
export const INVENTORY_CONFIG = {
weight: {
DEFAULT_MAX_CARRY_WEIGHT: 20000, // 20kg in grams
MIN_CARRY_WEIGHT: 20000,
MAX_CARRY_WEIGHT: 50000, // Future: fitness scaling
},
};Weight Utilities
import {
calculateItemWeight,
calculateContainerWeight,
calculateTotalCarryWeight,
checkContainerWeightLimit,
getWeightPercentage,
} from "@modules/core/inventory/utils/weight";
// Calculate single item weight (includes contents if container)
const itemWeight = calculateItemWeight(item, allItems, (kind) => registry.get(kind));
// Calculate total weight in a container
const containerWeight = calculateContainerWeight(containerId, items, (kind) => registry.get(kind));
// Calculate player's total carry weight
const totalWeight = calculateTotalCarryWeight(items, equipmentId, (kind) => registry.get(kind));
// Check if move would exceed container weight limit
const check = checkContainerWeightLimit(targetContainer, item, items, (kind) => registry.get(kind));
if (!check.allowed) {
console.log(check.reason); // "Would exceed weight limit"
}Item Actions
Built-in Actions
| Action ID | Purpose |
|---|---|
discard | Destroy item permanently |
split | Split stackable item |
examine | Reveal item information |
fold / unfold | Toggle folded state |
use | Consume consumable item |
Registering Custom Actions
import { InventoryService } from "@modules/core/inventory/inventory.service";
import { inject } from "@core/di";
export class WeaponService {
private inventoryService = inject(InventoryService);
@ServerOnly
initServerSide(): void {
this.inventoryService.actionRegistry.register({
id: "weapon:reload",
label: "Reload",
icon: "reload",
predicate: (item, definition) => {
// Only show for firearms
return definition.tags["weapon/firearm"] !== undefined;
},
execute: async (item, ctx) => {
// Reload logic here
return { success: true };
},
});
}
}Effect Handlers
For consumable items, register effect handlers:
inventoryService.actionRegistry.registerEffectHandler("healing:bandage", async (item, params, ctx) => {
const healAmount = params.healAmount as number;
// Apply healing to player
return { success: true };
});UI Integration
Using InventoryState in React
"use web";
import { useService, useObservable } from "@core/web/react";
import { InventoryService } from "@modules/core/inventory/inventory.service";
export default function InventoryPage() {
const inventoryService = useService(InventoryService);
const state = useObservable(inventoryService.inventoryState);
if (state.loading) {
return <div>Loading...</div>;
}
return (
<div className="inventory-container">
<EquipmentPanel equipment={state.equipment} />
<ItemGrid items={state.items} />
<WeightIndicator current={state.totalWeight} max={state.maxWeight} />
</div>
);
}Calling RPC Methods
const handleMoveItem = async (itemId: string, targetPos: GridPosition) => {
const result = await inventoryService.moveItem({
source: 0, // Filled by framework
args: [
{
itemId,
targetContainerId: openContainerId,
pos: targetPos,
slotKey: null,
rot: 0,
},
],
});
if (!result.success) {
showError(result.error);
}
};Concurrency Control
Optimistic Locking
Both EquipmentContainer and container ItemInstances use version fields for optimistic concurrency control:
// equipment.repository.ts
async updateSlot(
characterId: string,
slotKey: EquipmentSlotValue,
itemId: string | null,
expectedVersion: number,
session?: ClientSession,
): Promise<EquipmentContainer | null> {
const result = await collection.findOneAndUpdate(
{ characterId, version: expectedVersion }, // Check version
{
$set: { [`slots.${slotKey}`]: itemId },
$inc: { version: 1 }, // Increment version
},
{ returnDocument: "after", session },
);
return result; // null if version mismatch
}Transaction Usage
All mutations that affect multiple documents use transactions:
@Server
async moveItem(ctx: EventContext<[request: MoveItemRequest]>): Promise<{ success: boolean; error?: string }> {
return await withMongoTransaction(async (_db, session) => {
// All operations use session
const item = await this.itemRepository.findById(itemId, session);
await this.itemRepository.updateLocation(itemId, newLocation, session);
await this.equipmentRepository.updateSlot(..., session);
// ...
return { success: true };
});
}Testing
Unit Tests
Grid and weight utilities have pure unit tests:
// __tests__/grid.shared.test.ts
describe("checkCollision", () => {
it("should detect overlap", () => {
const occupied = new Set(["1,1", "1,2", "2,1", "2,2"]);
expect(checkCollision({ x: 1, y: 1 }, { w: 2, h: 2 }, 0, occupied)).toBe(true);
});
});Run with:
pnpm test:unitBest Practices
- Always validate on server: Never trust client input
- Use transactions: For any multi-document operation
- Check weight limits: Before accepting items into containers
- Check container accepts: Verify item kind is allowed
- Handle version conflicts: Retry on OCC failures
- Clean up on disconnect: Remove player observables