My personal site
by Karl Jan Clinckspoor
I decided to peek into the source code of Exult and figure out how combat works. Here’s the result of quite a few hours of code reading, note writing, source code manipulation and running some simulations. I hope this brings you a bit of satisfaction, and I’m certain you’ll read some parts and think “Oh, so that’s why this happens!”
A few disclaimers before we start:
With that out the way, let’s start.
Schedule
, which controls every action an npc (Actor
) performs. Other
schedules include baking, serving food, etc. Aggressive actions do not happen outside of combat (
save for usecode).now_what
from its schedule.initial
, approach
, strike
,
fire
, wait_return
.approach
state (which I’ll call a “turn”), an internal counter
called dex_points
increases based on the npc’s dex attribute. When this reaches dex_to_attack
,
which is 30, it can change its state from approach
to strike
or fire
. Checks are performed
to see if there’s a line of sight or if the npc should move. The excess value is carried over between
iterations, so an npc with 29 DEX, after 2 turns, has 29+29+29(-30) = 28 points.attval
is compared to the
defender’s defval
.
attval
is altered by the game difficulty (up to +/- 6, or 2 times the difficulty), the
weapon type (good thrown, poor thrown, ranged) and the range in the case of thrown weapons (
poor thrown removes 1 from attval per distance unit, good thrown removes 1/2).defval
is only affected by the Protection spell status, which increases defval
by 3.(15 + defval - attval + 1)/30
. However, there’s always a 1/30
chance of hitting and a 1/30 chance of missing.tournament flag
on, which hands off the handling of death/defeat to the usecode
machine, meaning they receive special treatment.1 + log2(total exp / 50)
. Every level up, 3 training
points are added.You can enable a debug feature in Exult called combat_trace
to print out some additional
information about what’s going on in combat to what’s called standard out
, or stdout
. If you run
exult from a console, it’ll print out to that, or it’ll be in the file called stdout.txt
in the Exult folder. To enable this, modify or include these lines in exult.cfg
:
<debug>
<trace>
<combat>
yes
</combat>
</trace>
</debug>
If you want, you can also enable the option called show_hits
in the game’s options. This shows
how many hit points an npc lost, and how many are remaining.
Dexterity controls how quickly an actor can attack. A dex value of 30 means they can
attack every other turn. If the npc’s dex is 29, the actor only attacks after 3 initial turns, and
then the accumulated dex_points
lets it attack every other turn. Only after 59 turns its
excess points are spent and the npc will have to build up points for 3 turns again, and attack on
turn 62. If the npc’s dex is 15, this means it will always attack once every 3 turns. A dex
attribute of 1 is comical. The npc will stare at the target for quite a while until they attempt to
raise their arms to attack.
I’ve… spent way more time trying to illustrate this relatively inconsequential aspect of combat than I should have. You can skip to the next section of you want. Here’s a few graphs:
How many attacks can be performed given a fixed number of turns. Notice how, for short battles, the number of attacks is the same for relatively wide ranges of dex values. Only for really long battles do we see a clear trend favoring higher dex values.
Here we can see how many turns are required to perform a specific number of attacks. Notice the logarithmic scale in the y axis. For absurdly low dex values, it takes ages to perform an attack. Note also how 30 dex is favored, especially for shorter battles.
Last we can see a visualization of attack frequency and the role of the leftover dex_points
on
each turn. Here, an attack being performed in a turn is indicated by a peak. The closer the peaks,
the faster are the attacks coming. Notice how sometimes there’s 3 turn gaps, then 2 turn gaps, then
1 turn gaps and finally at 30 dex, there are no gaps — an attack goes through every other turn.
You might be wondering how long a turn lasts. To test this, I went to the Trinsic stables and modified the Avatar’s stats to 1 STR and varying DEX values. Then, I modified the game engine to print out the current state and the game’s tick number at that instant (1 tick is 1 millisecond since the game started running). I then attacked the horse and noted down how long between I started “charging up” and I finally performed the attack animation. The speed the game runs depends on the set fps, so I did vary the fps values also. Here’s the results.
First, we see how long it takes to start a strike. This is calculated from the moment I double click the horse to induce the attack (and initiative starts building up) to the moment the Avatar decides to hit the enemy. As expected, this time depends on the fps and roughly half the fps leads to a delay twice as long. I’ve measured these a few times each and noticed there’s a variation in how long it takes to hit the target, and this increases at lower dex and lower fps. Nevertheless, the highest standard deviation I found was 80 ticks, or 0.08 seconds. Imperceptible in normal circumstances.
If we take the timing at 30 dex and consider this 2 turns, we can rescale this plot and compare to our previous result. This is what we get. Note how the actual number of turns to attack varies between fps. I don’t know the exact cause of this, but I guess my assumptions might be too simplistic.
For combatants with equal combat stats, the attacker is always slightly biased towards hitting. To better visualize how this works, and to show how bias affects the probabilities, I’ve prepared some hit probability maps containing theoretical values of hit probabilities, from game difficulty -3 to +3. These consider you’re attacking (i.e. party member) a random monster.
We can see when difficult is 0, the 50% hit probability is slightly offset downwards, indicating the green-yellowish area (>50% hit prob) is bigger. Then, as you go from -3 to +3 (easy to hard), the yellow region shrinks considerably. Since the bias is symmetrical, if you want to know how probably it is for a monster to attack your party, you just need to consider the opposite sign graphs (i.e. a monster to-hit map at the hardest difficulty is the -3 map, and so on.)
You can calmly walk around and talk to npcs with hostile alignments provided none of you enter the combat state. The instant you press ‘C’ however, you’ll attack. For example, Iriale Silvermist, located in the Fellowship retreat, is Evil but peaceful (for some time), as well as some npcs in New Magincia (Robin, Battles and Leavell).
You can also do some shenanigans and try to change the Avatar’s alignment to Evil
, either with
Exult Studio (I failed at that) or modifying the variable using a debugger, and see if you can
complete the game.
The parry
and stunned
states aren’t used anywhere in the code. Neither is the attack mode flank
or defend
. berserk
is the same as random
, but prevents npc from fleeing.
Enemies don’t require a spellbook to cast spells. Rather, they have spells are weapons they can fire a limited number of at enemies. If you manage to get one, you can use it too. This makes spellcasters especially dangerous, since they have no mana requirements. When killed, these items are removed from them so you can’t access them natually. However, I distinctly remember a few jesters around the castle of the white dragon that spawned with death bolts or something like that, and which I looted and used for a bit. For example, Aram-dol has two spells equipped, one with 99 uses and another with 2. Good luck surviving 99 casts of some spell. If you have Exult Studio equipped, you can open an npc’s inventory gump and see the spells. Here’s an example of a mage located at an island to the south of Trinsic.
An npc’s level is calculated by
1 + log2(total exp / 50)
(see actors.h:490
). If you want to calculate the exp for a specific
level, you can use 25 * 2 ^ level
. Every level gives the npc 3 training points. I prepared two
graphs with this info:
I’ll preface and say compiling exult was a bit of a pain, because I can’t use Visual Studio (disk space concerns). If you can, then it’s by far the easiest method to compile Exult. I managed to build it in Windows (msys2), in WSL (so technically Linux, but under Windows), in Windows (Visual Studio) and Linux.
First, follow the instructions in README.win32, README.MacOSX or INSTALL (Linux). If you can’t get it to work, send me a message and I’ll try to help you. I have all my steps recorded, but there’s a few aspects I don’t understand, so I won’t post here, lest it confuses people.
tags: games - ultima - c++