Items, traps and mods
How items, loot, traps and mods work in the hand-built scenes of games/crpg-realm, with the steps to add each. The scenario engine reads some of the same data differently; see the reference.
Items
items.json
data/v1/items.json is a JSON array. DataStoreV1.load_all_data() turns each entry into DataStore.items[id] = ItemData.from_dict(entry). A record:
{
"id": "service-sword",
"title": "Royal Guard Service Sword",
"category": "weapon",
"weaponType": "melee",
"equipSlot": "main_hand",
"damageDice": "1d8",
"damageType": "slashing",
"properties": ["versatile"],
"weight": 3,
"acBonus": 0,
"cost": 15,
"icon": "assets/icons/weapons/longsword.png",
"description": "Standard-issue steel military arming sword of the Oakhaven Royal Guard."
}
ItemData (src/generated/v1/ItemData.gd) keeps these keys and drops the rest. It also accepts the robos:-prefixed keys of the editor’s JSON-LD items.
| Key | ItemData field | Used by |
|---|---|---|
id, title, description | id, title, description | Inventory, shop, log messages |
category | category | GameState.equip_item() and use_item(): weapon, armor, consumable, accessory, and so on |
equipSlot | equip_slot | Picks the shield (off_hand), helmet (head) and accessory (accessory, ring, belt, eyes) slots |
armorType | armor_type | AC rules (below); shield and helmet select those slots |
acBonus | ac_bonus | Armor base AC; shield bonus |
damageDice, damageType | damage_dice, damage_type | Shown in the inventory, shop and character sheet. Combat damage does not use them. |
cost | cost | Shop buy price (15 if the cost is 0). Selling pays half, rounded down. |
icon | icon_path, icon | Icons. res:// is prepended, so give a project-relative path. |
properties | properties | see_invisibility and truesight are checked by GameState |
weight | weight | Stored only |
model3dAsset, model3dSocket, model3dScale, model3dTint | model_3d_* | Stored only. No GDScript reads them. |
The mesh shown on a pawn comes from CharacterModel3D, which matches the item id (an id containing greatsword gets weapon_greatsword.glb) or accepts a path ending in .glb. The keys weaponType, range, healDice and use in the file are not read by ItemData. (ScenarioEngine reads rangeFeet, use and grants from the raw data.)
Inventory, slots and AC
All inventory state is in GameState: inventory is a flat array of item ids (three potions are three "potion-healing" entries), plus equipped_weapon, equipped_armor, equipped_shield, equipped_helmet and equipped_accessory. add_item(id) appends one id and logs “Acquired item”. has_item(id) checks the inventory and the weapon, armor and accessory slots.
equip_item() chooses the slot in this order: weapon category, then shield, then helmet, then armor, then accessory. It swaps the old item back into the inventory.
recalculate_hero_ac() sets AC. With no armor, AC is 10 plus the DEX modifier. With armor, by armorType: light is acBonus plus DEX; medium is acBonus plus DEX capped at 2; heavy is acBonus; anything else is acBonus if above 0. A shield adds its acBonus (2 if that is 0). For example chain-mail is heavy with acBonus 16, and shield is armorType: "shield" with acBonus 2.
use_item() handles only consumables: antidote and potion-antidote remove poisoned; potion-invisibility applies invisible for 60 seconds; potion-greater-healing heals 4d4+4; any other consumable heals 2d4+2. The use and healDice keys are ignored.
The inventory window shows a 16-slot backpack grid with a weapon and an armor slot.
Ground items, chests and corpses
GroundItem(scenes/components/GroundItem.tscn) exportsitem_id,item_name,quantity,is_picked_upandicon_texture. A left-click callspickup(), which adds one copy ofitem_id, emitsitem_picked_upand frees the node. The label comes fromitem_nameand the sprite fromicon_texture, not fromitems.json.quantityis ignored, and there is no range check.TreasureChest(scripts/TreasureChest.gd) is anArea2Dthat, on the first left-click, setsis_opened, changes itsLabelto “[Chest Opened]” and emitschest_opened. The scene script decides what is inside:AncientCatacombs.gdconnects the signal and callsGameState.add_item("garrison-key"). The node needs a childLabel.- Enemy corpses:
TacticalEnemyexportsloot_gold(25) andloot_items(["potion-healing"]), handed out byloot_corpse(). - The shop: Blacksmith Brand’s stock is the
shop_itemsarray inscripts/ShopWindow.gd, not data.
Add an item
- Append an object to
data/v1/items.json. Mind the comma. Check the file withpython3 -m json.tool data/v1/items.json > /dev/null. - To place it on the floor, instance
GroundItem.tscnas a direct child of a scene root and setitem_id,item_nameandicon_texture. Always add the item toitems.jsonfirst. The village’sGroundItem_Swordusesitem-id-iron-sword, which is not initems.json, so after pickup the inventory shows the raw id. - To put it in a chest, connect
chest_openedin the scene script and callGameState.add_gold()andGameState.add_item(). - To sell it, add the id to
shop_itemsinShopWindow.gd.
Test it with steps that exist in crpg_steps.py and infinity_ai_steps.py:
Scenario: Pick up and equip the new sword
Given an isolated test starting in scene "VillageSquare" with party "Lieutenant Vance" the "fighter"
When the infinity ai agent picks up item "your-item-id"
Then the party has acquired item "your-item-id"
When the hero equips item "your-item-id"
Then the hero has item "your-item-id" equipped
Other steps in normal/04_shopkeeper_trading_economy_and_inventory.feature cover shop prices and inventory counts.
Traps
A trap is an instance of scenes/components/Trap.tscn: an Area2D running scripts/Trap.gd. The shipped scene AncientCatacombs.tscn places three: PoisonDartTrap, GlyphOfWarding and SpikePitTrap.
Data
Trap.gd exports every number it needs: trap_id, trap_name, trap_type, detect_dc (13), disarm_dc (14), save_stat (CON), save_dc (13), damage_dice_min (2), damage_dice_max (12), damage_type, status_effect, status_duration, detection_radius (650 px) and disarm_reach (80 px). In _ready(), if DataStore.traps has an entry for trap_id, its values overwrite the exports. data/v1/traps.json has four: poison-dart-trap, glyph-of-warding, spike-pit-trap and acid-spray-trap. The last is not placed in any scene.
{
"id": "poison-dart-trap",
"title": "Concealed Poison Dart Trap",
"type": "floor",
"detectDC": 13,
"disarmDC": 14,
"saveStat": "CON",
"saveDC": 13,
"damageFormula": "2d6",
"damageMin": 2,
"damageMax": 12,
"damageType": "poison",
"statusEffect": "poisoned",
"statusDuration": 3,
"description": "A pressure plate hidden beneath loose dungeon flagstones."
}
The code reads title, detectDC, disarmDC, saveStat, saveDC, damageMin, damageMax, damageType, statusEffect and statusDuration. Damage is a flat random integer between damageMin and damageMax, not a dice roll, and type, damageFormula and description are ignored. statusDuration is also ignored: a failed save always applies the status for 3.
States
A trap is concealed (invisible), detected (red tint, pulsing, with a “[TRAP]” label), disarmed or triggered. A disarmed or sprung trap flashes (green or orange), fades out and is removed from play. GameState.neutralized_traps remembers it per scene, so it stays gone when you re-enter.
Detection
A trap is found in one of two ways.
- Find Traps mode. The rogue’s toolbar button (
Find Traps, thenTraps (ON)) callsGameState.set_detect_traps_mode(). While it is on,GameStatecallspulse_trap_detection()every 1.5 seconds. That callsattempt_detection(name)on every direct child of the scene that has it. The searcher is the first party member whose class isrogue, or the hero.attempt_detectionfails if the searcher is more thandetection_radiusaway, or if fog of war is on and the trap’s position has not been explored or seen. Otherwise it callsCombatManager.resolve_trap_detection(), which rollsmax(10, d20)plus the perception bonus againstdetect_dc. The bonus ismax(4, WIS modifier + proficiency)for the hero (proficiency 4 for a rogue, else 2), 5 for “Bramble”, and 4 for anyone else. The floor of 10 means the lowest total is 14, so a trap withdetectDC14 or less is always found on the first pulse in range. - The Find Traps spell.
find-trapscallsreveal_trap()on every direct child that has it, with no roll. When fog of war is on, it skips traps in unexplored, unseen areas. The route differs by scene: the HTTPcast_spellaction runs its own loop when the scene’sCombatManagerhas nocast_spellmethod, andTacticalBattlegoes through the"find-traps"branch ofCombatManager.execute_cast_spell. The toolbar Spell button callscm.cast_spell("find-traps", ...), andCombatManagerdefines no method of that name, so from the toolbar it raises a runtime error in any scene that has aCombatManager.
Disarming
Left-clicking a detected trap sends the thief to approach_and_disarm(), which walks to 55 px from the trap and calls disarm_trap(). Clicking a concealed trap just moves the hero there, and they may walk onto it. disarm_trap() refuses beyond disarm_reach (80 px). The roll is max(10, d20) plus a tools bonus (max(5, DEX modifier + proficiency) for the hero, 5 for everyone else) against disarm_dc. Two consequences: the lowest total is 15, so every trap with a disarm DC of 15 or less is disarmed on the first try, and a natural fumble cannot happen. Only the test API’s fumble argument forces one, and a forced fumble springs the trap on the disarmer.
Springing
force_trigger() has three callers. Walking onto the trap (_on_body_entered) allows up to 78 px. Scripted triggers (trigger_trap action, walk_over_and_trigger) allow 54 px. A fumbled disarm has no limit. Any CharacterBody2D whose name does not contain “ghost” or “npc” springs it, including a patrolling enemy.
The save bonus is 2 for everyone except the hero, whose bonus is the ability modifier for save_stat plus 2 if the class is proficient (fighter: STR and CON; rogue: DEX and INT; others: WIS and CHA). resolve_trap_trigger() rolls a d20 against save_dc. A passed save halves the damage, rounded up. A failed save takes full damage and applies the status. Damage lands on the hero or on a matching party_members entry.
Add a trap
- Append an entry to
data/v1/traps.jsonwith a uniqueidand the keys above. - Instance
Trap.tscnas a direct child of the scene root, name it, and settrap_id. Set the other exports to the same values: the JSON wins at runtime, but the exports are the fallback and what the editor shows. - In a scene other than
AncientCatacombs, copy its loop in_ready()that connectstrap_detected,trap_disarmedandtrap_triggeredon everyTrapchild (withconst TrapClass = preload("res://scripts/Trap.gd")). - Give the scene a
CombatManagerchild. Without one,Trapbuilds a detachedCombatManager.new()each time.
Verify with the steps in normal/12_infinity_engine_traps_detection_and_disarm.feature:
Scenario: The new trap is revealed and disarmed
Given an isolated test starting in scene "AncientCatacombs" with party "Bramble" the "rogue"
When the trap "your-trap-id" is revealed
And the player orders "Bramble" to disarm trap "your-trap-id"
Then the party member is standing next to trap "your-trap-id"
Do not assert the trap "..." is disarmed for a trap whose disarm DC is above 15, because that roll can fail.
Mods
DataStoreV1.load_mods() scans res://mods/ (inside the project) and then user://mods/. Every subfolder that has a mod.json is a mod. A missing folder is skipped without error. _load_single_mod() stores the manifest in DataStore.loaded_mods and prints the mod’s name and version. The only key that loads data is traps_file, a path relative to the mod folder to a JSON array of trap objects, in the same shape as traps.json.
The shipped example is mods/catacomb-traps-mod/:
{
"id": "catacomb-traps-mod",
"name": "Catacomb Traps & Dungeon Hazards Mod",
"version": "1.0.0",
"author": "Lead System Architect",
"description": "Adds extra lethal dungeon traps ...",
"traps_file": "traps.json"
}
Its traps.json adds one trap, thunder-tripwire, which no scene places. The manifest’s description mentions “custom status conditions and expanded disarm checks”, but the loader does not read them.
What a mod can do:
- Replace a placed trap’s numbers. An entry with an existing id replaces the whole base entry, so include every key. A
user://mods/<id>/traps.jsonentry forspike-pit-trapwith a higherdisarmDCchanges the spike pit inAncientCatacombswithout touching the scene.user://mods/is read afterres://mods/, so it wins. The order between two mods in one folder is whateverDirAccessreturns. - Register a new trap id. It does nothing until a
Trap.tscninstance with thattrap_idsits in a scene.
What it cannot do: add items, spells, monsters, NPCs, dialogue, quests, scenes or scripts, place anything on a map, or be disabled. A malformed mod.json, a wrong traps_file path or a non-array file fails silently. Success prints DataStoreV1: Successfully loaded mod -> ... and Mod [id]: Registered custom trap -> ... to Godot’s output (tests/e2e/reports/godot_output.log under the tests).
user:// is Godot’s per-user data folder, named after config/name in project.godot. On Linux it is ~/.local/share/godot/app_userdata/<project name>/. The project name contains a colon, which some systems do not allow, so in the editor use Project, Open User Data Folder to find the real path, and create mods/ there.
The web export preset (export_presets.cfg) includes *.json and *.jsonld files, so mods placed under mods/ in the project are packed into a browser build.