An inventory system sounds simple (“just a list of items”) but in practice it must answer:
✔ Can items stack? How many per stack?
✔ Does the player have a weight limit or slot limit?
✔ Can items be equipped, consumed, dropped, or traded?
✔ Do items have random attributes (RPG loot)?
✔ Can containers exist inside containers (bags in a bag)?
✔ How is it saved, versioned, and synchronized in multiplayer?
The Item Data Model
// Item Definition — the TEMPLATE (stored in your item database, not in savegames)struct ItemDefinition { std::string id; // "sword_iron" — unique key std::string displayName; // "Iron Sword" std::string description; // Shown in UI tooltip std::string iconPath; // "ui/icons/sword_iron.png" enum class Category { Weapon, Armor, Consumable, Material, Quest, Key } category; enum class Rarity { Common, Uncommon, Rare, Epic, Legendary } rarity; float weight; // kg — 0 for weightless items int maxStack; // 1 = no stacking, 99 = up to 99 per slot bool isDroppable; // Can it be dropped on the ground? bool isTradeable; // Can it be sold / traded? bool isQuestItem; // Quest items often can't be discarded // Stat modifiers for equipment std::unordered_map<std::string, float> statModifiers; // { "attack": 15.0, "durability": 100.0 }};// Item Instance — ONE specific item in a player's inventorystruct ItemInstance { std::string definitionId; // Points to the ItemDefinition uint64_t instanceId; // Unique ID for this specific item (for saves) int quantity; // How many in this stack // Optional: per-instance data (RPG random rolls, durability) std::unordered_map<std::string, float> attributes; // { "attack": 18.5, "durability": 85.0, "fire_damage": 4.0 } bool operator==(const ItemInstance& other) const { return instanceId == other.instanceId; }};
Inventory Container
class Inventory {public: int maxSlots; float maxWeight; std::vector<std::optional<ItemInstance>> slots; // Fixed-size grid with empty slots // Add an item — handles stacking automatically bool addItem(const ItemInstance& newItem) { const ItemDefinition& def = ItemDatabase::get(newItem.definitionId); // Try to stack onto existing items first if (def.maxStack > 1) { for (auto& slot : slots) { if (!slot.has_value()) continue; if (slot->definitionId == newItem.definitionId && slot->quantity < def.maxStack) { int canAdd = def.maxStack - slot->quantity; int adding = std::min(canAdd, newItem.quantity); slot->quantity += adding; if (adding == newItem.quantity) return true; // Fully stacked } } } // Find an empty slot for (auto& slot : slots) { if (!slot.has_value()) { slot = newItem; return true; } } return false; // Inventory full } bool removeItem(uint64_t instanceId, int quantity = 1) { for (auto& slot : slots) { if (slot.has_value() && slot->instanceId == instanceId) { slot->quantity -= quantity; if (slot->quantity <= 0) slot.reset(); // Clear slot return true; } } return false; } float getTotalWeight() const { float total = 0.0f; for (const auto& slot : slots) { if (!slot.has_value()) continue; const auto& def = ItemDatabase::get(slot->definitionId); total += def.weight * slot->quantity; } return total; } bool hasItem(const std::string& definitionId, int requiredQty = 1) const { int found = 0; for (const auto& slot : slots) { if (slot.has_value() && slot->definitionId == definitionId) found += slot->quantity; } return found >= requiredQty; }};
Equipment System
enum class EquipSlot { Head, Chest, Legs, Feet, Hands, MainHand, OffHand, Ring1, Ring2, Neck };class EquipmentSystem { std::unordered_map<EquipSlot, std::optional<ItemInstance>> equipped;public: bool equip(ItemInstance item, EquipSlot slot, Inventory& inventory) { // Unequip existing item first if (equipped[slot].has_value()) { inventory.addItem(equipped[slot].value()); // Return to bag } // Remove from inventory and equip inventory.removeItem(item.instanceId); equipped[slot] = item; applyStatChanges(item, true); return true; } // Compute total stat modifications from all equipped items float getStatTotal(const std::string& stat) const { float total = 0.0f; for (const auto& [slot, item] : equipped) { if (item.has_value() && item->attributes.count(stat)) { total += item->attributes.at(stat); } } return total; }};
Inventory in Godot (GDScript)
# inventory.gd — Autoload or Componentclass_name Inventory extends Resource@export var max_slots: int = 36@export var max_weight: float = 50.0var slots: Array[Dictionary] = [] # [{id, quantity, instance_id, attributes}]signal item_added(item_data: Dictionary)signal item_removed(instance_id: String)signal inventory_fullfunc add_item(item_id: String, quantity: int = 1) -> bool: var item_def = ItemDatabase.get_item(item_id) # Try stacking if item_def.max_stack > 1: for slot in slots: if slot.id == item_id and slot.quantity < item_def.max_stack: slot.quantity = min(slot.quantity + quantity, item_def.max_stack) item_added.emit(slot) return true # Find empty slot if slots.size() < max_slots: var new_slot = { "id": item_id, "quantity": quantity, "instance_id": str(randi()), # Generate unique ID "attributes": {} } slots.append(new_slot) item_added.emit(new_slot) return true inventory_full.emit() return falsefunc has_item(item_id: String, required_qty: int = 1) -> bool: var total: int = 0 for slot in slots: if slot.id == item_id: total += slot.quantity return total >= required_qty
2 — Quest System
Quest Architecture
stateDiagram-v2
[*] --> Available : Player discovers quest
Available --> Active : Player accepts
Active --> Active : Objective progress
Active --> Failed : Time limit / bad choice
Active --> Completed : All objectives done
Completed --> [*]
Failed --> [*]
Quest Data Model
// A single condition that must be true for an objective to completestruct QuestCondition { enum class Type { KillEnemy, // Kill N of enemy type X CollectItem, // Have N of item X in inventory ReachLocation, // Enter trigger zone X TalkToNPC, // Initiate dialogue with NPC X CompleteCraft, // Craft item X SurviveTime, // Survive for T seconds CustomEvent // Game fires a custom string event }; Type type; std::string targetId; // Enemy type, item id, location id, etc. int requiredCount; // How many int currentCount; // Runtime progress (not stored in definition) bool isComplete() const { return currentCount >= requiredCount; }};// One step/goal within a queststruct QuestObjective { std::string id; std::string description; // "Kill 5 Wolves" bool isOptional; // Optional objectives give bonus rewards bool isHidden; // Revealed only when triggered std::vector<QuestCondition> conditions; // ALL must be met for objective completion std::string nextObjectiveId; // Chain: completing this unlocks the next};// Quest Rewardstruct QuestReward { int goldAmount; int experiencePoints; std::vector<std::string> itemIds; // Items given on completion std::string followUpQuestId; // Quest unlocked after this one};// The Full Quest Definition (data asset — stored in JSON/ScriptableObject)struct QuestDefinition { std::string id; // "main_q_01_prologue" std::string title; // "A New Beginning" std::string description; // Story text shown in journal std::string giverNpcId; // Who gives this quest enum class Category { MainStory, SideQuest, Daily, Faction, Hidden } category; std::vector<std::string> prerequisiteQuestIds; // Must complete these first std::vector<QuestObjective> objectives; QuestReward reward; bool hasFail; // Does this quest have a fail state? float timeLimitSec; // 0 = no time limit};
Quest Manager (Runtime Engine)
class QuestManager { // All quest states for the current player std::unordered_map<std::string, QuestStatus> questStates; // questStates["main_q_01"] = QuestStatus::Active // Index of active quest objectives listening for events std::multimap<std::string, std::pair<std::string, int>> eventListeners; // eventListeners["kill:wolf"] = { "main_q_01", objectiveIndex }public: void acceptQuest(const std::string& questId) { if (!canAccept(questId)) return; questStates[questId] = QuestStatus::Active; registerObjectiveListeners(questId); onQuestStarted.broadcast(questId); } // Called by the game engine whenever something happens // e.g., onGameEvent("kill:wolf", 1) when player kills a wolf void onGameEvent(const std::string& eventKey, int amount = 1) { auto range = eventListeners.equal_range(eventKey); for (auto it = range.first; it != range.second; ++it) { auto& [questId, objIndex] = it->second; updateObjectiveProgress(questId, objIndex, amount); } } void updateObjectiveProgress(const std::string& questId, int objIndex, int amount) { auto& quest = getActiveQuest(questId); auto& cond = quest.objectives[objIndex].conditions[0]; cond.currentCount = std::min(cond.currentCount + amount, cond.requiredCount); // Check if objective is now complete if (cond.isComplete()) { checkQuestCompletion(questId); } onQuestUpdated.broadcast(questId); } bool canAccept(const std::string& questId) const { const auto& def = QuestDatabase::get(questId); for (const auto& prereq : def.prerequisiteQuestIds) { if (questStates.count(prereq) == 0 || questStates.at(prereq) != QuestStatus::Completed) return false; } return questStates.count(questId) == 0; // Not already started/done }};
Branching Quests
graph TD
Q1["Quest: The Heist"]
O1["Objective: Get the key"]
C1{{"Player choice"}}
B1["Branch A: Steal from the merchant\n→ Merchant becomes hostile\n→ Unlocks quest: On the Run"]
B2["Branch B: Buy from the merchant\n→ Better reward\n→ Unlocks quest: The Deal"]
Q1 --> O1 --> C1 --> B1
C1 --> B2
The key design principle: track choices as flags, not as conditional logic hardcoded in objectives.
// Player made a choice — store it as a world state variableworldState.set("heist_method", "stolen"); // or "purchased"// Quest system reads world state to decide what comes nextif (worldState.get("heist_method") == "stolen") { questManager.startQuest("on_the_run");} else { questManager.startQuest("the_deal");}
3 — Dialogue System
Dialogue Tree Concepts
A dialogue system is a directed graph of nodes, where edges can be conditional.
graph TD
Start["START\nNPC: 'Hello, traveler!'"]
C1{"Player Choices"}
A["'Tell me about this town.'\n→ NPC gives town history"]
B["'I need work.'\n→ NPC offers quest [if quest_available]"]
C["'Goodbye.'\n→ Conversation ends"]
B2["NPC: 'I have nothing for you.' [if quest_done]"]
Start --> C1
C1 --> A
C1 -->|"Condition: quest_available == true"| B
C1 -->|"Condition: quest_available == false"| B2
C1 --> C
Dialogue Node Data Model
struct DialogueNode { std::string id; std::string speakerId; // "blacksmith_kira" (maps to NPC name + portrait) std::string text; // "Strange weather we've been having..." // Audio + animation data std::string audioClipId; // Voice over audio key std::string speakerAnimation; // "surprised", "happy", "angry" // Side effects that happen when this node triggers struct DialogueAction { enum class Type { StartQuest, SetFlag, GiveItem, PlayAnimation, EndConversation }; Type type; std::string parameter; // questId, flagName, itemId, etc. }; std::vector<DialogueAction> actions; // Executed when node is shown};struct DialogueChoice { std::string text; // The button text the player sees std::string nextNodeId; // Where this choice leads std::string condition; // Optional: "quest_active:main_q_01" or "gold >= 50" bool isHidden; // Hidden if condition isn't met (vs. greyed out)};struct DialogueTree { std::string id; // "blacksmith_kira_main" std::string startNodeId; std::unordered_map<std::string, DialogueNode> nodes; std::unordered_map<std::string, std::vector<DialogueChoice>> choices;};
Dialogue Runner
class DialogueRunner { const DialogueTree* currentTree = nullptr; std::string currentNodeId;public: void startConversation(const std::string& treeId) { currentTree = DialogueDatabase::get(treeId); showNode(currentTree->startNodeId); } void showNode(const std::string& nodeId) { currentNodeId = nodeId; const auto& node = currentTree->nodes.at(nodeId); // 1. Fire all actions for this node for (const auto& action : node.actions) { executeAction(action); } // 2. Show dialogue text in UI UI::showDialogueText(node.speakerId, node.text); // 3. Build available choices std::vector<DialogueChoice> availableChoices; for (const auto& choice : currentTree->choices.at(nodeId)) { if (evaluateCondition(choice.condition)) { availableChoices.push_back(choice); } } if (availableChoices.empty()) { endConversation(); // No choices = auto-end } else { UI::showChoices(availableChoices); } } // Called when player clicks a choice void selectChoice(int choiceIndex) { const auto& choice = /* get the choice */; WorldState::setLastDialogueChoice(choice.text); // Save for quests showNode(choice.nextNodeId); } bool evaluateCondition(const std::string& condition) { if (condition.empty()) return true; // Parse "flag:quest_active" or "gold >= 50" ... return ConditionParser::evaluate(condition); }};