Torchbound

So I dove into developing my game, which will use the dungeon I now know how to generate.
After much thought, I’ve decided to call it Torchbound, since it’s based on the rules of Five Torches Deep.

Version 1, aka “Optimism and carefree”

I thought to myself, “OK, it’s a simplified version of D&D—this will work easily,” and of course, as always, I started playing around with the UI.
And I built a UI in Lua—how should I put it?—it’s pretty old-school :)
And of course I created the logical sequence of screens for character creation: Race, Attibutes, Classes, Equipment—the usual stuff.
Then, of course, the inevitable happened: “Damn, the rules aren’t that simple,” and “Hello, unmaintainable if… then… else statements.
So, I… stopped everything and… started over from scratch (which just shows that WIS doesn't necessarily come with age).
(Well, OK, I’ll probably keep some assets like logos, buttons, and sounds)

Version 2, “Welcome to the Real World”

The UI will come later; I need to start with the game engine: the FTD rules.

Phase 1: The Rules
So I started by actually reading the rules—which, let’s face it, I’d only skimmed before. Well, even though it’s a minimalist version of D&D 5th Edition, it’s not as straightforward as it seems. I ended up with a rules file that’s… 120 KB, including 24 reference tables. Plain text, mind you—not a Word document with images.

Phase 2: Modeling the Rules
Then, after careful consideration, I settled on the following model:

      ┌→ Specific item (AC) ────────→ Plain text 'Contract' ────→ Lua 'Tests' ────→ Lua 'Logic'
      ├→ Specific item (Armor)──────→ Plain text 'Contract' ────→ Lua 'Tests' ────→ Lua 'Logic'
RULES ┼→ Specific item (Attack) ────→ Plain text 'Contract' ────→ Lua 'Tests' ────→ Lua 'Logic'
      ├→ ...
      └→ Specific item (Weapon) ────→ Plain text 'Contract' ────→ Lua 'Tests' ────→ Lua 'Logic'

And now I think I've covered everything with a little less than... 90 subdomains (from “AC” to “Weapon”). It's just huge compared to what I imagined, but at least the foundations will be isolated, well-defined, and their code and tests will be dedicated.

Phase 3: Implementing the Rules
For example, for AC:

Contract 'contract.md':

# CONTRACT: ARMOR CLASS

Every combatant, PC or NPC, has an Armor Class (AC).
AC is the number an attacker must meet or exceed to hit the target in combat.
AC is therefore the DC used to damage a target.
FTD defines, among others:
    Heavy Armor
        AC 15
    Light Armor
        AC 12 + DEX mod
A shield increases AC.
Further AC calculation details depend on the equipment and applicable rules.

## SOFTWARE INTERPRETATION
The contract receives the values required to calculate AC.
For heavy armor:
    AC = 15
For light armor:
    AC = 12 + DEX mod
The contract does not resolve the attack check, roll dice, select armor,
calculate the DEX modifier, or decide whether an attack hits.

## API
Module:
    Modules.Engine.ac

Entry point:
    ac.heavy()
    ac.light(dex_modifier)

## RETURN
The functions return:
    {ac = <valeur d'AC>}

## INVARIANTS
Heavy armor provides a base AC of 15.
Light armor provides a base AC of 12 before applying the DEX modifier.
The contract never modifies DEX, calculates the DEX modifier, or rolls dice.
It is deterministic for identical inputs.


## TEST CASES
The following cases must be covered:
- heavy armor
- light armor with a zero modifier
- light armor with a positive modifier
- light armor with a negative modifier
- determinism


## OUT OF SCOPE

The AC contract does not perform:
- RNG ;
- CHECK ;
(...)

## VALIDATION
The contract is considered validated when:
    lua Tests/run_tests.lua
returns zero failures and all contract cases are covered.

Tests 'test-AC.lua':

TestAC = {}
-- HEAVY ARMOR
function TestAC:test_heavy_armor()
    local result = ac.heavy()
    lu.assertEquals(result.ac, 15)
end
(...)
return TestAC

Logic 'ac.lua':

local ac = {}
function ac.heavy()
    return {
        ac = 15
    }
end
function ac.light(dex_modifier)
    return {
        ac = 12 + dex_modifier
    }
end
return ac

Phase 4: Writing All These Items All the {specifications, tests, code} for the 90 domains have been written, and I am verifying that:

  • the {specifications, tests, code} actually exist:
.\Specifications\Contracts\Rules_Check.ps1
 TORCHBOUND - RULES CHECK

===== COUNTS =====
Contracts             : 86
Contract modules      : 86
Contract tests        : 86
Infrastructure tests  : 5
Total tests           : 91

===== CONTRACT BIJECTION =====
Contracts          : 86
Modules            : 86
Contract tests     : 86
OK   complete 1:1 contract matrix.

 RULES CHECK : PASS
  • the tests are OK:
lua .\Tests\run_tests.lua
Ran 967 tests in 0.072 seconds, 967 successes, 0 failures

Nearly 1,000 unit tests are run with every change.

Phase 5: The engine that drives all these rules
The approach adopted is as follows:

                        +------------------+
                        |    INPUT / UI    |
                        +--------+---------+
                                 |
                                 | command
                                 v
                        +------------------+
                        | COMMAND VALIDATOR|
                        +--------+---------+
                                 |
                                 | valid command
                                 v
                        +------------------+
                        |  COMMAND QUEUE   |
                        +--------+---------+
                                 |
                                 | one command per update
                                 v
+----------------+      +--------+---------+      +----------------+
| ACTOR STATE    |<---->|      ENGINE      |<---->| COMBAT / TURN  |
| HP, SUP, class |      | orchestration    |      | current actor  |
| actions, etc.  |      +--------+---------+      +----------------+
+----------------+               |
                                 | selects resolver
              +------------------+------------------+
              |                  |                  |
              v                  v                  v
     +----------------+ +----------------+ +----------------+
     | ACTION         | | ATTACK         | | SPELLCASTING   |
     | RESOLVER       | | RESOLVER       | | RESOLVER       |
     +-------+--------+ +-------+--------+ +-------+--------+
             |                  |                  |
             |                  v                  v
             |          +---------------+  +---------------+
             |          | DAMAGE / HP   |  | SPELL CHECK   |
             |          +---------------+  +-------+-------+
             |                                     |
             |                             failure |
             |                                     v
             |                             +---------------+
             |                             | MAGIC MISHAP  |
             |                             +---------------+
             |
             +------------------+
                                |
                                | state changes
                                | and events
                                v
                       +-------------------+
                       |   EVENT BUFFER    |
                       +---------+---------+
                                 |
                                 | dispatch
                                 v
                       +-------------------+
                       | EVENT HANDLERS    |
                       +---------+---------+
                                 |
                    +------------+------------+
                    |                         |
                    v                         v
           +----------------+        +----------------+
           | COMBAT LOG     |        | FOLLOW-UP RULE |
           | observable     |        | death, etc.    |
           +----------------+        +----------------+

In practice, it looks something like this when you're playing around with a Goblin:

[CALL] PUBLIC engine.update(0)
[CALL] command_queue.pop
[RETURN] command_queue.pop
[STATE] queue 1 -> 0
[STATE] consumed command=Attack
[CALL] combat_command_resolver.execute
  [CALL] engine.use_action
  [RETURN] engine.use_action
  [STATE] Active false -> true
  [STATE] action accepted=true
  [CALL] combat_resolver.execute
    [CALL] attack.resolve
    [RETURN] attack.resolve
    [STATE] attack hit=true
    [CALL] damage.resolve
    [RETURN] damage.resolve
    [STATE] damage total=5
    [CALL] hp.apply_damage
    [RETURN] hp.apply_damage
    [STATE] hp result 5 -> 0
    [CALL] events.push
    [RETURN] events.push
    [EVENT] damage_applied
    [STATE] events 0 -> 1
  [RETURN] combat_resolver.execute
  [STATE] goblin.hp 5 -> 0
  [STATE] combat hit=true
[RETURN] combat_command_resolver.execute
[STATE] attack command success=true
[CALL] event_dispatcher.dispatch_all
  [CALL] event_dispatcher.dispatch
    [CALL] combat_log.on_damage_applied
    [RETURN] combat_log.on_damage_applied
    [STATE] logs 0 -> 1
    [CALL] death_handler.on_damage_applied
    [RETURN] death_handler.on_damage_applied
    [STATE] goblin.incapacitated nil -> true
    [STATE] goblin.dying nil -> true
    [STATE] death handler result=false
  [RETURN] event_dispatcher.dispatch
  [EVENT] dispatched damage_applied
[RETURN] event_dispatcher.dispatch_all
[STATE] events 1 -> 0
[STATE] logs 0 -> 1
[RETURN] PUBLIC engine.update(0)
[STATE] queue 1 -> 0
[STATE] logs 0 -> 1
[STATE] Active false -> true
[STATE] goblin.hp 5 -> 0
[STATE] goblin.incapacitated nil -> true
[STATE] goblin.dying nil -> true

So I’m still working on integrating all of this.

Once all the rules are properly implemented (especially since I’ve learned along the way that there’s an SRD for FTD, and of course it doesn’t match my rules 100%), I’ll start working on more visual aspects, but we’re not there yet.

A quick note on tools:

  • It’s obvious, but I’ll say it anyway: without Git, there’s no hope (I’m working entirely locally).
  • CodeGraph is simply indispensable as the code grows; it lets you generate things like this:
combat_resolver
├── actor::get_ac
│   ├── actor::get_ac_bonus
│   ├── armor::ac
│   └── shield::apply
├── actor::is_dead
│   └── actor::has_condition
├── attack::resolve
│   └── check::resolve
├── damage::resolve
├── engine::get_actor
├── engine::get_state
├── equipment::get
├── events::push
├── hp::apply_damage
│   ├── hp
│   │   └── supply::max
│   └── supply::max
├── weapon::attack_bonus
└── weapon::damage_bonus

and:

engine::rest_actor
├── engine::get_actor [Modules/Engine/engine.lua:204]
└── rest_handler::apply [Modules/Engine/engine.lua:273, Modules/Engine/rest_handler.lua:12]
    ├── actor::clear_temporary_injuries [Modules/Engine/actor.lua:142, Modules/Engine/rest_handler.lua:12]
    │   └── actor.ensure_injuries [Modules/Engine/actor.lua:75]
    ├── actor::has_condition [Modules/Engine/actor.lua:201, Modules/Engine/rest_handler.lua:12]
    ├── actor::remove_condition [Modules/Engine/actor.lua:222, Modules/Engine/rest_handler.lua:12]
    ├── hp::apply_healing [Modules/Engine/hp.lua:34, Modules/Engine/rest_handler.lua:12]
    │   └── hp.normalize_amount [Modules/Engine/hp.lua:4]
    │       └── supply::max [Modules/Engine/hp.lua:4]
    └── rest::resolve [Modules/Engine/rest.lua:3, Modules/Engine/rest_handler.lua:12]

And, most importantly, to find out what I've already integrated and what I haven't:

Runtime call tree reachable from main.lua.

main.lua
├── love.draw
│   └── engine.draw
├── love.load
│   └── engine.init
│       ├── combat.create
│       │   ├── combat_round.create
│       │   │   └── combat_turn.create
│       │   └── combat_round.create
│       │       └── combat_turn.create
│       ├── combat.create
│       │   ├── combat_round.create
│       │   │   └── combat_turn.create
│       │   └── combat_round.create
│       │       └── combat_turn.create
│       ├── command_queue.new
│       ├── command_queue.new
│       └── event_setup.init
└── love.update
    └── engine.update
        ├── action_resolver.execute
        │   ├── engine.get_actor
        │   ├── engine.get_state
        │   ├── engine.use_action
        │   │   ├── actions.can_follow
        │   │   ├── actions.can_take
        │   │   ├── actions.use
        │   │   │   └── actions.can_take
        │   │   ├── actions.use
        │   │   │   └── actions.can_take
        │   │   ├── actor.can_act
        │   │   │   ├── actor.create
        │   │   │   │   ├── supply.load
        │   │   │   │   └── supply.max
        │   │   │   └── actor.has_condition
        │   │   ├── engine.build_used_actions
        │   │   ├── engine.get_action_key
        │   │   ├── engine.get_actor
        │   │   └── engine.set_action_used
        │   ├── events.create
        │   └── events.push
        ├── actor.has_condition
        ├── command_queue.pop
        ├── death_handler.resolve_deadline
        │   ├── actor.add_condition
        │   ├── actor.has_condition
        │   ├── actor.remove_condition
        │   ├── death.resolve
        │   └── events.push
        ├── death_handler.resolve_deadline
        │   ├── actor.add_condition
        │   ├── actor.has_condition
        │   ├── actor.remove_condition
        │   ├── death.resolve
        │   └── events.push
        └── event_dispatcher.dispatch_all
  • Last but not least, to keep from getting lost amid development, integration, bugs, and testing, Linear is a lifesaver too. enter image description here

Well, I’ve spent about half my vacation on this, but I’m having fun :)
That was a (very) long post—sorry.
Have a safe trip, and don't forget your torches if you're going to seedy places—especially ones that aren't well-lit.
Peace, out.