Skip to content
wannacode

Items, inventory, loot

In short: Items are a table in text read into objects; equipment adds modifiers to the stats, the inventory has slots and stacks potions, loot is rolled by weights, and the shop buys and sells.

Problem

A new sword, without touching the code

In the mine the hero finds a spear. The trader sells chain mail. The goblin drops a potion. If every item is a line of code, new Item("spear", "spear", ...), then every new sword is a new version of the program, and a game designer who wants to change the price of the chain mail from 90 to 80 has to wait for a compilation. The game has twenty-two items, and there will be more.

That is why data is separated from code. The items are a table in text: one row is one item, the cells are separated by |, and rows starting with # are comments.

# id|name|kind|price|modifiers
spear|spear|weapon|20|accuracy:5
war_axe|war axe|weapon|80|accuracy:-5
leather|leather armor|armor|30|defense:2
chain|chain mail|armor|90|defense:4,evasion:-5
ring_life|ring of life|ring|70|maxHp:10
ring_agility|ring of agility|ring|70|agility:2
potion_heal|healing potion|potion|15|-
potion_mana|mana potion|potion|15|-
antidote|antidote|potion|10|-
ore|silver ore|misc|8|-

The lesson is about five things around this table: how the text becomes objects; how equipment (weapon, armor, ring) adds up into the stats; how the inventory with limited slots stacks identical potions; how loot is rolled from a table with weights; and how the shop buys and sells for gold. The small table above has ten items; the canon has twenty-two, but the code is the same.

The idea

A table in text, read into objects

The reader is three small functions. parseRows splits the text into lines, skips empty ones and comments, and splits the cells by |; if the number of cells is not what is expected, it stops at once and says which row is broken. parseMods reads the modifiers (defense:4,evasion:-5, and - means "none"). loadItems turns every row into an item.

record Mod(string Stat, int Value);
class Item
{
public readonly string Id;
public readonly string Name;
public readonly string Kind;
public readonly int Price;
public readonly List<Mod> Mods;
public readonly bool Stackable;
public Item(string id, string name, string kind, int price, List<Mod> mods)
{
Id = id;
Name = name;
Kind = kind;
Price = price;
Mods = mods;
Stackable = kind == "potion" || kind == "misc";
}
}
// One row of the table is one item: id|name|kind|price|modifiers.
static List<string[]> ParseRows(string text, int columns)
{
var rows = new List<string[]>();
foreach (string raw in text.Split('\n'))
{
string line = raw.Trim();
if (line.Length == 0 || line[0] == '#') continue;
string[] cells = line.Split('|');
if (cells.Length != columns)
{
throw new Exception("expected " + columns + " cells in the row: " + line);
}
rows.Add(cells);
}
return rows;
}
// "-" means "no modifiers"; otherwise "stat:value" pairs separated by commas.
static List<Mod> ParseMods(string text)
{
var mods = new List<Mod>();
if (text == "-") return mods;
foreach (string part in text.Split(','))
{
string[] pieces = part.Split(':');
mods.Add(new Mod(pieces[0], int.Parse(pieces[1])));
}
return mods;
}
static OrderedDictionary<string, Item> LoadItems(string text)
{
var items = new OrderedDictionary<string, Item>();
foreach (string[] c in ParseRows(text, 5))
{
items[c[0]] = new Item(c[0], c[1], c[2], int.Parse(c[3]), ParseMods(c[4]));
}
return items;
}
var items = LoadItems(ITEMS_TEXT);
foreach (Item item in items.Values)
{
Console.WriteLine(item.Name + " (" + item.Kind + ") " + item.Price + " gold: " + ModsText(item.Mods));
}
Console.WriteLine("total: " + items.Count);
try
{
LoadItems(BAD_ROW);
}
catch (Exception e)
{
Console.WriteLine(e.Message);
}
Output
spear (weapon) 20 gold: accuracy +5
war axe (weapon) 80 gold: accuracy -5
leather armor (armor) 30 gold: defense +2
chain mail (armor) 90 gold: defense +4, evasion -5
ring of life (ring) 70 gold: maxHp +10
ring of agility (ring) 70 gold: agility +2
healing potion (potion) 15 gold: -
mana potion (potion) 15 gold: -
antidote (potion) 10 gold: -
silver ore (misc) 8 gold: -
total: 10
expected 5 cells in the row: ring_life|ring of life|ring|70

The program prints the items in the order of the table and at the end passes in a row with a missing cell (the price): the reader stops it with an exact message. A broken table must stop the game at start-up, not in the middle of a fight, when the player first steps on the item.

