Skip to content

Energy and Speed

Joachim de Groot edited this page Sep 26, 2026 · 1 revision

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.


The one number that matters: ttmb

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.


How the scheduler spends it

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 them

Get() 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.


GetSpeed(): what changes it in play

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.

Hunger

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.

Burden

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.

Modifiers

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 remove

Two 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.


What the player sees

The status line prints speed as a rate, inverted from the internal duration (creature/creature.cpp:687):

100000 / GetSpeed(),      // the "Sp:" figure

ttmb 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.


The two dead arguments

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.

base_speed — the first argument

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.

attack_energy — the third argument

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().

A latent trap

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.


What content actually uses

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.


Non-creatures on the same clock

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.


If this were to be fixed

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:

  1. Retire or honour base_speed. Either delete the first argument of :Basic() - 104 call sites, mechanical — or decide that it, and not move_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.
  2. 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.
  3. 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 flat GetSpeed().

(1) and (2) need no save version bump. (3) does, if a cooldown or a partial-turn remainder has to persist.


Where it lives

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