EXPcaliburINSIDE THE MACHINE

05 / THE WORLD

How actions become a score

The bot can ask to move and aim. The game decides what really happens. If a fight produces a gem, the knight still has to pick it up. That pickup, not the kill, adds EXP.

Starts with: Actions, current effects, and live entity state.

Ends with: Combat, pickups, level changes, and EXP.

Question: Which state transition actually banks value?

Follow one spider all the way to the score

By the end of the VM chapter, the bot could ask to move and aim. Now the game applies its own rules: it limits movement, checks walls, moves enemies, resolves attacks, and updates gems. None of those steps adds EXP just because the bot asked.

The scoring loop rewards completing the whole cycleThe scoring loop rewards completing the whole cycleEngageMove into useful range;keep aim on a target.DefeatCooldown and localgeometry turn damageinto kills.DropA gem holds potentialEXP; the score has notchanged.CollectRoute near the gem;magnet motion reachesthe pickup threshold.LevelBank EXP; crossing athreshold raises statsand fully heals.Survive longerMore remaining timecreates more chances toearn and collect.time + routingfeedbackA damage increase helps only if the rest of this loop still works. A high kill count alone is insufficient.
The arena is the converter between decisions and score. The return arrow matters: a level gain heals and improves the knight, which changes what later fights can offer. View diagram at full size

Let's follow one simple spider with 14 HP. A level-one hit deals 12 damage and leaves 2 HP. A later hit kills it. The game then tries to create a gem worth 3 EXP. That can fail if every gem slot is taken, and even a real gem can expire before pickup.

Does this spider become EXP?

Step through one example. Change the two choices to see why the knight might not get EXP.

Is there a free gem slot when the spider dies?
If a gem appears, what happens later?
SPIDER HP14
KILLS0
TOTAL EXP30
KNIGHTLv 1 · 46 HP

The art comes from the game. The spacing does not show exact arena positions. This example shows one possible path and omits time between steps.

The board starts at 30 EXP and 46 HP to make the level change visible. These are teaching values, not a recorded match. The stages can span many ticks, because the next swing must wait and the gem needs time to travel. Only the event order and the rules behind each branch come from the code.

The second hit changes the enemy's state and the kill count. Then the game tries to create a gem. Notice that there is still no call to add_exp here:

