-
Notifications
You must be signed in to change notification settings - Fork 1
Energy and Speed
How often a creature acts. There is exactly one number that decides
this, and :Basic() takes three that look like it — the other two are
rolled, stored, saved to your character file, and never read by anything.
The live one is ttmb, and the first thing to know about it is that it is
a duration, not a rate. It is how many time units one action costs.
Higher is slower. A creature with ttmb 700 acts more often than one
with 1500.
ttmb lives on XObject (engine/xobject.h:163) — "basis of time to
move" — with ttm, the countdown to this object's next turn.
For a creature it is set once, at construction, from the second
argument of :Basic():
// creature/anycr.cpp:89
ttmb = cr->move_energy.Throw();
ttm = ttmb;For the hero it is set from the race table, through a named setter:
-- world/hero.lua:223
SetMoveEnergy(hero, race.speed)world/hero.lua:33 describes the field correctly — "how long a turn
takes; 0d0+1000 is ordinary". All seven races are 1000.
XScheduler (engine/xscheduler.h) is a ring of XSCHEDULER_STEPS_AHEAD
= 100 buckets, each one XSCHEDULER_TIME_SLICE = 100 time units wide.
Place() works out how far ahead an object belongs and moves it there,
charging it for the trip:
shift = sp->ttm / XSCHEDULER_TIME_SLICE + 1; // buckets to skip
sp->ttm -= shift * XSCHEDULER_TIME_SLICE; // pay for themGet() walks the ring. An object whose ttm has gone negative is
returned — it acts. An object still in credit is re-placed further ahead.
After acting, XCreature::Run() buys the next turn:
// creature/creature.cpp:474
if (ttm <= 0) {
ttm += GetSpeed();
}So the cadence of a creature is ttmb / 100 buckets. At ttmb 1000 that
is ten buckets between turns; at 700, seven; at 1500, fifteen.
In game time, XTime::RunTime() is called once per bucket the ring
advances past and adds 17 seconds (game/xtime.cpp:117). One bucket ≈ 17
s, so an ordinary action at ttmb 1000 takes about 170 seconds of
game time, and a day is roughly 500 actions.
The ring is 100 buckets, so a single placement can skip at most 99 of
them; anything slower than 9900 units is carried forward in stages rather
than scheduled in one go. Nothing in the world is near that — but
XLocation sets ttmb to 1000000, and that is how it gets away with it.
Run() does not add ttmb, it adds GetSpeed()
(creature/creature.cpp:798), and that is where every dynamic effect on
speed lives. It starts from ttmb and multiplies.
Because ttmb is a duration, a multiplier above 1 is a penalty.
if (nutrio < base_nutrio * 8 && nutrio > base_nutrio * 4) {
speed = (int)(speed * 0.92);
} else if (nutrio < base_nutrio * 2) {
speed = (int)(speed * 1.2);
} else if (nutrio > base_nutrio * 12) {
speed = (int)(speed * 1.1);
}Laid against the hunger words on the status line
(creature/creature.cpp:703-726):
nutrio |
Status line shows | Speed |
|---|---|---|
| > 18× | overfed! |
×1.1 — slower |
| > 14× | bloated |
×1.1 — slower |
| > 12× | satiated |
×1.1 — slower |
| > 10×, ≤ 12× | satiated |
unchanged |
| > 8×, ≤ 10× | (nothing) | unchanged |
| > 6×, < 8× | hungry |
×0.92 — faster |
| > 4×, ≤ 6× | very hungry |
×0.92 — faster |
| ≥ 2×, ≤ 4× | weak |
unchanged |
| > 1×, < 2× | weak |
×1.2 — slower |
| ≤ 1× | dying! |
×1.2 — slower |
Two things fall out of this that are worth knowing before touching it.
Being hungry makes you about 8% faster — the lean-and-quick idea, and
it applies to very hungry as well, which is the tier that interrupts
your actions. And the boundaries do not match the words: satiated spans
10×–14×, but only its upper half is slowed, so the same word on screen
means two different speeds.
int str = stats->Get(XStats::STR);
if (carried_weight >= str * 120 && carried_weight < str * 200) {
speed = (int)(speed * 1.1);
} else if (carried_weight >= str * 200 && carried_weight < str * 280) {
speed = (int)(speed * 1.3);
} else if (carried_weight >= str * 280) {
speed = (int)(speed * 2);
}The thresholds are the carry-state boundaries from
XCreature::CarryValue() (creature/creature.cpp:2030): ×1.1 at
BURDENED, ×1.3 at STRAINED, ×2 at OVERBURDEN or worse.
One discrepancy: CarryValue() computes its limits from
stats->Get(STR) + added_stats.Get(STR), and this does not add
added_stats. A strength buff raises what you may carry without raising
the weight at which you start slowing down.
Slower is the content-facing knob, and it is the only one that touches
ttmb itself rather than the multiplier:
// magic/modifiers.cpp:274, :295
owner->ttmb += row->slower; // on set
owner->ttmb -= row->slower; // on removeTwo modifiers use it (world/modifiers.lua): boost_speed at
Slower = -300 and slowness at Slower = 300. On an ordinary creature
that is 1000 → 700 and 1000 → 1300 — a 43% gain and a 23% loss in actions
per unit time.
The status line prints speed as a rate, inverted from the internal
duration (creature/creature.cpp:687):
100000 / GetSpeed(), // the "Sp:" figurettmb 1000 shows Sp:100. Overburdened, it shows Sp:50. So the number
on screen behaves the way a player expects — bigger is faster — while the
number in the code is the opposite. This is the single most reliable
source of confusion in this system, including in the previous version of
this document.
MonsterBuilder::Basic() (creature/anycr.cpp:378) takes
:Basic(speed, move_energy, attack_energy, size, weight)
and the constructor at creature/anycr.cpp:92-94 turns three of them into
plain ints:
attack_energy = cr->attack_energy.Throw();
move_energy = cr->move_energy.Throw();
base_speed = cr->speed.Throw();All three are serialized together (creature/creature.h:650). None of
the three is ever read. Only line 89's separate roll into ttmb does
anything.
Dead. And it is the one that carries the design intent: 26 distinct values across the world's creatures, evidently hand-tuned, all of them inert.
It also reads as a rate — low is slow, high is fast — which is the
opposite convention to the argument beside it. The two neighbouring
arguments of :Basic() are in opposite units.
The clearest demonstration of what this costs:
| Creature | 1st arg (dead) | 2nd arg (live) | Actual speed |
|---|---|---|---|
| gray ooze | 1d10+40 |
0d0+1000 |
the hero's |
| Gekta | 1d10+200 |
0d0+1000 |
the hero's |
A five-to-one intended spread, and an ooze that keeps pace with a running adventurer.
Dead, and covered in more detail in #14, which needs the same machinery.
47 of the 104 creatures give it a value different from their
move_energy, so a separate attack speed was clearly meant. Nothing reads
it, and XCreature::MeleeAttack() separately computes a per-blow time
cost from the weapon's use time and then throws that away too: every
action in the game is charged the same flat ttm += GetSpeed().
Lines 89 and 93 are two independent rolls of the same dice. Every
creature in the world writes move_energy as 0d0+N, which has no random
part, so the live ttmb and the stored move_energy always agree today.
Write 1d100+900 there and they would not.
104 creature :Basic() calls. Distribution of the live argument:
move_energy |
Creatures | Relative to the hero |
|---|---|---|
0d0+700 |
1 | 43% more actions |
0d0+800 |
2 | 25% more |
0d0+900 |
7 | 11% more |
0d0+1000 |
93 | the same |
0d0+1500 |
1 | 33% fewer |
So in practice the speed system has one setting and eleven exceptions:
-
Fastest: Roderik,
0d0+700 - Faster: Ahkulan, Torin, the high priest, both bats, three fiends, the volcano's inhabitant — 800 or 900
-
Slowest: Beelzevile,
0d0+1500— the only creature in the game slower than the player
Everything else, from a rat to a dragon, moves at exactly the hero's pace.
ttm/ttmb are XObject fields, so anything scheduled uses them:
| Object | ttmb |
What the tick does |
|---|---|---|
XLocation |
1000000, or whatever CreateTimerEvent() sets |
runs the location's Lua event (game/location.cpp:888) |
XCorpse |
1000 | one step of decay (item/xcorpse.cpp:72) |
XGenerator |
its run_time
|
one repopulation pass (engine/xgen.h:42) |
XLocation::CreateTimerEvent(event, ttm) is the Lua-facing way to ask for
a periodic callback, and it sets both fields from its argument.
Nothing here is broken in the sense of crashing; the system works, it is just much smaller than it looks. Three separable pieces of work:
-
Retire or honour
base_speed. Either delete the first argument of:Basic()- 104 call sites, mechanical — or decide that it, and notmove_energy, is the speed, convert its 26 values from rate to duration, and drop the second argument instead. The second is a world-wide rebalance, because it would give 30 creatures a speed they have never had. - Spend the differences that already exist. The eleven creatures that are not 1000 are the only speed variety in the game. If more is wanted, it costs nothing but content edits.
-
Per-action costs, if attack speed is to mean anything - see #14.
MeleeAttack()already computes the number;Run()would have to charge it instead of the flatGetSpeed().
(1) and (2) need no save version bump. (3) does, if a cooldown or a partial-turn remainder has to persist.
| What | Where |
|---|---|
ttm / ttmb
|
engine/xobject.h:163 |
| the ring, slice and step constants | engine/xscheduler.h:33-34 |
| placement and dispatch | engine/xscheduler.cpp:38-138 |
| the turn charge | creature/creature.cpp:474 |
| hunger and burden multipliers | creature/creature.cpp:798 |
the Sp: display |
creature/creature.cpp:687 |
| the three rolls, live and dead | creature/anycr.cpp:89-94 |
:Basic() |
creature/anycr.cpp:378 |
SetMoveEnergy for the hero |
creature/creature.h:419, lua/api_actor.cpp:337
|
Slower |
magic/modifiers.cpp:274, magic/modifiers.h:91
|
| game clock | game/xtime.cpp:116 |
| race speeds | world/hero.lua:33-100 |