Implementation guide · TypeScript item state

A headless TypeScript inventory system with explicit stacking and capacity.

Separate immutable item definitions from mutable slot state. Then inventory rules can run in React, a Three.js client, tests, save migrations, or an authoritative server without depending on a grid component.

Definitions describe items; slots describe ownership.

Item definitions belong to content and ship with the current build. Inventory slots are player state and belong in saves or server snapshots. Mixing them duplicates item metadata in every save and makes balance changes difficult to migrate.

Item definition

ID, localization keys, category, icon hint, stack rules, use effects, and metadata.

Inventory slot

An item ID and mutable quantity, constrained by max stack and container capacity.

Aggregated entry

A query view that combines quantities across slots for counts, filters, and UI.

Effect reference

A serializable instruction resolved by game-owned behavior when an item is used.

Keep item behavior declarative and game-specific.

const items = [
  {
    id: 'potion',
    name: 'item.potion.name',
    description: 'item.potion.description',
    category: 'consumable',
    icon: 'potion-red',
    stackable: true,
    maxStack: 99,
    consumable: true,
    useEffects: [
      { type: 'player.heal', params: { amount: 25 } },
    ],
  },
  {
    id: 'bronze-sword',
    name: 'item.bronzeSword.name',
    category: 'weapon',
    stackable: false,
    metadata: { equipmentSlot: 'main-hand' },
  },
]

The inventory engine does not know what healing or equipment means. It enforces item and container mechanics, then asks the game's effect registry to perform a use effect. Equipment, crafting, durability, and trading can remain separate systems that consume inventory facts.

Make overflow and failure visible to callers.

Adding an item should fill compatible stacks before opening new slots. If capacity is exhausted, callers need the exact amount added and the overflow so a world pickup, mailbox, trade, or reward system can decide what happens next.

import { createEffectRegistry } from '@overworld-engine/core'
import { createInventory } from '@overworld-engine/inventory'

const effects = createEffectRegistry<GameContext>()
effects.register('player.heal', ({ amount }, ctx) =>
  ctx.player.heal(Number(amount))
)

const inventory = createInventory({
  items,
  capacity: 20,
  effects,
  context: () => gameContext,
  persist: { name: 'inventory' },
})

const result = inventory.add('potion', 120)
// { success: true, added: 120, overflow: 0 } when slots permit

inventory.remove('potion', 5) // all-or-nothing boolean
inventory.use('potion')       // run heal effect, then consume one
inventory.count('potion')     // aggregate quantity across slots

Overworld removal is atomic: requesting more than the owned quantity fails instead of partially removing items. Use explicit return values rather than inferring success from a UI animation or a later store read.

Broadcast inventory facts; render state through selectors.

Inventory changes emit item:added, item:removed, and item:used. A collection quest or analytics pipeline can observe those events without the inventory package importing either system.

gameEvents.on('item:added', ({ itemId, total }) => {
  // quest, achievement, analytics, audio, or replication adapter
})

function InventoryGrid() {
  const slots = useStore(inventory.store, (state) => state.slots)

  return (
    <ul>
      {slots.map((slot, index) => (
        <li key={index}>
          {translate(inventory.getDefinition(slot.itemId)?.name)}
          <span>{slot.quantity}</span>
        </li>
      ))}
    </ul>
  )
}

Drag-and-drop, focus management, tooltips, gamepad navigation, and responsive layout belong in the UI. The store remains usable from React, another renderer, or no renderer.

Persist slots locally—or validate mutations on the server.

In a single-player game, persist only slot state and reload definitions from the current content version. Add migrations for renamed item IDs and changed capacity. Never rely on a saved display name or effect function.

In a competitive multiplayer game, clients should request inventory operations rather than writing authoritative slots directly. The server validates ownership, capacity, trade rules, and reward provenance, applies the mutation, then sends a snapshot or accepted event back to the client.

Move shared inventory rules to the server

Use the same deterministic mutation code behind validated inputs and reconcile the client with authoritative state.

Read the authoritative multiplayer guide →