Skip to main content

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:

ModulePathPurpose
inventorysrc/modules/core/inventory/Core logic, repositories, item definitions
inventory-uisrc/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 tests

Item 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:

TagSchemaPurpose
inventory/storageStorageTagSchemaMakes item a container with grid storage
equipment/slotEquipmentSlotTagSchemaDefines which equipment slots item fits
inventory/stackableStackableTagSchemaAllows stacking with maxStack limit
item/foldableFoldableTagSchemaItem can fold to foldedSize
item/consumableConsumableTagSchemaItem can be used, triggers effect handler
item/examinableExaminableTagSchemaRequires 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 cells
  • texture: Optional background texture for UI
  • accepts: 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 IDPurpose
discardDestroy item permanently
splitSplit stackable item
examineReveal item information
fold / unfoldToggle folded state
useConsume 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:unit

Best Practices

  1. Always validate on server: Never trust client input
  2. Use transactions: For any multi-document operation
  3. Check weight limits: Before accepting items into containers
  4. Check container accepts: Verify item kind is allowed
  5. Handle version conflicts: Retry on OCC failures
  6. Clean up on disconnect: Remove player observables