OrderedDictionary<string, Item> is a dictionary that keeps the insertion order (it exists since .NET 9). A plain Dictionary in practice keeps it too as long as nothing is deleted, but it does not promise it. The table is a raw string (""").

Here is the reader in the canon. There every table has its own columns (weapons are 10 cells, armor 7), so parseRows takes their number and the name of the table for the message:

public static List<string[]> ParseRows(string text, int columns, string tableName)
{
List<string[]> rows = new List<string[]>();
foreach (string rawLine in text.Split('\n'))
{
string line = TrimSpaces(rawLine);
if (line.Length == 0 || line[0] == '#') continue;
string[] cells = line.Split('|');
if (cells.Length != columns)
{
throw new Exception(tableName + ": expected " + columns + " cells in: " + line);
}
rows.Add(cells);
}
return rows;
}
public static List<Mod> ParseMods(string text)
{
List<Mod> mods = new List<Mod>();
foreach (string part in SplitList(text))
{
string[] pieces = part.Split(':');
mods.Add(new Mod { Stat = pieces[0], Value = ToInt(pieces[1]) });
}
return mods;
}
More precisely, if you are curious

Tables differ in their columns, so the canon has one short reader per kind (loadItems reads the five tables one after another), but one thing is shared: parseRows. A new kind of data (enemies, skills, dialogs) is a new table and a new short reader, not a new way of reading.

And here is the data itself from the game: the names in four languages, then the price and the modifiers.

// The data are tables in text: one line is one record, cells are separated by "|". Lines with "#"
// in front are notes. The code reads them into objects at start; a new sword needs a new row, not code.
// Modifiers are "stat:value", separated by commas; "-" means "nothing".
static partial class Arcanum
{
public static readonly string WEAPONS_DATA = """
# id|name|damage type|min|max|price|modifiers
fists|fists|blunt|1|2|0|-
hand_axe|hand axe|slash|4|7|20|-
spear|spear|pierce|3|6|20|accuracy:5
staff|staff|blunt|2|4|10|maxMana:4
war_axe|war axe|slash|6|10|80|accuracy:-5
longbow|longbow|pierce|5|8|80|accuracy:10
mace|mace|blunt|5|8|60|-
rune_staff|rune staff|blunt|3|6|75|maxMana:10
silver_sword|silver sword|slash|7|11|120|accuracy:5
""";
public static readonly string ARMORS_DATA = """
# id|name|price|modifiers
robe|robe|5|maxMana:2
leather|leather armor|30|defense:2
chain|chain mail|90|defense:4,evasion:-5
scale|scale armor|110|defense:5
""";
public static readonly string RINGS_DATA = """
# id|name|price|modifiers
ring_strength|ring of strength|70|strength:2
ring_agility|ring of agility|70|agility:2
ring_life|ring of life|70|maxHp:10
ring_mana|ring of mana|70|maxMana:8
""";
public static readonly string POTIONS_DATA = """
# id|name|price|action|power
potion_heal|healing potion|15|heal|20
potion_mana|mana potion|15|mana|15
antidote|antidote|10|cure|0
""";
public static readonly string MISC_DATA = """
# id|name|price
ore|silver ore|8
gold|gold|0
""";
// Loot: weighted rows for each table. "none" is "nothing"; the count is from min to max.
public static readonly string LOOT_DATA = """
# table|item|weight|min|max
vermin|none|60|0|0
vermin|potion_heal|25|1|1
vermin|antidote|15|1|1
goblin|none|40|0|0
goblin|potion_heal|30|1|1
goblin|potion_mana|10|1|1
goblin|spear|10|1|1
goblin|leather|10|1|1
beast|none|70|0|0
beast|potion_heal|30|1|1
leader|ring_agility|100|1|1
bones|none|50|0|0
bones|mace|20|1|1
bones|potion_heal|30|1|1
cult|potion_mana|40|1|1
cult|potion_heal|40|1|1
cult|rune_staff|20|1|1
golem|ore|100|1|2
warden|none|100|0|0
""";
public static readonly string SHOP_DATA = """
# what the shop sells, in this order
potion_heal
potion_mana
antidote
leather
chain
war_axe
longbow
mace
rune_staff
ring_life
ring_mana
""";
// The XP needed for each level: XP_TABLE[level] is the total XP that reaches that level.
public static readonly string XP_DATA = "0|0|20|55|105|170|250|345|455|580|720";
}

The idea

Equipment: the base plus the items

The hero has three slots: weapon, armor, ring. A slot holds the id of an item or an empty string. A stat is not stored ready-made: it is calculated every time as the base (the hero’s number) plus the sum of the modifiers of everything that is equipped. So taking a ring off does not need "restore the old number", it simply calculates again.

class Hero
{
public static readonly string[] Slots = ["weapon", "armor", "ring"];
public readonly string Name;
public readonly OrderedDictionary<string, Item> Items;
public readonly Dictionary<string, int> BaseStats = new();
public readonly Dictionary<string, string> Equipment = new();
public int Hp;
public Hero(string name, OrderedDictionary<string, Item> items)
{
Name = name;
Items = items;
foreach (string slot in Slots) Equipment[slot] = "";
}
// The base plus the bonus from everything that is equipped.
public int GearBonus(string stat)
{
int total = 0;
foreach (string slot in Slots)
{
string itemId = Equipment[slot];
if (itemId.Length == 0) continue;
foreach (Mod mod in Items[itemId].Mods)
{
if (mod.Stat == stat) total += mod.Value;
}
}
return total;
}
public int Stat(string stat)
{
return BaseStats.GetValueOrDefault(stat, 0) + GearBonus(stat);
}
// Life cannot stay above the new maximum.
public void ClampHp()
{
if (Hp > Stat("maxHp")) Hp = Stat("maxHp");
}
public string Equip(string itemId)
{
Item item = Items[itemId];
string old = Equipment[item.Kind];
Equipment[item.Kind] = itemId;
ClampHp();
return old;
}
public void Unequip(string slot)
{
Equipment[slot] = "";
ClampHp();
}
}

equip puts the item in the slot of its kind (item.kind) and returns the old one, so it can go back to the bag. There is one unexpected moment: if the ring gave +10 maximum life and you take it off, the current life can stay above the new maximum. clampHp brings it back down.

var items = LoadItems(ITEMS_TEXT);
var hero = new Hero("Iva", items);
hero.BaseStats["maxHp"] = 30;
hero.BaseStats["defense"] = 1;
hero.BaseStats["accuracy"] = 70;
hero.Hp = hero.Stat("maxHp");
Console.WriteLine(hero.Name + ": " + Stats(hero));
foreach (string itemId in new[] { "spear", "leather", "ring_life" })
{
hero.Equip(itemId);
Console.WriteLine("+ " + items[itemId].Name + ": " + Stats(hero));
}
hero.Hp = hero.Stat("maxHp");
Console.WriteLine("heal: " + Stats(hero));
hero.Equip("war_axe");
Console.WriteLine("+ " + items["war_axe"].Name + ": " + Stats(hero));
hero.Unequip("ring");
Console.WriteLine("- " + items["ring_life"].Name + ": " + Stats(hero));
Output
Iva: hp 30/30, defense 1, accuracy 70
+ spear: hp 30/30, defense 1, accuracy 75
+ leather armor: hp 30/30, defense 3, accuracy 75
+ ring of life: hp 30/40, defense 3, accuracy 75
heal: hp 40/40, defense 3, accuracy 75
+ war axe: hp 40/40, defense 3, accuracy 65
- ring of life: hp 30/30, defense 3, accuracy 65

The lines follow the hero: the spear adds 5 to accuracy, the leather armor 2 to defense, the ring 10 to maximum life (but the current life is still 30/40: the maximum grows, the life does not). After the healing the war axe takes the place of the spear and accuracy drops from 75 to 65 (−5 instead of +5). And taking the ring off brings the maximum life back to 30, and clampHp lowers the current life to 30 as well.

The idea

Inventory: slots and stacks

The bag has a limited number of slots (10 in the game). An item takes a slot. But ten healing potions are not ten slots: identical potions and ore stack in one. In the table these are the kinds potion and misc (stackable). Weapons and armor do not stack: two swords are two slots.

class Inventory
{
public readonly int Capacity;
public readonly OrderedDictionary<string, Item> Items;
public readonly List<Slot> Slots = new();
public Inventory(int capacity, OrderedDictionary<string, Item> items)
{
Capacity = capacity;
Items = items;
}
public int Count(string itemId)
{
int total = 0;
foreach (Slot slot in Slots)
{
if (slot.Id == itemId) total += slot.Count;
}
return total;
}
public bool CanAdd(string itemId, int count)
{
Item item = Items[itemId];
if (item.Stackable && Count(itemId) > 0) return true;
int needed = item.Stackable ? 1 : count;
return Slots.Count + needed <= Capacity;
}
public bool Add(string itemId, int count)
{
if (!CanAdd(itemId, count)) return false;
Item item = Items[itemId];
if (item.Stackable)
{
foreach (Slot slot in Slots)
{
if (slot.Id == itemId)
{
slot.Count += count;
return true;
}
}
Slots.Add(new Slot(itemId, count));
return true;
}
for (int i = 0; i < count; i++) Slots.Add(new Slot(itemId, 1));
return true;
}
public void Remove(string itemId, int count)
{
int left = count;
var kept = new List<Slot>();
foreach (Slot slot in Slots)
{
if (slot.Id == itemId && left > 0)
{
int taken = Math.Min(left, slot.Count);
slot.Count -= taken;
left -= taken;
}
if (slot.Count > 0) kept.Add(slot);
}
Slots.Clear();
Slots.AddRange(kept);
}
public void RemoveAt(int index)
{
Slot slot = Slots[index];
slot.Count -= 1;
if (slot.Count <= 0) Slots.RemoveAt(index);
}
}

It is all decided in canAdd: if the item stacks and there is already one, no slot is needed; otherwise as many new slots are needed as there are items. add asks canAdd first, and if it is not possible it does nothing (it does not put half a stack). The result is true or false, so the caller knows whether to say "no room".

static string Label(string name, int count)
{
return count > 1 ? name + " x" + count : name;
}
static string FillText(Inventory bag)
{
return "(" + bag.Slots.Count + "/" + bag.Capacity + ")";
}
static void PrintBag(Inventory bag)
{
Console.WriteLine("The bag:");
for (int i = 0; i < bag.Slots.Count; i++)
{
Slot slot = bag.Slots[i];
Console.WriteLine((i + 1) + ") " + Label(bag.Items[slot.Id].Name, slot.Count));
}
}
static void AddAndShow(Inventory bag, string itemId, int count)
{
bool added = bag.Add(itemId, count);
Console.WriteLine("+ " + Label(bag.Items[itemId].Name, count) + ": " + (added ? "done" : "no room") + " " + FillText(bag));
}
var items = LoadItems(ITEMS_TEXT);
var bag = new Inventory(4, items);
AddAndShow(bag, "potion_heal", 2);
AddAndShow(bag, "spear", 1);
AddAndShow(bag, "potion_heal", 1);
AddAndShow(bag, "potion_mana", 1);
AddAndShow(bag, "leather", 1);
AddAndShow(bag, "chain", 1);
AddAndShow(bag, "potion_heal", 1);
AddAndShow(bag, "ore", 1);
PrintBag(bag);
bag.Remove("potion_heal", 3);
Console.WriteLine("- " + Label(items["potion_heal"].Name, 3) + ": " + FillText(bag));
bag.Remove("spear", 1);
Console.WriteLine("- " + items["spear"].Name + ": " + FillText(bag));
AddAndShow(bag, "chain", 1);
PrintBag(bag);
Output
+ healing potion x2: done (1/4)
+ spear: done (2/4)
+ healing potion: done (2/4)
+ mana potion: done (3/4)
+ leather armor: done (4/4)
+ chain mail: no room (4/4)
+ healing potion: done (4/4)
+ silver ore: no room (4/4)
The bag:
1) healing potion x4
2) spear
3) mana potion
4) leather armor
- healing potion x3: (4/4)
- spear: (3/4)
+ chain mail: done (4/4)
The bag:
1) healing potion
2) mana potion
3) leather armor
4) chain mail

The chain mail does not fit into a full bag (4 of 4), but one more healing potion does: its stack is already started. The ore has nowhere to go. After that remove takes three potions off the stack and the spear away, and the chain mail fits into the freed slot. removeAt(i) removes one item from a slot (the shop uses it for selling) and frees the slot when the count drops to zero.

More precisely, if you are curious

A stack holds an id and a count, not the item itself. The definition of an item (name, price, modifiers) is one for the whole world, in the table; the bag only holds references with counts. Twenty spears do not copy the price twenty times.

The idea

Loot with weights

What does the goblin drop? Not always the same thing, and not with equal chances: often nothing, sometimes a potion, rarely a spear. For this every enemy has a loot table: rows with a weight. The weight is relative: a row with weight 30 in a table with a total of 100 comes up in 30% of the cases. The row none means "nothing", and the count is random between min and max.

# table|item|weight|min|max
goblin|none|40|0|0
goblin|potion_heal|30|1|1
goblin|potion_mana|10|1|1
goblin|spear|10|1|1
goblin|leather|10|1|1
golem|ore|100|1|2

The roll is short. The weights are added up (100 in total), a number from 0 to 99 is rolled, and then the rows are walked in order and the weight of each is subtracted from the number until the number is smaller than the weight of the current row. That row drops. So the width of a row is exactly its chance, and you need neither an order by weights nor percentages that add up to 100.

record Entry(string ItemId, int Weight, int Min, int Max);
record Drop(string Id, int Count);
static Dictionary<string, List<Entry>> LoadLoot(string text)
{
var tables = new Dictionary<string, List<Entry>>();
foreach (string[] c in ParseRows(text, 5))
{
if (!tables.ContainsKey(c[0])) tables[c[0]] = new List<Entry>();
tables[c[0]].Add(new Entry(c[1], int.Parse(c[2]), int.Parse(c[3]), int.Parse(c[4])));
}
return tables;
}
static int TotalWeight(List<Entry> entries)
{
int total = 0;
foreach (Entry entry in entries) total += entry.Weight;
return total;
}
// A number from 0 to the total weight minus 1 is rolled; the weights are subtracted one by one until the number falls inside a row.
static Drop? RollLoot(Random rng, List<Entry> entries)
{
int roll = rng.NextInt(TotalWeight(entries));
foreach (Entry entry in entries)
{
if (roll < entry.Weight)
{
if (entry.ItemId == "none") return null;
return new Drop(entry.ItemId, rng.Range(entry.Min, entry.Max));
}
roll -= entry.Weight;
}
return null;
}
// A number from 0 to the total weight minus 1 is rolled; the weights are subtracted one by one until the number falls inside a row.
static Drop? RollLoot(Random rng, List<Entry> entries)
{
    int roll = rng.NextInt(TotalWeight(entries));
    foreach (Entry entry in entries)
    {
        if (roll < entry.Weight)
        {
            if (entry.ItemId == "none") return null;
            return new Drop(entry.ItemId, rng.Range(entry.Min, entry.Max));
        }
        roll  entry.Weight;
    }
    return null;
}

The program defeats six enemies with seed 6 and puts the loot straight into a bag with 3 slots. The generator is the Random of the canon (the same mulberry32 as in the battle), so the output is the same in all four languages:

var items = LoadItems(ITEMS_TEXT);
var tables = LoadLoot(LOOT_TEXT);
var rng = new Random(6);
var bag = new Inventory(3, items);
for (int i = 0; i < kills.Length; i++)
{
Drop? drop = RollLoot(rng, tables[kills[i]]);
string head = "enemy " + (i + 1) + " (" + kills[i] + "): ";
if (drop == null)
{
Console.WriteLine(head + "nothing");
continue;
}
bool added = bag.Add(drop.Id, drop.Count);
Console.WriteLine(head + Label(items[drop.Id].Name, drop.Count) + " -> " + (added ? "done" : "no room") + " " + FillText(bag));
}
PrintBag(bag);
Output
enemy 1 (goblin): nothing
enemy 2 (goblin): healing potion -> done (1/3)
enemy 3 (goblin): spear -> done (2/3)
enemy 4 (golem): silver ore x2 -> done (3/3)
enemy 5 (goblin): mana potion -> no room (3/3)
enemy 6 (goblin): healing potion -> done (3/3)
The bag:
1) healing potion x2
2) spear
3) silver ore x2

The first goblin drops nothing. The second drops a potion, the third a spear, the golem two pieces of ore (the bag is already full, 3 of 3). The fifth goblin drops a mana potion, but there is no room for a new stack: in the game it would stay on the floor, here it simply does not go in. The sixth drops a healing potion: there is one in the bag already and it stacks, although the bag is full.

Only the number is random; the weights decide how often. Let us check: the same table, but 1000 defeated goblins, counting how many times each row came up.

var entries = LoadLoot(LOOT_TEXT)["goblin"];
var rng = new Random(2026);
var counts = new Dictionary<string, int>();
for (int i = 0; i < 1000; i++)
{
Drop? drop = RollLoot(rng, entries);
string key = drop == null ? "none" : drop.Id;
counts[key] = counts.GetValueOrDefault(key, 0) + 1;
}
int total = TotalWeight(entries);
foreach (Entry entry in entries)
{
Console.WriteLine(entry.ItemId + ": " + counts.GetValueOrDefault(entry.ItemId, 0) + " (expected " + entry.Weight * 1000 / total + ")");
}
Output
none: 396 (expected 400)
potion_heal: 300 (expected 300)
potion_mana: 117 (expected 100)
spear: 91 (expected 100)
leather: 96 (expected 100)

The expected counts are the weights × 10, and the results are close to them, but rarely exactly equal. This is probability, not a distribution by schedule. With fewer defeated enemies the difference is bigger (six enemies are far too few for anything), so a good balance is checked with thousands of defeated enemies: the last lesson does exactly that with the whole game.

See it

The roll in action

Here are the same six defeated enemies turn by turn. The bar is the table: the wider a row, the more often it comes up. The pointer goes to the rolled number, the subtraction of the weights is shown below, and the item leaves its row and enters the bag: in a new slot, in the old slot (it stacks) or not at all if the bag is full.

// A number from 0 to the total weight minus 1 is rolled; the weights are subtracted one by one until the number falls inside a row.
static Drop? RollLoot(Random rng, List<Entry> entries)
{
int roll = rng.NextInt(TotalWeight(entries));
foreach (Entry entry in entries)
{
if (roll < entry.Weight)
{
if (entry.ItemId == "none") return null;
return new Drop(entry.ItemId, rng.Range(entry.Min, entry.Max));
}
roll -= entry.Weight;
}
return null;
}
public bool CanAdd(string itemId, int count)
{
Item item = Items[itemId];
if (item.Stackable && Count(itemId) > 0) return true;
int needed = item.Stackable ? 1 : count;
return Slots.Count + needed <= Capacity;
}
public bool Add(string itemId, int count)
{
if (!CanAdd(itemId, count)) return false;
Item item = Items[itemId];
if (item.Stackable)
{
foreach (Slot slot in Slots)
{
if (slot.Id == itemId)
{
slot.Count += count;
return true;
}
}
Slots.Add(new Slot(itemId, count));
return true;
}
for (int i = 0; i < count; i++) Slots.Add(new Slot(itemId, 1));
return true;
}
Step by step, in words
  1. The goblin’s table is text: 5 rows, each with a weight, 100 in total. The weight is the width of the row in the bar: weight 30 of 100 is a 30% chance. The bag has 3 slots.
  2. Enemy 1 (goblin): a number from 0 to 99 is rolled and gives 1. The weights are subtracted one by one: 1 < 40. The number falls in the row “nothing” (weight 40 of 100).
  3. “nothing” is a row like any other (weight 40): no loot, and the bag is not touched.
  4. Enemy 2 (goblin): a number from 0 to 99 is rolled and gives 56. The weights are subtracted one by one: 56 ≥ 40 → 16 → 16 < 30. The number falls in the row “healing potion” (weight 30 of 100).
  5. Healing potion goes into the bag in a new slot (1/3).
  6. Enemy 3 (goblin): a number from 0 to 99 is rolled and gives 89. The weights are subtracted one by one: 89 ≥ 40 → 49 → 49 ≥ 30 → 19 → 19 ≥ 10 → 9 → 9 < 10. The number falls in the row “spear” (weight 10 of 100).
  7. Spear goes into the bag in a new slot (2/3).
  8. Enemy 4 (golem): a number from 0 to 99 is rolled and gives 1. The weights are subtracted one by one: 1 < 100. The number falls in the row “silver ore” (weight 100 of 100).
  9. Silver ore x2 goes into the bag in a new slot (3/3).
  10. Enemy 5 (goblin): a number from 0 to 99 is rolled and gives 76. The weights are subtracted one by one: 76 ≥ 40 → 36 → 36 ≥ 30 → 6 → 6 < 10. The number falls in the row “mana potion” (weight 10 of 100).
  11. The bag is full (3/3) and mana potion has nothing to stack with: canAdd returns false and add does nothing (in the game the item stays on the floor).
  12. Enemy 6 (goblin): a number from 0 to 99 is rolled and gives 56. The weights are subtracted one by one: 56 ≥ 40 → 16 → 16 < 30. The number falls in the row “healing potion” (weight 30 of 100).
  13. Healing potion is already in the bag, so it stacks in the same slot (healing potion x2); the used slots stay at 3/3.
  14. After 6 defeated enemies the bag is full. Only the number is random; the weights say how often each row comes up, and the bag decides what fits.

The code next to the scene is rollLoot, canAdd and add from the program. At each step the lines that are working at that moment light up.

The idea

The shop: buying and selling

The shop is the shortest of the five things, because everything it needs is already built. Buying is three checks in order: is there enough gold, is there room in the bag, then pay and add the item. Selling removes one item from a slot and pays half the price, rounded down.

// A sale pays half the price, rounded down.
static int SellPrice(Item item)
{
return item.Price / 2;
}
static string Buy(Player player, OrderedDictionary<string, Item> items, string itemId)
{
Item item = items[itemId];
string result = "done";
if (player.Gold < item.Price) result = "not enough gold";
else if (!player.Bag.CanAdd(itemId, 1)) result = "no room";
else
{
player.Gold -= item.Price;
player.Bag.Add(itemId, 1);
}
return "buys " + item.Name + " (" + item.Price + "): " + result + ", gold " + player.Gold;
}
static string Sell(Player player, OrderedDictionary<string, Item> items, int slotNumber)
{
Slot slot = player.Bag.Slots[slotNumber - 1];
Item item = items[slot.Id];
int price = SellPrice(item);
player.Bag.RemoveAt(slotNumber - 1);
player.Gold += price;
return "sells " + item.Name + " (" + price + "): done, gold " + player.Gold;
}
var items = LoadItems(ITEMS_TEXT);
var player = new Player(100, new Inventory(3, items));
Console.WriteLine(Buy(player, items, "potion_heal"));
Console.WriteLine(Buy(player, items, "potion_heal"));
Console.WriteLine(Buy(player, items, "chain"));
Console.WriteLine(Buy(player, items, "leather"));
Console.WriteLine(Buy(player, items, "war_axe"));
Console.WriteLine(Sell(player, items, 2));
Console.WriteLine(Buy(player, items, "potion_mana"));
Console.WriteLine(Buy(player, items, "antidote"));
Console.WriteLine(Buy(player, items, "leather"));
Console.WriteLine(Sell(player, items, 1));
Output
buys healing potion (15): done, gold 85
buys healing potion (15): done, gold 70
buys chain mail (90): not enough gold, gold 70
buys leather armor (30): done, gold 40
buys war axe (80): not enough gold, gold 40
sells leather armor (15): done, gold 55
buys mana potion (15): done, gold 40
buys antidote (10): done, gold 30
buys leather armor (30): no room, gold 30
sells healing potion (7): done, gold 37

The order of the checks matters. The chain mail for 90 cannot be bought with 70 gold, nor the war axe for 80 with 40. On the ninth line the hero has the gold for a leather armor (30), but the bag is full: the gold check passes, canAdd refuses, and no gold is taken. Selling the leather armor pays 15 (30 / 2), and a healing potion 7 (15 / 2, rounded down): buying and straight away selling the same thing is never a profit.

Here is the selling price in the canon. Buying there is the same checks, only the screen reads the number from the keyboard:

public static int SellPrice(Item item)
{
return item.Price / 2;
}

The idea

In the game

What we built is the same as in the canon, only smaller. Here is the real inventory:

class Inventory
{
public int Capacity;
public List<ItemStack> Slots;
public Inventory(int capacity)
{
Capacity = capacity;
Slots = new List<ItemStack>();
}
public int Count(string itemId)
{
int total = 0;
foreach (ItemStack slot in Slots)
{
if (slot.Id == itemId) total += slot.Count;
}
return total;
}
public bool CanAdd(string itemId, int count)
{
Item item = ITEMS[itemId];
if (item.Stackable && Count(itemId) > 0) return true;
int needed = item.Stackable ? 1 : count;
return Slots.Count + needed <= Capacity;
}
public bool Add(string itemId, int count)
{
if (!CanAdd(itemId, count)) return false;
Item item = ITEMS[itemId];
if (item.Stackable)
{
foreach (ItemStack slot in Slots)
{
if (slot.Id == itemId)
{
slot.Count += count;
return true;
}
}
Slots.Add(new ItemStack { Id = itemId, Count = count });
return true;
}
for (int i = 0; i < count; i++) Slots.Add(new ItemStack { Id = itemId, Count = 1 });
return true;
}
public void Remove(string itemId, int count)
{
int left = count;
List<ItemStack> kept = new List<ItemStack>();
foreach (ItemStack slot in Slots)
{
if (slot.Id == itemId && left > 0)
{
int taken = Math.Min(left, slot.Count);
slot.Count -= taken;
left -= taken;
}
if (slot.Count > 0) kept.Add(slot);
}
Slots = kept;
}
public void RemoveAt(int index)
{
ItemStack slot = Slots[index];
slot.Count -= 1;
if (slot.Count <= 0) Slots.RemoveAt(index);
}
}

Equipment in the canon is part of Creature: gearBonus adds up the modifiers over the slots and sits between the base and the effects (see the lesson on heroes and enemies):

public override int GearBonus(string stat)
{
int total = 0;
foreach (string slot in Equipment.Keys)
{
string itemId = Equipment.Get(slot);
if (itemId == "") continue;
foreach (Mod mod in ITEMS[itemId].Mods)
{
if (mod.Stat == stat) total += mod.Value;
}
}
return total;
}
public Item Weapon()
{
string id = Equipment.Get("weapon");
return ITEMS[id == "" ? "fists" : id];
}

Putting an item on from the bag swaps the old one in at the same place, so the order of the items does not get mixed up:

// Equips the item from the inventory; the old one takes its place in the inventory.
static partial class Arcanum
{
public static string EquipFromInventory(Hero hero, int index)
{
ItemStack slot = hero.Inventory.Slots[index];
Item item = ITEMS[slot.Id];
if (item.Kind != "weapon" && item.Kind != "armor" && item.Kind != "ring") return "";
string old = hero.Equipment.Get(item.Kind);
hero.Inventory.RemoveAt(index);
hero.Equipment.Set(item.Kind, item.Id);
if (old != "") hero.Inventory.Slots.Insert(index, new ItemStack { Id = old, Count = 1 });
if (hero.Hp > hero.MaxHp()) hero.Hp = hero.MaxHp();
if (hero.Mana > hero.MaxMana()) hero.Mana = hero.MaxMana();
return item.Id;
}
}

And the loot roll, which you know:

// Weighted loot: weight 30 out of a total of 100 means a 30% chance.
static partial class Arcanum
{
public static ItemStack? RollLoot(Random rng, string tableId)
{
List<LootEntry> entries = LOOT_TABLES[tableId];
int total = 0;
foreach (LootEntry entry in entries) total += entry.Weight;
int roll = rng.NextInt(total);
foreach (LootEntry entry in entries)
{
if (roll < entry.Weight)
{
if (entry.ItemId == "none") return null;
return new ItemStack { Id = entry.ItemId, Count = rng.Range(entry.Min, entry.Max) };
}
roll -= entry.Weight;
}
return null;
}
}

The differences from our program:

  • One table per kind. Weapons, armor, rings, potions and the rest each have their own columns; our table is a single one so that it reads easily.
  • Names in four languages. Every row in the canon carries the name in bg, en, de and es; nm(item) picks by the language of the game. Here the name is one cell.
  • A global ITEMS. The canon keeps the items in one global dictionary and looks them up by id. We pass them to Inventory and Hero, so that the dependencies are visible and testing is easy.
  • A bag with 10 slots. The hero in the canon has Inventory(10); here the slots are 3 and 4, so you can see when they fill up.
  • Gold and potions. Gold is not an item in the bag but a number on the hero (enemies drop gold separately from loot). Potions have an action (heal, mana, cure) and drinkPotion drinks them in battle and on the map; here we only stack them.
  • Loot falls to the floor. A defeated enemy leaves the item on the floor (dropItem), and the hero picks it up with g; with a full bag the item stays on the floor and the player sees "the bag is full". In our program the loot goes straight into the bag, and a full bag returns false.

Try it

Predict the output

Three rows with weights 50, 30 and 20 and six rolled numbers. What does the program print?

static string Pick(string[] ids, int[] weights, int roll)
{
for (int i = 0; i < ids.Length; i++)
{
if (roll < weights[i]) return ids[i];
roll -= weights[i];
}
return ids[ids.Length - 1];
}
string[] ids = ["none", "potion_heal", "spear"];
int[] weights = [50, 30, 20];
int[] rolls = [0, 49, 50, 79, 80, 99];
foreach (int roll in rolls)
{
Console.WriteLine(roll + ": " + Pick(ids, weights, roll));
}
Answer

Try it

Find the bug

A colleague rewrote the check in canAdd, and now a bag with 2 slots holds three items, and a potion does not go into a full bag:

var items = LoadItems(ITEMS_TEXT);
var bag = new Inventory(2, items);
AddAndShow(bag, "spear", 1);
AddAndShow(bag, "potion_heal", 1);
AddAndShow(bag, "potion_mana", 1);
AddAndShow(bag, "potion_heal", 1);
Output
+ spear: done (1/2)
+ healing potion: done (2/2)
+ mana potion: done (3/2)
+ healing potion: no room (3/2)

Which line is broken?

Pick the line with the bug.

More precisely

More precisely: probabilities, shares and weak spots

Weights, not percentages. Weights do not have to add up to 100. A table 3, 1, 1 is the same as 60, 20, 20. The total is the divisor and is calculated again for every roll, so a new row is added without touching the rest. Percentages would have to add up to 100 after every change, weights do not.

Subtracting or adding. You can roll a number and compare it with sums (40, 70, 80, 90, 100): row i comes up when the number is below the i-th sum. Subtracting is the same without a second array. For big tables the sums are kept ready and searched with binary search; for the tables of a game that is unnecessary.

"Nothing" is a row. none with weight 40 is a row like any other: with one number a table gives 0% or 90% "nothing". And the table of the leader (leader) in the canon has only one row with weight 100: the ring always drops.

All or nothing. Every change to the bag goes through add, which asks canAdd first. So "half a stack" or "three items in two slots" cannot happen. The exercise below protects exactly that.

Rounding. price / 2 with integer division cuts off the remainder, so a sale never pays more than half. In Python and JavaScript integer division is // and Math.floor; they work correctly because prices are not negative.

Randomness and repeatability. The roll does not know what will come up; it only knows how to answer a number. The generator has a seed (mulberry32), so with the same seed and the same kills the loot is the same in all four languages and in a recorded playthrough. We will need this for saving and for the simulation as well.

Exercise

Stacks with an upper limit

Solve the task in your editor. Start from the inventory program.

In the game, ten potions in one slot is too much. Add an upper limit for a stack: at most 5 of one item per slot. What changes:

  • a stack that reaches 5 is full; more of the same goes to a new slot;
  • add must be all or nothing: if they do not all fit (into old stacks and into new slots), nothing is put in;
  • non-stacking items take one slot each, as before.

You need room (how much more fits into old stacks), a corrected canAdd, and an add that first tops up the old stacks and then makes new ones. The bag has 3 slots, and the scenario is this:

static void AddAndShow(Inventory bag, string itemId, int count)
{
bool added = bag.Add(itemId, count);
var parts = new List<string>();
foreach (Slot slot in bag.Slots) parts.Add(Label(bag.Items[slot.Id].Name, slot.Count));
Console.WriteLine("+ " + Label(bag.Items[itemId].Name, count) + ": " + (added ? "done" : "no room") + " " + FillText(bag) + " [" + string.Join(", ", parts) + "]");
}
var items = LoadItems(ITEMS_TEXT);
var bag = new Inventory(3, items);
AddAndShow(bag, "potion_heal", 4);
AddAndShow(bag, "potion_heal", 3);
AddAndShow(bag, "potion_mana", 2);
AddAndShow(bag, "potion_heal", 3);
AddAndShow(bag, "potion_heal", 1);
AddAndShow(bag, "potion_mana", 4);

The output is:

Output
+ healing potion x4: done (1/3) [healing potion x4]
+ healing potion x3: done (2/3) [healing potion x5, healing potion x2]
+ mana potion x2: done (3/3) [healing potion x5, healing potion x2, mana potion x2]
+ healing potion x3: done (3/3) [healing potion x5, healing potion x5, mana potion x2]
+ healing potion: no room (3/3) [healing potion x5, healing potion x5, mana potion x2]
+ mana potion x4: no room (3/3) [healing potion x5, healing potion x5, mana potion x2]

The lines show the boundaries too: the second step tops up the old stack to 5 and opens a new slot for the remaining 2; the fourth tops up the already started stack and needs no new slot; the last one (4 more mana potions with a stack of 2) puts nothing in, although 3 of them would fit, because the fourth has nowhere to go.

More tasks

  • Sell the junk. Add sellJunk(player, items): it sells everything of kind misc and returns how much gold was received.
  • The best weapon. When the hero picks up a weapon, put it on only if the sum of its modifiers is larger than the sum of the one currently equipped.
  • Luck. Give rollLoot a parameter luck: the bigger it is, the more the weight of the none row is reduced (but not below 0), and the others stay. What happens to the chances of the others?

Test

Test yourself

1. Why are the items of the game a table in text and not new Item(...) in the code?
2. The hero has 40 life with a ring of life (+10 maximum life). The life is full: 40/40. They take the ring off. How much life is left?
3. The bag has 4 slots and all of them are taken. There is a healing potion in it. Can one more such potion be added?
4. The goblin’s table is: nothing 40, healing potion 30, mana potion 10, spear 10, leather armor 10. The roll is 85. Which item drops?
5. A healing potion costs 15. For how much does the hero sell it in the shop?
6. In 1000 defeated goblins the program counts 396 times “nothing”, not exactly 400. Why?

Made by Ivo Gergov as part of Evo Tech's campaign for accessible programming education ·