Dice-based Combat

A post by @TVISARL on the Twine sub-forum has me thinking about D&D style dice combat. I threw together a little bit of code to experiment. Results:

> attack kobold
You swing at the kobold! You hit! You do 4 damage! The kobold swings at you! The attack is a miss!

To start off with, here is the room and the two characters in this example.

#room
(room *)
#player
(current player *)
(proper *)
(animate *)
(* is #in #room)
(* HP 10)
(* MaxHP 10)
(* AC 10)
(* ATK 0)
(* DMG 1 d 4 plus 0)
#kobold
(name *)
        kobold
(singleton *)
(animate *)
(* is #in #room)
(* HP 10)
(* MaxHP 10)
(* AC 10)
(* ATK 0)
(* DMG 1 d 4 plus 0)

Lets edit the status bar to show HP of the two actors:

(redraw status bar)
        (status bar @status)
        {
                (#player HP $player_HP)
                (if)  ($player_HP < 1) (then)
                        You are dead!
                        (now) ~(current player $)
                (else)
                        (#player MaxHP $player_MaxHP)
                        (#kobold HP $kobold_HP)
                        (#kobold MaxHP $kobold_MaxHP)
                        You: $player_HP/$player_MaxHP,
                        Kobold: $kobold_HP/$kobold_MaxHP.
                (endif)
        }

Next, some D&D style dice:

(0 d $ plus $Z into $Z)
($X d $Y plus $Z into $W)
        (random from 1 to $Y into $A)
        ($X minus 1 into $B)
        ($B d $Y plus $Z into $C)
        ($A plus $C into $W)

Here’s the player’s attack, it makes the monster attacked hostile if it isn’t already:

(perform [attack $Monster])
        You swing at (the $Monster)!
        (if)
                (#player ATK $ATK)
                ($Monster AC $AC)
                (1 d 20 plus $ATK into $HIT)
                ($HIT > $AC)
        (then)
                You hit!
                (#player DMG $X d $Y plus $Z)
                ($X d $Y plus $Z into $W)
                You do $W damage!
                ($Monster HP $MonsterHP)
                ($MonsterHP minus $W into $NewHP)
                (now) ~($Monster HP $)
                (now) ($Monster HP $NewHP)
        (else)
                You miss!
        (endif)
        (now) ($Monster is hostile)

And here’s how the monster counterattacks:

(on every tick)
        (collect $M)
                ($M is hostile)
                ($M HP $HP)
                ($HP > 0)
        (into $Monsters)
        (attack player $Monsters)
(attack player [])
(attack player [$Monster | $Monsters])
        (The $Monster) swing(s $Monster) at you!
        (if)
                ($Monster ATK $ATK)
                (#player AC $AC)
                (1 d 20 plus $ATK into $HIT)
                ($HIT > $AC)
        (then)
                (It $Monster) hit(s $Monster) you!
                ($Monster DMG $X d $Y plus $Z)
                ($X d $Y plus $Z into $W)
                (It $Monster) (does $Monster) $W damage!
                (#player HP $PlayerHP)
                ($PlayerHP minus $W into $NewHP)
                (now) ~(#player HP $)
                (now) (#player HP $NewHP)
                (if) ($NewHP < 1) (then)
                        You have died!
                (endif)
        (else)
                The attack is a miss!
        (endif)
        (attack player $Monsters)

And that’s it! Obviously the system needs a lot of work (monsters as coded right now can swing at you from any room in the dungeon I believe), but I think there’s potential.

3 Likes

Very cool!

I believe there’s a bug here:

The query to ($M is hostile) will only return once, unless you add an asterisk:

(on every tick)
	(collect $M)
		*($M is hostile)
		($M HP $HP)
		($HP > 0)
	(into $Monsters)
	(attack player $Monsters)

Moving on to subjective matters, I would suggest that if you’re constructing a list of monsters only to traverse the list and visit every monster in turn, it might be easier to skip the list entirely:

(on every tick)
	*($M is hostile)
	($M HP $HP)
	($HP > 0)
	(attack player $M)

(attack player $Monster)
	...
	%% no recursive call at the end

Finally, this will confuse the library, and should be avoided:

	(now) ~(current player $)

Instead, I would handle the death-condition in an (on every tick) rule, defined after the attacking-rule above:

(on every tick)
	(#player HP $player_HP)
	(if) ($player_HP < 1) (then)
		(game over)
	(endif)

(game over status bar)
	(status bar @status) { You are dead! }

Happy hacking!

3 Likes

A first pass at armor.

> attack kobold
You swing a kick at the kobold! You hit! You do 3 damage!
> attack kobold
You swing a fist at the kobold! You hit, but the kobold 's armor absorbs the blow!
> attack kobold
You swing a fist at the kobold! You miss!

Here’s the interesting bit of the code:

(perform [attack $Monster])
        (current player $Player)
        You swing a
        (select) punch (or) fist (or) kick (purely at random)
        at (the $Monster)!
        (collect $X)
                *($Y is #wornby $Monster)
                ($Y AC $X)
        (into $ListOfAC)
        (sum $ListOfAC into $ArmorAC)
        ($Monster AC $MonsterAC)
        ($MonsterAC plus $ArmorAC into $TotalAC)
        ($Player DEX $PlayerDEX)
        (3 d 6 plus $PlayerDEX into $HIT)
        (if) ($HIT > $TotalAC) (then)
                You hit!
                ($Player STR $PlayerSTR)
                (1 d 6 plus $PlayerSTR into $DMG)
                You do $DMG damage!
                ($Monster HP $MonsterHP)
                ($MonsterHP minus $DMG into $NewHP)
                (now) ~($Monster HP $)
                (now) ($Monster HP $NewHP)
        (elseif) ($HIT > $MonsterAC) (then)
                You hit, but (the $Monster)'s armor absorbs the blow!
        (else)
                You miss!
        (endif)
        (now) ($Monster is hostile)

I wanted to report the specific piece of armor that blocked the damage, I just had an idea for how to do that but haven’t had a chance to implement it.

I would like to implement weapons as well. I was going down a rabbit hole trying to figure out the bookkeeping required to record the current weapon and / or introduce a new wielded by relation, but I had an idea for something simpler: out of all your held weapons, randomly attack with one of them. The player will be given a scabbard (container) or weapon strap (I guess it would be a supporter since you put weapons on a strap rather than in one) to put weapons they don’t want to use in / on.

I got weapon-s and armor-mitigation alerts working, below is just a proof of concept without damage being dealt:

> attack brigand
You strike at the brigand with your spear!
You hit!
> attack brigand
You strike at the brigand with your spear!
You hit, but the damage is absorbed by the brigand’s leather armor!
> attack brigand
You strike at the brigand with your spear!
You miss!
> drop spear
Your spear falls to the ground.
> attack brigand
You strike at the brigand with a punch!
You hit, but the damage is absorbed by the brigand’s leather armor!

Here is the relevant code:

(perform [attack $Victim])
        (current player $Player)
        (collect $X)
                *($X is #heldby $Player)
                (weapon $X)
        (into $WeaponsList)
        (if) (empty $WeaponsList) (then)
                ($Weapon = $Player)
        (else)
                (randomly select $Weapon from $WeaponsList)
        (endif)
        You strike at (the $Victim) with
        (if) ($Weapon = $Player) (then)
                (select) your fist! (or) a punch! (or) a kick! (at random)
        (else)
                (the $Weapon)!
        (endif)
        (line)
        (3 d 6 plus 0 into $HIT)
        ($Victim AGI $VictimAGI)
        (if) ($HIT < $VictimAGI) (then)
                You miss!
        (else)
                ($HIT minus $VictimAGI into $NewHIT)
                (collect $X)
                        *($X is #wornby $Victim)
                        ($X AC $)
                (into $ArmorList)
                (collect $Y)
                        *($X is #wornby $Victim)
                        ($X AC $Y)
                (into $ACList)
                (zip $ArmorList and $ACList into $List)
                (if) ($NewHIT hits $List armor $Armor) (then)
                        You hit, but the damage is absorbed by (the $Armor)!
                (else)
                        You hit!
                (endif)
        (endif)
        (now) ($Victim is hostile)

($HIT hits [[$Armor $AC] | $] armor $Armor)
        ($HIT < $AC)
($HIT hits [[$ $AC] | $Rest] armor $Armor)
        ($HIT minus $AC into $NewHIT)
        ($NewHIT hits $Rest armor $Armor)

Here is zip:

(zip [] and $ into [])
(zip $ and [] into [])
(zip [$X|$XS] and [$Y|$YS] into [$Z|$ZS])
        (zip $XS and $YS into $ZS)
        ([$X $Y] = $Z)

For this, you could:

	(collect [$X $Y])
		*($X is #wornby $Victim)
		($X AC $Y)
	(into $List)

which eliminates the need for zip.

1 Like

Are you using d20 or 3d6 for your attack roll?

I’d do some probability calculations before getting too deep into design. D&D mechanics have some strange behaviors; very high defenses exponentially change survivability. A sanity check is good: how many fights are there, how often should the player lose any one fight, and how many times must a player start over before winning the game? A success rate of 95% per battle sounds good until you calculate that 30 battles means the player loses the game 60% of the time through random chance. After all, the monsters only have to win once.

1 Like

I’m not trying to be contradictory, but I’m confused by your math. How is it a 60% chance of losing in 30 battles? Are you saying that 60 battles would make it a 120% chance of losing? Wouldn’t it it still a 5% chance of losing? It’s not like the chances increase with each subsequent battle. Just like if you flip a coin 100 times in a row, the chance of getting heads or tails is still 50% on the next coin toss.

What I mean is a binomial distribution. If you are unfamiliar with that, it is not the odds of winning a single battle, but the odds of winning the war. To put it another way: if you flipped a coin 50 times, what are the odds that it comes up tails exactly once over that time? As you can imagine, while the odds of a single coin flip are 50/50, the odds of flipping 50 times and only getting tails once is very small.

The formula looks something like this:

(n!) / k!(n-k)! * p^k * (1-p)^(n-k)

Where:
n = the number of times you flip the coin
k = the number of “wins”
p = the odds of winning
(1-p) = the odds of losing

Breaking it down:
p^k = the odds we will succeed k times
(1-p)^(n-k) = the odds we will fail every other time
(n!) / k!(n-k)! = the number of ways that combination can happen. We do this bit because, in our case, order doesn’t matter. If we flip 50 times, there are 50 ways the coin can land once on tails (one for each flip). We would also use this calculation for battles because, as I said, the monsters only have to win once to ruin the player’s day (edit: and it doesn’t matter which one). The player has to win every battle to win the war.

If you wanted to calculate the odds of winning at least once, you’d take the odds of winning 1 + odds of winning 2 + odds of winning 3 + … and so on. Fortunately the calculator does this for you.

A binomial calculator can be found here. Turns out my estimate of losing 60% of the time was generous. It’s more like 79%. The odds of winning 30 battles with 0 losses with a 0.95 probability per battle is only 21%. Your player would have to make about 5 attempts to get through all that combat without losing (disregarding the effect of healing potions, etc).

2 Likes

Thank you for that in-depth explanation. I’m not very well versed in higher level equations like that.

That site is also very useful. I’m going to have to bookmark that. Thanks. :smiley:

1 Like

@Hertz I actually did 1d20 for attacks in my first post then switched to 3d6, not for any particular reason. You are absolutely right that if this is to be more than just a coding exercise on my part I need to make some (ideally informed by math) design decisions. One option is to have a loss not be a total loss; either by save points or getting captured and having to break out of monster jail.

EDIT: About save points, obviously the player can save the story file at any point, but I wonder if there’s a clean way to make and store a checkpoint of world state to be restored on game over. If not, maybe there should be?

EDIT2: To explore dice some more, I wanted to create some dice of a kind called “fudge dice” or “fate dice”, where a six sided die has two -1, two 0 and two +1. Unfortunately I re-learned quickly that Dialog does not support negative numbers, so I reformulated the problem to be three-sided dice with values 0, 1 and 2. The distribution of values for a given number of dice are given below.

1 Die: 0x1 1x1 2x1
2 Dice: 0x1 1x2 2x3 3x2 4x1
3 Dice: 0x1 1x2 2x6 3x7 4x6 5x2 6x1
4 Dice: 0x1 1x3 2x9 3x15 4x19 5x15 6x9 7x3 8x1

There are some nice properties of this: the expected value is equal to the number of dice rolled, the distribution is vaguely bell-curvish as the number of dice goes higher, and the chance of “critical hits” / “critical misses” (max value possible / min value possible) are each 1 / (3^D) where D is dice count.

Here’s the simple code:

(0 roll 0)
($D roll $N)
        (random from 0 to 2 into $X)
        ($D minus 1 into $Y)
        ($Y roll $Z)
        ($X plus $Z into $N)

GURPS (Generic Universal Role-Playing System by Steve Jackson Games) uses 3d6 for most everything. I prefer it, because it means the rare results are rare and the usual results are more common. Odds of rolling 3 or 18 on 3d6 are ~1.4%, if I recall correctly.

Let me throw out another way to calculate your system: survivability, in # of rounds.

Say your player has 20 HP, and hits 75% of the time for 8 damage. On average, he’ll do 6 damage per attack (0.75 * 8).

Say your monster has 15 HP, hits 50% of the time for an average of 10 damage. On a typical attack it does (0.5 * 10) 5 damage.

This means it usually takes the player 3 rounds to win (15 enemy ho / 6 average attack = 2.5). The monster needs 4 rounds to win (20 player hp / 5 average monster attack = 4). The player will usually win, but not always.

As the monster’s average damage goes down, either due to player armor or damage reduction, player survivability goes up.

Monster hit % — Rounds survived
60% - 5
70% - 6.6
80% - 10
85% - 13.3
90% - 20
95% - 40

As you can see, extremely high defenses greatly increase survival rate. It’s something to keep in mind.

You know, instead of doing a lot of complicated math, you could probably just run a trial simulation of combat using the game engine. Set up a loop and some simple conditions (if HP < 15%, drink a potion if we have one; if potions = 0, flee; else, best attack). That could give you a pretty good estimate on the number of battles your player will survive out of, say, 500 combats.

Tie them together, perhaps. The checkpoint could be the furthest-in Safe Place the player has found. If the player flees combat, he returns there. (This prevents players from bypassing the intended combat by fleeing into the dungeon.)

1 Like

Using the dice I discussed in my previous post, I came up with what I think is a relatively elegant system. First, showing that it works (I only have the player attack’s side coded so far but the monsters’ attack will work the same way):

> attack kobold
You attack the kobold with your swift blade! It is deflected by the kobold’s shield!
> again
You attack the kobold with your sharp blade! You miss the kobold!
> again
You attack the kobold with your sharp blade! Your strike hits, dealing 2 damage!
> again
You attack the kobold with your swift blade! The kobold is protected from harm by its hard scales!

Here is the perform definition:

(perform [attack $Victim])
	(current player $Player)
	(collect $W)
		*($W is #heldby $Player)
		(weapon $W)
	(into $Weapons)
	(if) (empty $Weapons) (then)
		You swing a (fist $Player) at (the $Victim)!
		($Player Aim $AimDice)
		($Player Force $ForceDice)
	(else)
		(randomly select $Weapon from $Weapons)
		You attack (the $Victim) with (a $Weapon)!
		($Weapon Aim $AimDice)
		($Weapon Force $ForceDice)
	(endif)
	(collect $A)
		*($A is #wornby $Victim)
		(armor $A)
	(into $Armors)
	(length of $Armors into $ArmorCount)
	(random from 0 to $ArmorCount into $Target)
	(if) ($Target = 0) (then)
		($Victim Evasion $Evasion)
		($Victim Mitigation $Mitigation)
	(else)
		(nth $Armors $Target $Armor)
		($Armor Evasion $EvasionDice)
		($Armor Mitigation $MitigationDice)
	(endif)
	(roll $AimDice into $AimRoll)
	(roll $EvasionDice into $EvasionRoll)
	(if) ($AimRoll > $EvasionRoll) (then)
		(roll $ForceDice into $ForceRoll)
		(roll $MitigationDice into $MitigationRoll)
		(if) ($ForceRoll > $MitigationRoll) (then)
			($ForceRoll minus $MitigationRoll into $Damage)
			Your strike hits, dealing $Damage damage!
			($Victim Health $OldHealth)
			(if) ($OldHealth > $Damage) (then)
				($OldHealth minus $Damage into $NewHealth)
				(now) ($Victim Health $NewHealth)
			(else)
				You have killed (the $Victim)!
				(now) ($Victim Health 0)
			(endif)
		(else)
			(if) ($Target = 0) (then)
				(The $Victim) is protected from harm by
				(its $Victim) (skin $Victim)!
			(else)
				It is deflected by (the $Armor)!
			(endif)
		(endif)
	(else)
		You miss (the $Victim)!
	(endif)

To summarize: all combat-capable entities have six stats: Health (HP), Vitality (Max HP), Aim (Dice to roll for chance to hit), Force (Dice to roll for damage), Evasion (Dice to roll opposing Aim to avoid getting hit), and Mitigation (Dice to roll for partial or complete damage reduction). If you have weapons, instead of using your base Aim and Force you use one of your weapon’s values for those stats; if your opponent has armor there is a chance it uses that armor’s values for Evasion and Mitigation instead of its base values.

Here are the object definitions used:

#player
(* is #in #room)
(current player *)
(fist *)
	fist
(skin *)
	skin
(animate *)
(proper *)
(* Health 5)
(* Vitality 5)
(* Aim 1)
(* Force 1)
(* Evasion 3)
(* Mitigation 0)

#sharpblade
(name *)
	your sharp blade
(* is #heldby #player)
(weapon *)
(proper *)
(* Aim 1)
(* Force 3)

#swiftblade
(name *)
	your swift blade
(* is #heldby #player)
(weapon *)
(proper *)
(* Aim 3)
(* Force 1)

#kobold
(name *)
	kobold
(fist *)
	claw
(skin *)
	hard scales
(* is #in #room)
(animate *)
(singleton *)
(* Health 5)
(* Vitality 5)
(* Aim 1)
(* Force 1)
(* Evasion 2)
(* Mitigation 1)

#shield
(name *)
	kobold's shield
(* is #wornby #kobold)
(armor *)
(singleton *)
(* Evasion 0)
(* Mitigation 4)

I don’t yet have a good sense of if this system will be easy to balance / make for fun gameplay, but I’m pretty happy with how simple it is while still allowing for interesting interactions.

Here’s the first version of the code that might possibly be sufficient for use in a game. Most of the new code is the general (let $Aggressor attack $Victim) predicate family, which both the player and monster use. There are also some sensible (prevent [attack $]) rules, and if a ($ gives chase) flag is set for a monster it will chase the player through the dungeon if the player leaves the room.

First, the dice:

(roll 0 into 0)
(roll $D into $N)
	(random from 0 to 2 into $X)
	($D minus 1 into $Y)
	(roll $Y into $Z)
	($X plus $Z into $N)

Next, armor is a kind of wearable and weapons are a kind of item:

(item $Weapon)		*(weapon $Weapon)
(wearable $Armor)	*(armor $Armor)

Here’s the big predicate, which handles combat between any two entities:

(let $Aggressor attack $Victim using aim $AimDice and force $ForceDice)
	(collect $A)
		*($A is #wornby $Victim)
		(armor $A)
	(into $Armors)
	(length of $Armors into $ArmorCount)
	(random from 0 to $ArmorCount into $ArmorIndex)
	(if) ($ArmorIndex = 0) (then)
		($Victim Evasion $EvasionDice)
		($Victim Mitigation $MitigationDice)
	(else)
		(nth $Armors $ArmorIndex $Armor)
		($Armor Evasion $EvasionDice)
		($Armor Mitigation $MitigationDice)
	(endif)
	(roll $AimDice into $AimRoll)
	(roll $EvasionDice into $EvasionRoll)
	(if) ($AimRoll > $EvasionRoll) (then)
		(roll $ForceDice into $ForceRoll)
		(roll $MitigationDice into $MitigationRoll)
		(if) ($ForceRoll > $MitigationRoll) (then)
			($ForceRoll minus $MitigationRoll into $Damage)
			(if) (current player $Aggressor) (then)
				You
			(else)
				(The $Aggressor)
			(endif)
			strike(s $Aggressor), dealing $Damage damage to
			(if) (current player $Victim) (then)
				you!
			(else)
				(the $Victim)!
			(endif)
			($Victim Health $OldHealth)
			(if) ($OldHealth > $Damage) (then)
				($OldHealth minus $Damage into $NewHealth)
				(now) ($Victim Health $NewHealth)
			(else)
				(line)
				(if) (current player $Victim) (then)
					You have died!
				(else)
					(The $Aggressor) (has $Aggressor)
					killed (the $Victim)!
				(endif)
				(now) ($Victim Health 0)
			(endif)
		(else)
			(if) ($ArmorIndex = 0) (then)
				(if) ($MitigationDice = 0) (then)
					The
					(if) (plural $Aggressor) (then)
						attacks are
					(else)
						attack is
					(endif)
					ineffective!
				(else)
					(if) (current player $Victim) (then)
						You
					(else)
						(The $Victim)
					(endif)
					(is $Victim) protected by
					(if) (current player $Victim) (then)
						your
					(else)
						(its $Victim)
					(endif)
					(skin $Victim)!
				(endif)
			(else)
				(if) (plural $Aggressor) (then)
					They are
				(else)
					It is
				(endif)
				deflected by (a $Armor)!
			(endif)
		(endif)
	(else)
		(if) (current player $Aggressor) (then)
			You miss
		(elseif) (plural $Aggressor) (then)
			They miss
		(else)
			(It $Aggressor) misses
		(endif)
		(if) (current player $Victim) (then)
			you!
		(else)
			(the $Victim)!
		(endif)
	(endif)
	(line)
	(if) (current player $Aggressor) ~($Victim is hostile) (then)
		(The $Victim) become(s $Victim) hostile!
		(now) ($Victim is hostile)
	(endif)
	(par)

This is the code that gets called if an attacker is using a weapon…

(let $Aggressor attack $Victim with $Weapon)
	(if) (current player $Aggressor) (then)
		You
	(else)
		(The $Aggressor)
	(endif)
	attack(s $Aggressor)
	(if) (current player $Victim) (then)
		you
	(else)
		(the $Victim)
	(endif)
	with (a $Weapon)!
	(line)
	($Weapon Aim $AimDice)
	($Weapon Force $ForceDice)
	(let $Aggressor attack $Victim using aim $AimDice and force $ForceDice)

…and this is used in the case of an unarmed attack.

(let $Aggressor attack $Victim unarmed)
	(if) (current player $Aggressor) (then)
		You
	(else)
		(The $Aggressor)
	(endif)
	swing(s $Aggressor)
	(if) ~(plural $Aggressor) (then) a (endif)
	(fist $Aggressor) at
	(if) (current player $Victim) (then)
		you!
	(else)
		(the $Victim)!
	(endif)
	(line)
	($Aggressor Aim $AimDice)
	($Aggressor Force $ForceDice)
	(let $Aggressor attack $Victim using aim $AimDice and force $ForceDice)

One of those two actions gets called depending on whether the aggressor has a weapon:

(let $Aggressor attack $Victim)
	(par)
	(collect $W)
		*($W is #heldby $Aggressor)
		(weapon $W)
	(into $Weapons)
	(if) (empty $Weapons) (then)
		(let $Aggressor attack $Victim unarmed)
	(else)
		(randomly select $Weapon from $Weapons)
		(let $Aggressor attack $Victim with $Weapon)
	(endif)

Here’s the player-specific code for attacking (or preventing the attack):

(prevent [attack $Corpse])
	($Corpse Health 0)
	(The $Corpse) is already dead!

(prevent [attack $Object])
	(if) ~($Object Vitality $) (then)
		Attacking (the $Object) would accomplish little.
	(else)
		(fail)
	(endif)

(prevent [attack $Player])
	(current player $Player)
	You shouldn't attack yourself!

(perform [attack $Victim])
	(current player $Player)
	(let $Player attack $Victim)

Here’s the code for monsters attacking and chasing (note, order of predicates matters here, if you swap them I think the monster gets a free attack on you after following you into a room):

(on every tick in $Room)
	(current player $Player)
	*($Aggressor is hostile)
	($Aggressor is in room $Room)
	($Aggressor Health $Health)
	($Health > 0)
	(let $Aggressor attack $Player)

(on every tick in $Room)
	(current player $Player)
	*($Aggressor is hostile)
	~($Aggressor is in room $Room)
	($Aggressor gives chase)
	($Aggressor Health $Health)
	($Health > 0)
	($Aggressor is in room $Elsewhere)
	(first step from $Elsewhere to $Room is $Dir)
	(let $Aggressor go $Dir)

That’s it for the library so far!

Here’s a small world to test things out in:

#room1
(room *)

#room2
(room *)

#door
(name *)
	door
(door *)
(singleton *)
(* is open)
(openable *)
(* is unlocked)
(lockable *)

(from #room1 go #east through #door to #room2)
(from #room2 go #west through #door to #room1)

#player
(* is #in #room1)
(current player *)
(fist *)
	(select) fist (or) punch (or) elbow (or) kick (purely at random)
(skin *)
	tough skin
(animate *)
(proper *)
(* Health 8)
(* Vitality 8)
(* Aim 1)
(* Force 1)
(* Evasion 1)
(* Mitigation 0)

#whip
(name *)
	your whip
(* is #heldby #player)
(weapon *)
(proper *)
(* Aim 2)
(* Force 2)

#leatherjacket
(name *)
	your leather jacket
(* is #wornby #player)
(armor *)
(proper *)
(* Evasion $X)
	(* is #wornby $Actor)
	($Actor Evasion $X)
(* Evasion 0)
(* Mitigation $X)
	(* is #wornby $Actor)
	($Actor Mitigation $Y)
	($Y plus 1 into $X)
(* Mitigation 1)

#goblin
(name *)
	goblin
(* is #in #room1)
(fist *)
	claw
(skin *)
	tough green skin
(* gives chase)
(animate *)
(singleton *)
(* Health 4)
(* Vitality 4)
(* Aim 1)
(* Force 1)
(* Evasion 1)
(* Mitigation 1)

#spear
(name *)
	crude spear
(* is #heldby #goblin)
(weapon *)
(* Aim 2)
(* Force 2)

#shield
(name *)
	iron shield
(* is #wornby #goblin)
(armor *)
(an *)
(* Evasion 0)
(* Mitigation $X)
	(* is #wornby $Actor)
	($Actor Mitigation $Y)
	($Y plus 2 into $X)
(* Mitigation 2)

I kind of like that you can either have armor / weapons give dice roll bonuses to the actor’s base stats or overwrite them (for example with the iron shield, which gives good mitigation but is heavy so it interferes with evasion).

One thing I don’t like is that you can’t yet run through a door and slam it shut behind you to escape a chasing monster; as soon as you go through the door the monster follows. I think I’ll handle this by adding an (after [go $Dir]) to close (and lock if you have the key) the door you just went through; then I’ll let certain monsters have the ability to open doors (and unlock doors if they have the key). That maybe will motivate interesting puzzles, like say going into a jail, stealing the jailer’s keys, hitting the jailer to get them to follow you into the jail cell, then rushing back out and locking them in.

EDIT: Here’s code to attack with a specified weapon:

(prevent [attack $ with $Object])
        ~(weapon $Object)
        (The $Object) isn't a weapon!

(prevent [attack $Corpse with $Weapon])
        ($Corpse Health 0)
        You poke (the $Corpse) with (the $Weapon).
        (It $Corpse) (is $Corpse) definitely dead.

(prevent [attack $Object with $Weapon])
        (if) ~($Object Vitality $) (then)
                Attacking (the $Object) with (the $Weapon) is pointless.
        (else)
                (fail)
        (endif)

(prevent [attack $Player with $Weapon])
        (current player $Player)
        Turning (the $Weapon) on yourself is a bad idea.

(perform [attack $Victim with $Weapon])
        (current player $Player)
        (let $Player attack $Victim with $Weapon)

And attacking unarmed:

(understand [attack/break/smash/hit/slap/kick/fight/torture/wreck/crack/destroy/murder/kill/punch/thump | $Words] as [attack $Victim unarmed])
        (split $Words by [unarmed] into $VictimWords and [])
        *(understand $VictimWords as non-all object $Victim)

(prevent [attack $Corpse unarmed])
        ($Corpse Health 0)
        There's no need; (the $Corpse) is dead.

(prevent [attack $Object unarmed])
        (if) ~($Object Vitality $) (then)
                There's no sense in attacking (the $Object).
        (else)
                (fail)
        (endif)

(prevent [attack $Player unarmed])
        (current player $Player)
        Stop hitting yourself!

(perform [attack $Victim unarmed])
        (current player $Player)
        (let $Player attack $Victim unarmed)

Edit2: Here’s the code to close and lock doors behind you:

(after [go $Dir])
        (current room $NewRoom)
        (from $ go $Dir through $Door to $NewRoom)
        (openable $Door)
        Would you like to close (the $Door) behind you?
        (line)
        (if) (yesno) (then)
                (try [close $Door])
        (endif)
        (if)
                ($Door is closed)
                (current player $Player)
                ($Key unlocks $Door)
                ($Key is #heldby $Player)
        (then)
                Would you like to lock (the $Door) behind you?
                (line)
                (if) (yesno) (then)
                        (try [lock $Door])
                (endif)
        (endif)

Here’s code for having monsters with ($ can open doors) that are chasing the player open unlocked doors.

(unvisited and closed okay first step from $A to $B is $Dir)
        (unvisited and closed okay find paths starting at $A)
        (reconstruct first hop $Dir from $A to $B)
(unvisited and closed okay find paths starting at $Start)
        (exhaust) {
                *(room $R)
                (now) ~(last hop before $R is $)
        }
        (now) (last hop before $Start is [])
        (exhaust) {
                (unvisited and closed okay elaborate paths from [$Start])
        }
(unvisited and closed okay elaborate paths from $RoomList)
        (nonempty $RoomList)
        (collect $R)
                *($Here is one of $RoomList)
                {
                        *(from $Here go $ to $R)
                        (room $R)
                (or)
                        *(from $Here through $Door to $R)
                        ~($Door is locked)
                }
                ~(last hop before $R is $)
                (now) (last hop before $R is $Here)
        (into $NextGen)
        (unvisited and closed okay elaborate paths from $NextGen)
(on every tick in $Room)
        *($Aggressor is hostile)
        ~($Aggressor is in room $Room)
        ($Aggressor is in room $Elsewhere)
        ($Aggressor gives chase)
        ($Aggressor can open doors)
        ($Aggressor Health $Health)
        ($Health > 0)
        (unvisited and closed okay first step from $Elsewhere to $Room is $Dir)
        (from $Elsewhere go $Dir through $Door to $Next)
        ($Door is closed)
        (openable $Door)
        ~($Door is locked)
        (now) ($Door is open)
        (if) ($Room = $Next) (then)
                (opposite of $Dir is $ReverseDir)
                (The $Door) opens from (the $ReverseDir).
        (endif)

Code to make monsters open locks if they are holding the right keys would be possible with similar code; for now I’ve decided not to code that.