RUST crates/engine/src/combat.rs · source lines 378–384
            if died {
                self.enemies[i].alive = false;
                self.alive_count = self.alive_count.saturating_sub(1);
                self.hero.kills += 1;
                let gy = ey - fxmul(radius, C_0_4);
                self.spawn_gem(ex, gy, exp_value);
                self.events.push(Event::Kill {

spawn_gem needs a free slot. If all 1,500 gem slots are full, it returns without a gem. That is the first branch on the board:

RUST crates/engine/src/combat.rs · source lines 395–410
    pub(crate) fn spawn_gem(&mut self, x: Fx, y: Fx, value: u32) {
        let slot = match self.free_gem_slot() {
            Some(s) => s,
            None => {
                #[cfg(any(test, feature = "tuning"))]
                {
                    self.economy_audit.rejected_gems =
                        self.economy_audit.rejected_gems.saturating_add(1);
                    self.economy_audit.rejected_value = self
                        .economy_audit
                        .rejected_value
                        .saturating_add(u64::from(value));
                }
                return;
            }
        };

If the gem exists, update_gems moves it and checks whether the living knight can pull it. Entering the knight's pickup radius starts the pull. It does not add EXP yet. Collection needs the gem to reach the smaller collection distance:

RUST crates/engine/src/combat.rs · source lines 516–527
            let mut collected = false;
            if self.hero.alive {
                let d = fx_dist(g.x, g.y, px, py);
                if d <= pr {
                    let ang = fx_atan2(py - g.y, px - g.x);
                    let mv = mul_dt(from_int(magnet_speed));
                    g.x += fxmul(fx_cos(ang), mv);
                    g.y += fxmul(fx_sin(ang), mv);
                    let d2 = fx_dist(g.x, g.y, px, py);
                    if d2 <= from_int(PLAYER_RADIUS) + from_int(10) {
                        collected = true;
                    }

Only collection calls add_exp. An expired gem simply disappears. The pickup event then records the EXP that actually reached the knight's total:

RUST crates/engine/src/combat.rs · source lines 565–568
                let kind = g.kind;
                let from_level = self.hero.level;
                let gained = self.tables.add_exp(&mut self.hero, value);
                self.events.push(Event::Pickup {

After the kill, the score path can break in two places: a full gem pool or an expired gem. A kill alone adds no EXP. The drop has to make the trip.

A kill can create a gem; pickup adds EXPA kill can create a gem; pickup adds EXP1HIT2DROP3COLLECT14 HP → 2 HP12 damage, no EXP yetSecond hit defeats itGem worth 3 on ground30 → 33 total EXPLevel 2: 46 → 118 HPA full gem pool or missed pickup can break the chain before EXP changes.
Follow the spider's value until it becomes the knight's score. The first hit changes enemy HP. The second can create a gem. Pickup is the moment total EXP changes; in this teaching example, it also crosses a level threshold and heals the knight. View diagram at full size

Fixed time, fixed arithmetic

The game runs at 60 simulation ticks per second, for at most 36,000 ticks. Positions, speeds, distances, and many combat values are stored as plain integers. This is Q16.16 fixed-point, where the low 16 bits hold the fraction. To read a value, divide the stored integer by 65,536. For example, 229,376 represents 3.5.

To multiply two Q16.16 numbers, the game computes a 64-bit product and shifts it right by 16 bits. Division scales the numerator first and then drops the leftover fraction toward zero. For angles, it uses a saved table and fixed integer rotations called CORDIC. Because rounding can shift a position or change a collision, a bot simulator needs the same integer rules for exact results.

RUST crates/common/src/fixed.rs · source lines 25–39
#[inline]
pub fn fxmul(a: Fx, b: Fx) -> Fx {
    ((a as i64 * b as i64) >> 16) as i32
}

#[inline]
pub fn fxdiv(a: Fx, b: Fx) -> Fx {
    if b == 0 {
        0
    } else {
        (((a as i64) << 16) / (b as i64)) as i32
    }
}

Q16.16: the integer underneath the coordinate

Integer arithmetic alone does not make runs repeatable. The game must also keep the order of operations, random draws, enemy visits, and tie breaks fixed. Change any of them and you can change the rounding or which enemy gets a free slot. Same seed, different order, different match.

The map is shared data

The world is a 96-by-96 grid of 64-pixel tiles, or 6,144 pixels on each side. The knight starts near the center at (3104, 3104). The engine includes the generated arena_gen.rs arrays from the client's map generator, so the headless simulator uses the terrain players see.

Tile occupancy is not the whole collision story, though. Water, trees, and building bases block a full tile. A rock blocks only a smaller circle, sized by its variant and centered slightly low. Bushes slow movement to about 0.62 of the ordinary rate. Tiles outside the map are solid.

The real arena arrays · 96 × 96 tiles · 6,144 × 6,144 pixels
Terrain overview of the repository's arena, with water, trees, rocks, bushes, and the knight's central starting location

Generated from the checked-in terrain, solid, and bush arrays. Rocks are shown by tile here; their actual collision uses a smaller circular footprint. This is not a screenshot of the game renderer.

A movement request gives the host a direction, not an instant destination. The host first shrinks the step to fit this tick's remaining movement allowance, slowing it further if the knight starts in a bush. Then come the walls. The game checks the knight's 17-pixel collision circle against the map. It tries X first, then tries Y from whichever X position was accepted.

One diagonal request, two ways to slide along a wall

The dotted gold arrow is what the bot requested after the host's speed limit. The solid blue arrow is where the knight actually goes. These are simple positions to show the rule, not coordinates from a recorded match.

Wall blocks X

wallstartend×

X check: blocked. Y check: clear from the old X. The knight slides upward.

Wall blocks Y

wallstartend×

X check: clear. Y check: blocked from the new X. The knight slides right.

A blocked direction moves the knight zero pixels, but the host still charges the allowed step to this tick's movement budget. Bumping a wall repeatedly is not free movement.

The movement function makes this order visible. Both nx and ny start at the current position. But the Y test uses nx, which may already hold the new X. That one variable is why the order matters:

RUST crates/engine/src/map.rs · source lines 149–159
    pub fn resolve_move(&self, x: Fx, y: Fx, r: Fx, dx: Fx, dy: Fx) -> (Fx, Fx) {
        let mut nx = x;
        let mut ny = y;
        if dx != 0 && !self.blocked_for_circle(x + dx, y, r) {
            nx = x + dx;
        }
        if dy != 0 && !self.blocked_for_circle(nx, y + dy, r) {
            ny = y + dy;
        }
        (nx, ny)
    }

The speed limit and bush slowdown happen before this function. Afterward, the host charges the budget for the requested distance before wall checks. As a result, a wall can make the real move shorter than the charge. The difference is not refunded. Later combat and pickup checks use the resulting position.

Spatial indexing and stable order

To avoid examining every enemy, the game first looks only in nearby 64-pixel map squares. An enemy found there is only a candidate. The game still checks its exact distance and attack angle before any hit.

Each square keeps a list of enemy slots: a shortlist, not a hit list. Within a square, enemies come back in increasing slot order, and nearby squares are visited in fixed order. The lists save search work, and the fixed order keeps the choices repeatable.

For example, the search can reach a spider's square while the spider stays just outside sword reach. That spider is examined as a candidate but takes no damage.

The fixed base pools have 780 enemy slots, 1,500 gem slots, and 320 projectile slots. A full pool accepts nothing more.

If all 1,500 gem slots are full, a defeated enemy's gem is simply not created. The game's audit can record that lost value. Even loot has a capacity limit: a dead enemy does not always mean an available gem.

Growth is a feedback loop

The knight's level selects a row of base statistics. At level one, that row gives 100 maximum HP, 12 damage, and 212 pixels per second of movement. It also sets the reach at 94 pixels and the knight's pickup radius at 74 pixels. Later rows improve those numbers, up to their caps. So every collected gem can help with the next gem. That feedback loop drives a high score.

How a pickup can help the next fight

FightA successful attack can defeat an enemy and create a gem.
CollectA pickup adds EXP. A level change can fully heal the knight.
Fight betterNew stats may make the next encounter easier.

Enemies also get stronger as time passes.

Base statistic Level dependence before fixed-point rounding
Maximum HP 100 + 18 × (L − 1)
Damage 12 × (1 + 0.12 × (L − 1))
Movement speed min(212 + 2 × (L − 1), 285)
Reach min(94 + 1.4 × (L − 1), 150)
Full swing arc min(96 + 0.8 × (L − 1), 165) degrees
Knockback 360 + 5 × (L − 1)
Pickup radius min(74 + 1.6 × (L − 1), 165)

The level cap is 128. The attack cooldown comes from an exponentially decreasing base. The game converts it to integer ticks and never lets it fall below 10. The engine builds these tables once per world instead of re-evaluating floating-point formulas every tick.

The EXP for the next level uses an integer square root matching floor(12 L^1.5 + 10 L + 10). A cumulative table then maps total EXP to the highest level reached. Most importantly, a level increase updates the knight's statistics and fully heals it. The code calls the knight's record hero, so this step lives in hero.rs:

RUST crates/engine/src/hero.rs · source lines 173–186
    pub fn add_exp(&self, h: &mut Hero, value: u32) -> u16 {
        h.total_exp += value as u64;
        let new_level = self.level_for_total(h.total_exp);
        if new_level > h.level {
            let gained = new_level - h.level;
            self.apply_level(h, new_level);
            h.hp = h.max_hp;
            gained
        } else {
            0
        }
    }

That heal connects gem collection to survival. Kills without pickups do not advance the loop. That means a bot can have a large kill count and still miss the level-up that would have saved it.

Walk through the level-up code

In add_exp, the first line raises total_exp. The next line looks up the level for that new total. Only if that level is higher does the function apply the new statistics and set hp = max_hp. It returns the number of levels gained for the caller's event record.

Start at level one with 46 HP and 30 total EXP. A pickup worth 3 raises total EXP to 33, past the first threshold of 32, so the knight reaches level two. Then apply_level raises maximum HP from 100 to 118 and updates the other level-dependent statistics. The next assignment fills current HP to 118.

A pickup worth 1 instead would leave total EXP at 31. No level change, no heal. That single point can decide whether the knight survives the next hit.

This is why heading toward a gem can keep the knight alive. Its pickup may cross a level threshold and trigger a full heal. A bot that always flees when hurt can miss a heal that was sitting nearby.

The swarm scales too

The knight is not the only one getting stronger. When an ordinary enemy spawns, its health and damage depend on elapsed minutes and, more mildly, on knight level. Its speed also rises with time, up to a cap, and its EXP value rises with time without a cap. With a squared-time term in both formulas, late enemies can be much stronger than early ones.

For ordinary health, the conceptual multiplier is (1 + 0.85m + 0.11m²) × (1 + 0.025(L − 1)). Damage uses (1 + 0.5m + 0.03m²) × (1 + 0.015(L − 1)). They describe fixed-point code, whose integer operation order decides the exact values.

For a level-one knight, the ordinary enemy health multiplier is 1 at the start but about 3.14 after two minutes. In other words, an enemy with base health H needs roughly three times as much damage later. Rounding and type-specific rules then adjust the exact amount.

Over a match, the same aim rule can produce fewer kills, gems, and pickups. The bot's code has not changed; the enemies have. Stronger power or better positioning has to make up for that growth.

The spawn director also changes spawn timing, batch size, and the live-enemy limit. Boss periods temporarily reduce ordinary spawns. The roster mixes enemies that fight close, attack at range, flee, or use boss behavior. Whether attacking one is worth it depends on its movement, danger, gem value, and position.

The roster before spawn scaling

The table below gives base values from types.rs. They are not a late-match enemy's final stats, because spawn multipliers, behavior, and engine adjustments still apply. The cooldown column is measured in simulation ticks.

Enemy HP Damage Cooldown Speed Base EXP Behavior
Spider 14 6 48 168 3 Melee
Goblin 22 8 60 120 4 Melee
Snake 16 7 42 150 3.5 Melee
Skull 34 10 63 104 6 Melee
Lizard 28 9 54 138 5.5 Melee
Gnome 24 8 60 112 9 Melee
Spear 48 13 72 108 9 Melee, extra reach
Gnoll 42 11 114 96 10 Ranged
Thief 18 45 60 178 1.5 Fleeing, special live speed and wave rules
Shaman 54 15 132 92 13 Ranged
Bear 130 20 78 118 22 Melee, knockback resistance
Panda 160 18 84 98 24 Melee, knockback resistance

An enemy definition can store fractional base EXP as a fixed-point value. By the time EXP reaches the score, though, it is a whole number. The diver bot's target weights are also separate from this EXP table. Giving the thief a high weight means “aim here sooner”; it does not make a thief drop 100 EXP.

The two bosses use separate state machines. Minotaur starts with 1,500 base HP and 200 base EXP. Troll starts with 2,800 base HP and 360 base EXP. Their attacks pass through approach, windup, active, and recovery states, so a single contact cooldown cannot describe them.

Both bosses strongly resist knockback. Their ordinary damage field is zero, yet their special boss attacks can still hurt the knight.

Tick order explains apparently simultaneous events

After the VM runs, the world makes spawn decisions and updates thief behavior and enemy AI. It also prepares attack state and separates overlapping entities. Only then does it resolve automatic swings, projectiles, gems, and boss-spawn checks. So an enemy can move after the bot scans it but before a swing tests its position. The order is fixed. The bot's scan always comes first.

This order matters when you study a replay. A bot can aim where an enemy was at scan time and still miss it. A swing can also create a gem that then enters the later gem update.

A level increase during that gem update comes too late. It cannot change an attack that has already happened. To explain a close result, place each change within the tick, not just within the displayed replay frame.

Aim chooses the wedge; a proven effect can extend the hit regionAim chooses the wedge; a proven effect can extend the hit region123spidergnomeminotaur · bossknight1 Ordinary wedgeA target in reach and inside the aimed arccan acquire the local swing.2 Authorized discOnce the swing is acquired, an activedevice effect can add nearby non-bosstargets.3 Boss qualificationA boss behind the knight does not receive adisc-only hit; its own attack rules stillapply.Schematic geometry with first-frame sprites from the game assets; distances are illustrative.
The attack test combines reach, aim angle, and enemy size. The diagram is schematic. The source below is the actual ordinary wedge membership test used by the engine. View diagram at full size
RUST crates/engine/src/combat.rs · source lines 600–611
fn enemy_in_wedge(e: &Enemy, px: Fx, py: Fx, aim: Fx, range: Fx, half: Fx) -> bool {
    if !e.alive {
        return false;
    }
    let d = fx_dist(px, py, e.x, e.y);
    if d > range + e.radius {
        return false;
    }
    let ang = fx_atan2(e.y - py, e.x - px);
    let slack = fx_asin(fxdiv(e.radius, d.max(ONE)).min(ONE));
    angle_diff(ang, aim).abs() <= half + slack
}

The test first excludes dead enemies. Next, the center distance must be no greater than weapon range plus the enemy's radius. Then the engine finds the direction from knight to enemy. It also computes an angular slack from the enemy's visible radius at that distance. Finally, the angle between that direction and the aim must be no larger than the half-arc plus slack.

Suppose the range is 94, the enemy radius is 15, and the center distance is 105. The enemy's near edge is then only 90 away. The radial test accepts it. A center-only test such as distance <= 94 would wrongly reject it.

The angular slack makes the same size correction at the sides of the wedge. Passing this test is not enough for a hit. Cooldown and the attack path decide when the game uses it.

Combat is local geometry plus timing

An ordinary swing needs a ready cooldown and an enemy in reach. It then damages enemies inside a directional wedge, with slack for each enemy's radius. The engine also resolves knockback. The world separates overlapping entities earlier in the tick, before the automatic swing runs.

The ABYSS device can add a stronger local disc, but with conditions. An ordinary wedge target must still start the swing. Only then can the game find non-boss targets in the wedge or the disc. A small disc never shrinks the ordinary wedge.

Bosses remain wedge-only targets and keep their own limits. They take only ordinary damage, only from a swing's first pass, and no faster than the ordinary cooldown. A powerful local disc is not a command to delete every enemy on the map.

The game reduces damage in a set order

When an enemy hits, the game first checks the knight. Is it already dead, or still briefly protected from a previous hit? If so, the new hit stops there. Otherwise, the selected engine runs its damage steps.

Armor removes a fixed amount, HEAT makes the hit bigger, and ABYSS ward removes a fraction. Their order decides the final HP loss.

CLASSIC subtracts armor before it applies HEAT, while ABYSS applies HEAT, then ward, then armor. Try the same pretend hit in both paths below. Its values illustrate the rule; they are not measurements from a saved match.

One hit, two rulebooks

Give both engines the same made-up hit and power values. This shows the effect of operation order. The engines normally earn power in different ways. Start with 46 HP, then try all three cases.

In the first case, ward is zero. CLASSIC heats what remains after armor: (20 − 8) × 1.5 = 18. ABYSS heats the full hit and removes armor afterward: 20 × 1.5 − 8 = 22. That four-HP gap comes from order alone. In the second case, ward also removes a quarter of the heated hit before armor, leaving 14.5.

The small-hit case reveals a second difference. Both paths reduce the HP loss to zero. But CLASSIC still starts the knight's brief protection timer, while ABYSS does not for a fully absorbed hit. That matters if another enemy connects immediately afterward.

Here is the code behind those steps. In it, amount is fixed-point, so fractions such as 14.5 survive. The code also limits the armor and extra protection time that each engine can supply:

RUST crates/engine/src/ai.rs · source lines 269–304
        let mut amount = amount;
        let mut grant_iframes = true;
        let mut iframe_bonus = 0u16;
        if self.bugs_on() {
            let mitig = (self.hero.guard_flat + self.hero.armor_hp).clamp(0, ARMOR_TOTAL_CAP);
            if mitig > 0 {
                amount = (amount - from_int(mitig)).max(0);
            }
            amount = fxmul(amount, self.incoming_dmg_mult);
            iframe_bonus = self.hero.iframe_bonus.clamp(0, IFRAME_BONUS_CAP) as u16;
        } else if self.device_levers_on() {
            let heated = fxmul(amount, self.incoming_dmg_mult);
            let warded = apply_device_ward(heated, self.hero.ward_q16);
            let armor = (self.hero.guard_flat + self.hero.armor_hp).clamp(0, self.eff_armor_cap());
            amount = (warded - from_int(armor)).max(0);
            if amount == 0 {
                grant_iframes = false;
            } else {
                iframe_bonus = self.hero.iframe_bonus.clamp(0, self.eff_iframe_cap()) as u16;
            }
        }
        self.hero.hp -= amount;
        if grant_iframes {
            self.hero.invuln_timer = I_FRAMES_TICKS + iframe_bonus;
        }

One arithmetic detail matters if you want exact results. ABYSS calculates the ward reduction from the heated hit, rounds it, and then subtracts it. It does not multiply by 1 − ward in one step. The two methods can differ by one fixed-point unit.

The widget uses simple values that hide this rounding, so treat the source above as the exact rule.

Score follows the gem

When an enemy dies, the world updates its slot and the kill count. It then tries to create a gem holding that enemy's EXP value. That value is frozen: fixed in the gem at creation. The new gem flies off at a slowing scatter speed and has a limited lifetime. Inside the knight's pickup radius (74 px at level 1), the magnet pulls it in. Collection happens only at the smaller collection distance: 27 px, the knight's 17-pixel radius plus 10.

The conservation path
Enemy defeatedA real world interaction ends the enemy's life.
Gem createdA frozen value and unique identity enter the gem pool, if capacity allows.
Gem movesScatter, decay, magnetism, expiry, and routing matter.
Gem collectedThe normal pickup path calls add_exp.
Level and scoreTotal EXP changes; a crossed threshold can trigger a full heal.

At the end, RunResult reports the knight's hero.total_exp. The Gate's effect structure, by contrast, has no EXP field at all. The author audit tracks gem value in five states: spawned, rejected, live, expired, and collected. It also checks for duplicate pickup identities. Together, these records show exactly where the score came from.

Randomness is deterministic, not absent

Each run owns a seeded integer PRNG. Building the world and deriving separate protocol values are designed not to consume the spawn stream unexpectedly. The live simulation still draws random values, though. Gem creation, for example, takes its scatter angle and speed from self.rng.

The header comment in common::rng says the tick loop makes no random draws. The current call sites contradict it. The accurate statement is that draw order is fixed for the same run path. A bot that changes kills or spawns can change later draws. Even so, each artifact and seed gives a repeatable result.