Skip to content
Felucca Online

Docs

Combat: real-time tactics on a tempo timeline

Status: decision #68 (amends #55); the client-only showcase prototype is implemented (decision #69, see Demo prototype): no server, no ledger. The rules are written so they can later move unchanged into the server and the uo_ledger program (see Later: server and chain). Open questions are at the end.

The idea

Fallout 1–2 meets Final Fantasy X, in real time.

  • Fallout: you fight on the tactical grid, and that grid is the world map itself. There is no separate battle screen. Moving tile by tile and attacking both cost action points. Positions, range, line of fire and cover matter.
  • FFX: every action has a tempo cost, and a public timeline shows who acts next. Heavy actions push you far back on it.
  • Real time: the clock never stops. An action's AP cost is how long you recover afterwards, in real seconds. A halberd swing costs 3 s. In that time an opponent with a dagger swings three times.

Every obstacle in the world can be destroyed (decision #66). Obstacles block projectiles and targeted spells, so they are cover, and taking cover apart is part of the fight.

Time and action points

  • 1 AP = 250 ms of real time (AP_MS). Costs are stored in milliseconds, and AP is the unit the UI shows them in.
  • Every combatant is either ready or recovering until ready_at. A ready combatant may start any legal action. Starting an action of cost c sets ready_at = now + c. There is no AP pool: nothing is banked, and idle time while ready is simply lost.
  • The clock never pauses, not even in the offline demo. Several combatants can be ready at the same moment. Actions are applied in the order they are issued, and simultaneous ones in the same simulation step are ordered by higher Dex, then by lower combatant id.
  • Queued order. While recovering you can queue the next order (a path, an attack, a cast). It starts on the exact moment you become ready, so playing well never depends on reaction time alone. A new order replaces the queued one.
  • Auto-attack (UO's swing timer). With no queued order, a ready combatant that has a current target in reach and line of fire swings at it automatically. Hold turns this off until the next order.
  • The simulation runs at a fixed step (SIM_STEP 50 ms) on integer milliseconds with a seeded RNG, so the same inputs produce the same fight.

Action costs

Starting values, tuned in play. Weapon costs keep UO's order (fast dagger, slow polearm) but spread it out, so the choice of weapon is felt.

ActionCostNotes
Step2 AP (0.5 s) per tileDiagonal costs the same (UO's 8-way movement). Open world, out of a fight: a run, 1 AP (#92)
Swing, dagger4 AP (1.0 s)
Swing, longsword / mace7–8 AP
Swing, quarterstaff / tools9 AP
Swing, halberd12 AP (3.0 s)Reach 2 tiles
Shot, bow8 APRange 10
CastCast time by circle (2 + circle AP), then 2 AP recoveryTwo-phase, see Casting
Potion2 AP
Guard4 APBetter parry until ready again

Swing costs are scaled by stamina, as in UO: cost · (1.5 − 0.5 · stam / max_stam), so a tired fighter is up to 50 % slower. Dex and item modifiers (#52) can subtract. The halberd and the cost table are new catalog data: weapon ids are append-only, and the AP cost becomes part of WeaponDef.

The grid

  • Combat uses the world's tiles. Each combatant occupies one tile, and two combatants can't share a tile.
  • A move order is a path of steps. Each step is its own 2-AP action, so the character walks while it recovers. You can replace the rest of the path between any two steps, and enemies keep acting while you walk.
  • Paths are A* over the 8 neighbours. A step can't cross a blocked edge (walls, fences, closed doors and gates: blocked_edges), enter a tile blocked by a static, or cut a diagonal corner past a blocked edge. A broken static stops blocking (#66), so destruction opens new paths.
  • Reach is Chebyshev distance in tiles: 1 for most melee weapons, 2 for the halberd, the weapon's range for bows and spells. Melee also needs the edge between the tiles to be open, so you can't stab through a wall.

Line of fire and cover

Projectiles (arrows, bolts, thrown weapons, missile spells) and targeted spells (any spell aimed at a mobile or a tile) need a line of fire. Melee ignores cover. It needs reach and an open edge only.

  • The line runs from the attacker's tile centre to the target's tile centre at chest height. Every tile it passes through (supercover, so a line along a tile corner touches both neighbours) and every tile edge it crosses is checked. The attacker's own tile doesn't count, and neither does the target's.

  • Each destructible static on that line has a cover class, taken from its baked height (height_range of its sprite) with name overrides:

    ClassExamplesEffect
    Full (top ≥ 1.2 units)walls, closed doors and gates, trees, rocks, bookcasesAlways stops the shot
    Low (0.4–1.2 units)crates, barrels, fences, tables, chestsStops LOW_COVER (50 %) of shots
    None (< 0.4 units)small items, plants, rugs, floorsIgnored
  • A shot that cover stops hits the obstacle instead. It plays the hit flash, shake and cracks of #66 and takes structural hits (below). Each cover object on the line rolls in order from the attacker outwards, so two crates are better cover than one.

  • The target screen previews it all. When you aim, the line is drawn, the objects that would stop the shot are highlighted, and the hit chance already includes cover.

  • Other combatants don't give cover in v1.

  • Your own cover doesn't block you (decision #71): low cover touching the shooter (an edge on its own tile, or a tile object next to it) is ignored for its shots. You shoot over your crate or out of your window, while it still covers you from the other side.

  • Windows (wall pieces with a window) are low cover. Doorways, open doors and gates stand on their edge with no cover and no blocking, but can still be broken.

Destroying cover

Breaking cover uses the existing durability in hits (durability(sprite) from #66, sent as HitStatic):

  • A stopped shot deals 1 hit (heavy spells deal 2).
  • An area spell is a Blast on its target tile and damages every destructible static in range.
  • Anyone can attack an obstacle directly (Attack(static)). A melee swing deals 1 hit, 2 with two-handed weapons. This is how you open a wall or clear a doorway.
  • A broken object stops giving cover and stops blocking movement at once. Its debris has no effect on the rules.
  • Sweep (decision #71): a two-handed melee swing (halberd, staff) also takes SWEEP_HITS (2) off every object within 1 tile of its target that the weapon reaches: furniture, fences, walls. Fights indoors wreck rooms.
  • Powder kegs (decision #71): a fighter with charges can Plant one next to an obstacle it touches (6 AP). The keg is an obstacle with a fuse (FUSE_MS 4 s). When it burns down, or the moment anything hits it, it goes off: everyone within 1 tile takes 25–40 damage and every obstacle there takes 10 hits. That is a door with its frame, the window beside it, the wall pieces either side, and the furniture just inside.
  • Mega Explosion, the last stand (decision #86): a seventh spell (hotkey 7). It costs no mana, and you can cast it only at or below 25 % health and only once per fight.
    • A 0.6 s channel that can't fizzle; the caster can't drop below 1 hit while channelling.
    • Then everyone within 7 tiles, the caster included, takes 120–160 damage, and every obstacle within 7 tiles takes 20 hits.
    • A killing blow on a fighter who knows it and hasn't used it sets it off by itself: the fighter hangs on at 1 hit and goes out with the blast.
    • When nobody is left standing, the outcome is NoSurvivors.
    • The client shows a house-sized fireball with a ring of secondary blasts and about 1.2 s of camera shake (CameraState::shake). There is no slow motion: it was tried and read as lag.
  • Doors open (decision #72): Open(door) (2 AP, standing at it) opens a closed door. It then stops blocking and covering, and the client swaps in the _open sprite. Paths can plan through closed doors (Cross::Doors) as well as through breakable obstacles (Cross::Break).

Casting

  • Cast(spell, target) spends mana when the cast starts, then the caster channels for the cast time, rooted (no steps). At the end of the channel the spell resolves, and the 2-AP recovery follows.
  • Damage taken while channelling can fizzle the spell. The chance comes from the damage and the caster's Magery, like UO's interrupt. Mana spent on a fizzle is lost. This is the core tempo play: hit a caster before their spell lands.
  • Line of fire is checked when the spell resolves, not when the cast starts. A target that steps behind a wall during the channel makes the spell hit the wall.
  • Demo spell set: Magic Arrow (circle 1, missile), Heal (2, targeted), Fireball (3, missile), Wall of Stone (3, raises a temporary destructible wall: cover you can create), Lightning (4, targeted), Explosion (6, area: Blast + damage around the target tile).
  • Reagents come later with the ledger. The demo spends mana only.

Speed properties (planned, decision #93)

Direction, not implemented: gear-driven speed in the spirit of UO Age of Shadows / Mondain's Legacy, so characters differ by their equipment and improving it is part of the game.

  • Properties (modifiers on items and effects, Attr in uo_proto::character): Swing Speed Increase (percent), Faster Casting and Faster Cast Recovery (points), each with a cap; more AOS-style properties follow the same way.
  • Quantum: every action time is computed in milliseconds and rounded down to the quantum, AP_MS = 250 ms today. As in UO, a bonus either saves a whole quantum or nothing, so builds aim at breakpoints and the timeline stays readable.
  • Formulas (starting points, tuned in play):
    • swing = floor_q(weapon base × stamina factor × 100 / (100 + SSI)), never below a minimum swing;
    • cast = (2 + circle) quanta − FC;
    • recovery after a cast = (base − FCR) quanta, with a base above today's 2 AP so FCR has something to cut.
  • Later: the quantum may drop to 150–200 ms with higher base costs, for finer steps between gear levels. The rules read times in milliseconds everywhere, so this is a constant and a rebalance, not a rewrite.
  • Open: caps, the exact formulas, the property list, how loot rolls them, and whether Dex feeds swing speed.

Hits and damage

  • Stats, skills, armour and resists are effective values from uo_proto::character (#52): base values plus item and effect modifiers.
  • Hit chance (UO): (attacker_skill + 20) / ((defender_skill + 20) · 2), clamped to 2–98 %, then multiplied by the chance that no cover stops the shot. Guard adds a parry roll with Parrying and the shield.
  • Damage: weapon damage range + Str and Tactics bonuses, split by AOS damage type against resists. Spells use their own damage range.
  • All of this lives in integer maths in a pure resolver (uo-combat, see below), so a later server or program computes the same results.

Starting and leaving a fight

  • A fight starts when a hostile notices you (aggro range 8 tiles, needs line of sight) or when you attack. Everyone hostile within 12 tiles joins the battle. Outside a battle, movement is the normal free walking.
  • While in a battle, movement is tile-stepped and every action goes through the timeline. There is no explicit Flee: you leave by getting more than 12 tiles from every enemy and staying out of their line of sight for 5 s.
  • A combatant at 0 HP dies. The death animation plays and the body stays. The battle ends when one side is dead or gone.

Presentation

  • Timeline bar (FFX style, real time): a strip at the top showing now at the left edge and the next 8 s to the right. Each combatant's portrait sits at its ready_at and slides left as time passes. A channelled cast shows as a bracket ending at its resolve time. Ready combatants pile up at the left edge. Hovering an action places a ghost of your portrait where that action would put you, so you can see who gets to act before you.
  • On the map: the reachable tiles for the queued path with their AP cost, your path, the line of fire with its stopping objects, and the hit chance shown on the target.
  • Animations come from the character clips (#48): walk on each step, swing clips stretched over the start of the recovery, hit react, cast channel, death. Arrows and missile spells fly visibly along the line of fire, and stopped ones strike the obstacle.
  • The UI follows the EVE-style design system (#56, #59): dense, numeric, colour only as signal.

Demo prototype

Implemented (decision #69): a showcase that runs in the offline client. There is no server, no ledger and no networking.

cargo run -p uo-client -- --combat-demo            # the forest cabin (default)
cargo run -p uo-client -- --combat-demo camp       # the bandit camp
cargo run -p uo-client -- --combat-auto            # the hero fights by itself
cargo run --release -p uo-client -- --combat-auto --record fight.mp4 --width 1920 --height 1080 --zoom 3

Autoplay (--combat-auto, decisions #72/#79): the cabin's hero is a Ranger (Archetype::Ranger, an archer with spells) and an ordinary man: Str 75, Archery and Tactics 80, no potions, no Heal and no champion blessing. He shoots the best clear target through the window (Lightning when his mana is brimming). Indoors he holds the house:

  • When an enemy keg is lit at the walls, he falls back to the far side. The spot is the reachable indoor tile farthest from the keg, with the most furniture between, out of reach of polearms through windows.
  • Once a melee bandit can get inside, he seals the way in once with a Wall of Stone. The wall is aimed at the doorstep and the first tile in (each shifted a tile either way), and is chosen by simulating that it cuts every melee bandit off, or at least the nearest.
  • A blast staggers everyone within 2 tiles for 1.5 s, which gives him the moment to cast.

Outdoors (the camp, or after the house is lost) he raises a Wall of Stone across the nearest melee bandit's way in (every 15 s, only where at least two pieces fit, Battle::wall_edges), casts Explosion on two or more bandits bunched together, and kites below 40 % health. Walking on needs no reaction delay, so every fighter moves at the full 2 tiles/s. A typical run:

TimeEvent
8–12 sThe bandits break the fence
12.4 sThe keg is planted; the hero falls back
16.4 sThe keg blows the door, the window beside it and a wall piece
18.75 sThe hero's wall seals exactly that gap
29 sThe wall crumbles; the killing blow sets off the hero's Mega Explosion
29.6 sEveryone dies and about 50 objects break, most of the cabin with them

Recording (--record out.mp4, --seconds max, default 120; add --no-hud for clean footage without the HUD, floating text and overlays, e.g. 9:16 with --width 1080 --height 1920 --zoom 3): renders offscreen. The presentation camera draws into an image with the HUD (it is the default UI camera), and game time steps exactly 1/30 s per frame. Every frame goes to ffmpeg (H.264, CRF 18, 30 fps). The battle waits until capture starts, and the recording ends 3 s after the battle does. Use the release build: the debug build manages about 5 frames per second.

  • Rules crate: crates/uo-combat, pure Rust with no Bevy, deterministic (integer ms, SIM_STEP 50 ms, seeded SplitMix RNG, no hash-map iteration in the rules). grid.rs: Arena (walkable bounds, blocked tiles, edge and tile obstacles with cover class and hits), supercover line of fire, A* (optionally through obstacles, for AI that breaks walls). battle.rs: fighters, orders, actions, pending blows/projectiles, channels and the Event stream (Moved, Swing, Shot, Hit, Missed, Parried, ObstacleHit, CastStarted, Fizzled, SpellStruck, Died, Over, …), plus preview_attack / preview_cast for the UI. rules.rs: profiles from character sheets, spells, formulas. ai.rs: archetypes. demo.rs: the roster as real uo_proto character sheets. Tests cover the tempo (6 dagger swings to 2 halberd swings in 6 s), cover, spells against cover, Wall of Stone, walking, breaking through walls and determinism; cargo test -p uo-combat balance -- --ignored --nocapture reports how often an AI-driven hero wins.
  • Client: uo-client/src/combat/. mod.rs builds the Arena from the world (walls/fences/doors → edge obstacles, other destructible statics → tile obstacles; hits from Durability, cover from the sprite's baked height_range: full ≥ 1.2, low ≥ 0.4, iron bars low, rope none) and ticks the battle on frame time. present.rs turns events into steps (the Actor walks each step in exactly its recovery), facing, one-shot clips (anim::OneShot: Sword_Regular_A for fast weapons, Sword_Attack timed to the impact, Hit_Chest, Sword_Block, Spell_Simple_*, Consume), arrows and spell missiles, lightning bolts, explosions, rising Wall of Stone pieces (wall_fieldstone_x/_y statics), HitStatic on obstacles and floating text. props.rs puts box-mesh weapons and shields on the hand bones. hud.rs: timeline bar, vitals, spell bar, target card, log, banner, overhead health bars. input.rs: controls and gizmo overlays.
  • Forest cabin (cabin.rs, the default scene, decision #71): a log cabin with a thatch roof in a clearing, built with the village's Plan (door and doorway on the north wall, windows, a furnished interior: fireplace, cooking pot, bed, nightstand, table and chairs, bookcase, spinning wheel, chest, barrels, weapon rack, wall torch). A closed split-rail fence rings the yard (well, woodpile, chopping stump, sawhorse, training dummy, hay, a garden plot, trough, torches, flowers), and forest surrounds it. A dirt path comes from the south, the camera side (decision #80): the door is in the cabin's south front, facing the viewer, and the bandits come up the path towards it. The trees are real size (#74, 10–16 units tall) and hide about 1.4 tiles of screen depth per unit of height behind them, so tall trees only stand well behind the scene or at the sides of the view, and there is undergrowth in front. The hero, an archer (bow, Archery 80, attack spells and Wall of Stone, no champion blessing), starts at the north window (the south one). The bandits know where the hero is: a sapper (Firebug, one keg), two halberdiers, a cutpurse and a poacher. They smash the fence (brutes break fences at once) and gather near the door out of the blast radius. The sapper plants the keg, and the blast takes the door, its frame and the window. Then the halberds sweep the furniture. Brutes leave walls and doors to a sapper while one has a keg, and nobody stays next to a lit keg. The cabin's roof hides while the player is anywhere in the yard, so the indoor fight stays visible and a wrecked cabin never gets its roof back; all four walls stay drawn (no cutaway, user feedback on #80). A typical auto run: fence down at 8 s, keg planted at 15 s, breach at 19 s, about 20 objects broken by 50 s.
  • Bandit camp (--combat-demo camp): camp.rs, a bandit camp on levelled ground south of the village (the village moves north in this mode): tent, campfire, logs, a ruined fieldstone wall with a gap and windows, a wooden pen fence, the archer's picket fence, crates, barrels, trees and torches. The hero (Avatar: longsword, heater shield, chainmail; Magery 80 with all six spells; 5 potions; a demo champion effect, +120 max hits) faces a Cutpurse (dagger), a Halberdier, a Poacher (bow) and a Hedge Mage. The bandits wait until they see the hero (8 tiles, line of sight) and alert allies within 10 tiles. Balance: the walk-in AI hero wins about a third of the fights after ≈ 90 s; using cover and spells does better.
  • Controls (the same in every mode, decision #96): left click a tile to walk, a bandit to attack, a destructible object to attack it; hold the right mouse button or WASD to run (one tile at a time while nothing else is ordered, as the server does); numpad steps. 1–6 arm a spell, then click its target (the ground for Wall of Stone; 2 twice heals yourself; Shift keeps it armed); right click / Esc disarms. Q potion, E guard, R hold, Tab next target, Enter fight again. The debug Shift + right-click blast is off during the battle.
  • HUD: the shared HUD (status, vitals, spell bar, target card, log, overhead bars, outcome banner) plus the battle's own timeline: it shows the next 8 s with a READY gutter left of "now"; bandits appear on it once they have spotted you; channels are blue bars; hovering an action shows a ghost of the hero where it would put you. Overlays: path, line of fire coloured clear / low cover / blocked with the covering objects outlined, the explosion area, the Wall of Stone edges.
  • Debugging: UO_COMBAT_LOG=1 prints every battle event; --headless --screenshot saves the scene render without a window (no UI; it can't go black from a window losing focus); UO_GRIP="rx,ry,rz" turns the right-hand props for tuning. In --screenshot runs the battle advances 1/30 s per frame. Lights of effects are separate, unscaled entities, never children of the scaled effect meshes. Window captures go black when the window loses focus mid-run; don't use the machine while capturing.
  • Not in the demo: reagents, loot, death penalties, notoriety, more than one storey (combat on ground level only), networking, attacks of opportunity, fighters as cover.

Later: server and chain

This is the target from #55. It is revised by this design and is not needed for the demo.

  • The uo-combat crate is meant to be shared by the client (previews), the server (mob AI, authority) and the uo_ledger program (no_std), so everyone computes the same transcript.
  • Players sign their orders with their owner or session key (#8). The server plays mobs and signs their orders as the game authority. Mob AI stays off chain, and the rules are enforced for both.
  • Real time changes the chain picture. Costs are wall-clock time, so the program needs a trusted clock, and order is decided by arrival, not by a strict turn sequence. That makes ephemeral rollups (MagicBlock, with battle and character accounts delegated for the fight and ~10–50 ms confirmations) a requirement, not an option. On plain L1, a 0.4–1 s slot is coarser than a dagger swing.
  • Positions and cover on chain. The program would have to check paths, reach and line of fire, so it needs the obstacles around the battle and their durability. Either the battle account gets a snapshot of the local map when it opens, or the server attests every geometric check.
  • Randomness: a VRF, so that the server (itself a combatant) can neither choose nor foresee rolls. Slot hashes alone can be biased by the leader.

Open questions

  1. Attacks of opportunity: does stepping out of an enemy's reach give it a free swing?
  2. Do other combatants (allies included) give cover or block shots?
  3. Combat across storeys and roofs: height advantage, shooting up and down stairs.
  4. Server/chain: map snapshot in the battle account vs server attestation for geometry, and the RNG provider.
  5. Death and looting, insurance; PvP flagging and notoriety.
  6. Battle size limits and how big fights shard.