Dev blog 2026-10-22 · Keel 0.5.2.1

How a shot lands

Six steps stand between a click and a point of damage.

We built a fight on a hex grid that a pilot can read off a ship card. This post follows one shot from the Attack click to the hull bar, then walks through the code that decides it and the tests that hold it.

All of it is live in Keel 0.5.2.1. Two small pieces are coming in 0.5.3 and are marked where they appear. The server decides every hit, and we show where in the code.

The Keel game screen on Layer 1. Six Carrier fleet cards are stacked on the left, each with shield and hull bars and a refill timer. Two hostile Breaker Patrol contact cards on the right show shield, hull and a lock timer, and one is tagged Jammed. A hex map fills the middle with the fleet in one sector. Order, stance and formation buttons and the powers Brace, Clamp, Unclamp, Jam, Relay and Sensor run along the bottom, above a log that reads Locking 4.00 s.
Layer 1 on a dev account with the Infinite test fleet: six Carriers against two Breaker Patrols. Each contact card shows shield, hull and a lock timer, one is tagged Jammed, and each fleet card carries a REFILL timer for the hull clock. Captured on 21 October. Not a normal Layer 1 fleet.
The problem

A fight where every ship is the same is a fight about counting.

In the first alpha builds a Carrier carried the same hull, shield and volley per gun as a Patrol. Classes differed in move time, lock time, power grid and slots only. No hull had any base evasion.

That is easy to balance and flat to fly. A small ship had no way to be hard to hit, and a big ship had no extra weight in a fight. We wanted a fight a pilot can read in a few seconds, where the choices made in fitting change what happens.

In the first buildsHull, shield and volley multipliers were 1.0 for every class, and no hull had base evasion. A shot at a hull with no evasion gear always landed.
TodayHull and shield run from 1.0 on a Patrol to 4.5 on a Carrier. Base evasion runs from 15 percent to 0. Three damage types meet printed resists.
What we wanted instead

Three rules for a fight.

These three shaped everything else in the model.

Size has a price both ways

A small hull is harder to hit and finds a big hull easier to hit. A big hull locks slowly and carries more hull, shield and gun. Nobody gets size for free.

The server owns the hit

The client may animate a move. It may not decide a hit. Every lock, cycle, roll and point of damage is the server's.

Every power has an answer

A Clamp stops a ship leaving its sector, not shooting. A Jam or an Unclamp breaks it, and a Jam leaves the guns running.

What we built

One shot, six steps, and numbers on a ship card.

A pilot locks a target and the lock bar fills. The guns then cycle on their own. Each cycle the server checks the battery, rolls the hit, applies the damage type against the struck module, and empties the shield before it touches the hull.

50 msThe server step. Lock timers, cycles, cooldowns and refills all count down in it.
2 to 4 sBase lock time, Patrol to Carrier. A target one class larger takes 0.25 s off.
15 to 0Base evasion in percent, Patrol to Carrier. Stand-in values that testers will tune.
10 sOut of combat before fight hull refills in full.

The grid, and five classes

Sectors are pointy-top hexes with six neighbours. A move is a chain of hops. Each hop first waits out a 0.4 second window in which Esc can cancel it, then takes the class's move time. A lock needs the target in the same sector, and a hop drops it.

The baselines before gear and Shard are hull 400, shield 100 and volley 100, and Human gear sits 10 percent under the alien lines. A sixth class is coming in 0.5.3. This post stays with the five that are live.

PatrolMove 0.75 s. Lock 2.00 s. Evasion 15 percent. Hull and shield times 1.0, volley times 1.0.1 gun
FrigateMove 1.00 s. Lock 2.50 s. Evasion 11 percent. Hull and shield times 1.5, volley times 1.2.1 gun
DestroyerMove 1.25 s. Lock 3.00 s. Evasion 7 percent. Hull and shield times 2.2, volley times 1.45.1 gun
CruiserMove 1.50 s. Lock 3.50 s. Evasion 3 percent. Hull and shield times 3.2, volley times 0.95 a gun.2 guns
CarrierMove 1.75 s. Lock 4.00 s. Evasion 0. Hull and shield times 4.5, volley times 1.15 a gun.2 guns

Numbers from the balance file. The evasion row and the size step below are marked stand-in there. The move and lock rows come from the design documents.

PatFriDesCruCar Pat2.001.751.501.251.00 Fri2.502.502.252.001.75 Des3.003.003.002.752.50 Cru3.503.503.503.503.25 Car4.004.004.004.004.00
Lock time in seconds. Rows are the shooter, columns the target. A smaller target adds nothing. Computed from the lock function for this post, not a capture.
PatFriDesCruCar Pat85%94%100%100%100% Fri80%89%98%100%100% Des75%84%93%100%100% Cru70%79%88%97%100% Car65%74%83%92%100%
Chance to hit with a weapon that has no tracking. Rows are the shooter, columns the target. Sentry guns win back half of the big-shooter penalty, Human guns a quarter. Computed from the evasion functions for this post, not a capture.
The ship card

Every number above is on the card.

The fitting screen prints what the server computes: volley, cycle, sustained damage a second, shield, hull, evasion, tracking, resist on each pool, move, lock and battery. A pilot does not need a table from us. The card is the table.

This capture is early. Layer 2 was still called Field in the header, and the map was a fixed grid. The cycle of 1.15 s, evasion of 15 percent, lock of 2.00 s and move of 0.75 s on it are the same constants the code holds today.

The card also prints Hit and crit as 100 percent and 0 percent, times 1.5. The hit figure is the gun's own, before the target's evasion. The crit figure is a real zero: no item gives crit chance.

The Fitting screen on an early alpha build. A ship card for a Raider Patrol hull lists offense, defense, navigation and battery numbers, among them volley 126, cycle 1.15 seconds, hit 100 percent, evasion 15 percent, move 0.75 seconds and lock 2.00 seconds. Four fleet cards from Patrol to Carrier sit on the left and three hostile contact cards on the right.
The Fitting screen in an early alpha capture from 20 October. The card is for a Raider Patrol hull, and its shield resists Impact 15 percent.
Guns and battery

A volley is the damage. A cycle is the wait.

A gun fires once a cycle. The volley is the damage of that shot before the target's edge and resist. Each shot spends battery.

HumanMixed damage, a third of each type. Volley 90, cycle 1.0 s, 20 battery a shot. The starting gear.
RaiderImpact. Volley 125, cycle 1.15 s, 20 battery a shot. The hardest plain hit.
SentryPhase. Volley 85, cycle 1.0 s, 20 battery a shot. Lock time times 0.75.
BreakerPlasma. Volley 85, cycle 1.0 s, 15 battery a shot, a quarter less.
  • The figures above are Common, rank 1 gear with no Shard, read from the sheet function. Grade, quality, rank and durability scale the volley. They do not scale the cycle.
  • A gun can be fast or slow. Fast takes the volley times 0.72 and the cycle times 0.62. Slow takes 1.55 and 1.70. On a Raider gun that is 126 damage a second fast, 109 plain and 99 slow, but the slow shot is 194 in one hit.
  • Battery starts at 100 and trickles back all the time, empty to full in about ten seconds on a bare ship. On the baseline numbers a gun firing flat out spends 20 a second and gets 10 back. By that arithmetic the battery sets the pace after about ten seconds. We worked that out from the constants and did not time it in play.
  • A Cruiser or Carrier has two weapon wells. The second gun is folded into the first. Its damage a second adds to the first gun's volley, and its battery a second adds to the shot cost. The pair rolls once a cycle, on the first gun's damage type.
Damage and pools

Three damage types, and the shield goes first.

Raiders deal Impact, Sentries Phase and Breakers Plasma. Impact beats Phase, Phase beats Plasma and Plasma beats Impact. The edge is read from the module that is struck, not from the gun that fires.

ImpactRaider guns. Does 15 percent more to Sentry gear and 15 percent less to Breaker gear.
PhaseSentry guns. Does 15 percent more to Breaker gear and 15 percent less to Raider gear.
PlasmaBreaker guns. Does 15 percent more to Raider gear and 15 percent less to Sentry gear.
  • Every armor and shield resists all three types and prints it. A Common alien piece resists its own type by 15 percent and the other two by 5. A Common Human piece resists 8 percent of each. Rare, Legendary and Ultimate add 2, 4 and 6 points. A resist stops at 75 percent, and a negative one is ignored.
  • Human gear is the generalist. A Human gun splits every shot into thirds of Plasma, Impact and Phase, and each third meets its own resist. Human armor and shields take no edge either way. New accounts start Human, so a new pilot meets all of this without choosing a faction.
  • A filled shield takes hits first even while its ship is Jammed. Jam stops the refill, not the pool. The shield refills in about twice the ship's move time, 1.5 s on a Patrol and 3.5 s on a Carrier. Nothing in the code delays that refill after a hit. Only a Jam or an empty battery stops it.
  • At zero hull, Layer 2 and deeper destroy the hull. On Layer 1 the hull is never destroyed.
Shot100 Plasma from a Breaker gun at a Raider ship with a full shield of 100.
EdgePlasma beats Impact, and a Raider shield is Impact gear. Times 1.15.
ResistA Common Raider shield resists Plasma 5 percent. Times 0.95.
Struck100 times 1.15 times 0.95 is 109.25 against a shield of 100.
ShieldIt empties. The 9.25 left over is turned back into raw, 8.47.
HullWith a Common Raider armor fitted the same edge and resist apply, so 9.25 reaches the hull.
BraceWith Brace up the resist is 15 percent. The same shot strikes for 97.75 instead of 109.25.

Worked through for this post with the split function, not a capture. The figures are Common gear, rank 1, with no Shard.

Powers

Brace, Jam and Clamp: what each costs.

Every power spends battery, and every power restarts the hull refill clock. Powers act on the active ship. Escorts use them through triggers.

Q BraceRestores 80 hull on a baseline module and raises every resist by 10 points for 3 seconds. Costs 15 battery and cools down for 4 seconds. A Jam silences it. Raiders restore more and pay more, Sentries cool down faster, Breakers pay less.
E JamSilences the locked target for 3 seconds at the baseline. Its Clamp, Brace, Sensor, mender, aura and shield refill stop, and a cloak drops. Weapons keep firing. Costs 10 battery and cools down for 8 seconds. It also breaks any Clamp held on you. Raider Jams last longest, Breaker Jams shortest.
W Clamp and X UnclampClamp holds an NPC target in its sector. It costs 10 battery and has no cooldown. An enemy Clamp holds your ship once it tries to leave, while the grapple is at least 50, half of a baseline 100. Unclamp costs 10 battery, breaks every Clamp on the active ship and keeps new ones off for 0.5 seconds. The move the Clamp stopped goes again.
RefillFight hull refills in full after 10 seconds with no firing, no hit and no power. Shield refills on its own clock. Battery trickles back all the time. Docking at Haven or a charter berth refills all three at once.
RepairA Mender is a system module. Each cycle it repairs the most damaged ship of yours or a charter mate within one sector: 50 hull every 2 seconds for 10 battery, before gear. A Jam silences it. Module durability is repaired at Haven for 10 quality.
As a pilot lives it

A click, a bar, and a rhythm.

The pilot sees bars, tags and log lines. The server owns the lock, the cycle, the roll and the damage, and it writes the numbers the client draws.

  1. AttackLeft click a contact. The active ship starts a lock and the escorts in that sector focus it. A right click is the active ship only.
  2. Hold the lockThe gold bar fills in the lock time. A hop drops the lock. The squad you fight starts its own lock after a random delay of at most half a second.
  3. CycleThe first shot leaves one server step after the lock completes. Then one shot a cycle while the battery holds. A failed roll writes Shot missed.
  4. PowersQ, W, E and X act on the active ship. Escorts Brace under 40 percent hull by default, and their Jam triggers default on.
  5. QuietTen quiet seconds refill the hull. Dock refills everything. Shield and battery come back on their own.
LogLocking. 2.00 s.
LogShot missed.
LogJam. Target silenced 3.0 s. Its Clamp, Brace, Sensor, and shield regen stop.
LogClamped. Press Unclamp or Jam it.
LogUnclamped. 0.5 s before a Clamp can catch this ship again.
LogFight hull refilled out of combat.

Wording is copied from the server. The seconds come from the ship's own numbers. On paper a Patrol misses a Patrol 15 times in 100.

Stances, and who may shoot whom

AggressiveEngages anything in the sector, helps fleet mates one hex away, and engages pilots you marked KOS where firing is allowed.
DefensiveFires on what locks this ship or its protectee, and on the active ship's target.
SupportStays with its protectee, fires on whatever locks it, always Jams and Clamps its attackers, and Braces when hurt.
PassiveMoves on orders and never fires unless told to focus.
  • Layer 1 has no player fire. Layer 2 is a duel by consent, or hostile charters. From Layer 3 up it is open fire. A pilot is locked only by an explicit Attack or Focus.
  • Layer travel and wormhole jumps take 5 seconds, and in transit a ship cannot lock or fire. The note reads: Travel up to Layer 2. 5 s. You cannot lock or fire.
  • Coming in 0.5.3: after any wormhole arrival the ship cannot be locked and cannot fire for 5 seconds. The flag ends early the moment that ship locks or fires.
  • A rescue jump gives the same flag for the layer's rescue delay: 45 seconds on Layer 2, then 35, 25 and 15, and 10 on Layer 6. A rescue arrival does not end early. NPC squads skip a ship that is arriving, and a pilot who tries to lock one reads: That ship just jumped in. It cannot be locked yet.
Under the hood

Pure rules, a stateful mixin, and three clocks.

The fight lives in combat.py, 454 lines, and the pure math in rules.py, 2,444 lines. The world step in world.py runs the clocks and the powers.

Rulesrules.py holds functions of numbers: lock time, size evasion, the hit roll, edge and resist. No ship dictionary, no state, so a test can call them with plain numbers.
Combatcombat.py is a mixin on the world. It owns the lock state, the cycle, the damage path for ships and for NPCs, and hull destruction.
Clocks and powersworld.py ticks battery, shield and hull refill every 50 ms step, and holds Brace, Jam, Clamp and Unclamp. combat_log.py keeps the last 200 shots per pilot.

One shot, as a call chain

  1. LockThe server checks the sector, the weapon, the duel rules and the arrival flags, then sets the lock timer._attack, _attack_ship, lock_seconds
  2. CycleEach step counts the lock down, then the cycle. A shot needs the battery, and an enemy suppression aura stretches the cycle._tick_player_guns, suppressed_cycle
  3. RollHit chance minus evasion, with the size step and tracking. One random draw, and only when the chance is not certain._shot_damage, size_evasion, resolve_attack
  4. EdgeThe struck module's faction sets the edge and each pool sets its resist. A Human shot is split in thirds._hurt_ship, _hurt_npc, _apply_pools, split_edges
  5. PoolsShield first, overflow to the hull. Any damage restarts the refill clock. At zero hull, Layer 2 and deeper destroy the hull._apply_pools, _destroy_hull
  6. RecordThe combat log gets the raw and the dealt numbers, and the next snapshot carries the new pools to the client._clog_ship_hit, _clog_miss, snapshot

The main functions

_attack_shipcombat.py

Starts a lock. Checks the sector, the weapon and the target, then sets the lock timer from the shooter's class and the target's lock size. An Anchor hull is locked as one class larger.

lock_secondsrules.py

The shooter's base lock, minus 0.25 s for each class the target is larger, times the lock multiplier from gear and hull.

_tick_player_gunscombat.py

Runs every step for every firing ship, a little over fifty lines. Counts the lock down, then the cycle, checks and spends the battery, rolls the shot, and calls the damage path. A pilot or structure target is handed to a sibling function.

_shot_damagecombat.py

The hit roll. Reads hit chance, evasion, size and tracking from the two ships, draws a random number only when the chance is between zero and one, and returns hit, crit and damage.

split_edgesrules.py

Incoming first, then the edge, then resist on each pool, shield first. The overflow goes back to raw before it meets armor.

_apply_poolscombat.py

Splits a Human shot into thirds, calls the split, takes the shield and hull points, restarts the refill clock, and sends a hull at zero to destruction except on Layer 1 and in the tutorial.

_hurt_npccombat.py

The same job for NPCs, with its own loop plus squad, incursion and wreck hooks. Two hooks for the Titan and the Dreadnought Siege sit in this file and in the roll, and they are coming in 0.5.3.

_tick_shield, _tick_refillworld.py

Shield refills at its maximum over twice the move time unless the ship is jammed or out of battery. Hull refills in full after ten quiet seconds. A third clock, _tick_battery, trickles the battery back.

_brace_ship, _jam_ship, _clamp_shipworld.py

Each power checks its module, its silence and its cooldown, spends battery, and restarts the refill clock.

shortest_path, neighborsrules.py, hex_grid.gd

Pointy-top hexes on odd-r offset coordinates, with cube coordinates for distance and a breadth-first path. The same math lives in both languages.

Choices we are happy with

Pure rules, stateful mixinThe math is separate from the state. Several tests call the edge and resist function with plain numbers and compare it with a hand sum.
One roll path for everyoneNPCs fly real fits from the same sheet, and their shots go through the same roll function. Players and NPCs meet the same evasion and edge.
Overflow goes back to rawA shot that breaks a shield does not carry the shield's edge into the hull. It meets the armor's own edge and resist.
A certain hit draws no numberWith a chance of 0 or 1 and no crit chance, the server never calls the random source. A certain hit does no extra work and leaves the random sequence alone.
The client side

The client draws. The server decides.

  • hex_grid.gd is hex math and nothing else: pointy-top hexes, odd-r offsets, cube coordinates, neighbours, a path. It holds no combat rule.
  • ship_tile.gd draws the lock bar and the cycle ring from lock_left and cycle_left in the snapshot. It starts nothing. The Attack button sends an op.
  • main.gd plays the gun sound when cycle_left goes up between two snapshots. A comment there says the server decided every event.
  • The path preview mirrors the server's route for known sectors. The server still builds the real path and answers with the arrival time.
What the code looks like in numbers
454Lines in combat.py
2,444Lines in rules.py
214Lines in hex_grid.gd
200Shots kept per pilot in the combat log
What the tests prove

What is pinned, and what is not.

The unit tests live in test_alpha.py and run on every commit through the pre-commit hook. playtest_check.py is a separate check that drives live sockets through an NPC fight and a pilot kill.

Pinned

  • test_lock_and_move_tables: the Patrol and Carrier move times, three lock times and a lock multiplier.
  • test_size_gap_moves_evasion_and_tracking_softens_it: a big shooter faces more evasion, a small shooter less than zero, and tracking takes its share off.
  • test_counter_loop_uses_the_defense_not_the_weapon: the edge comes from the struck module, and Human gear takes none.
  • test_resist_is_per_type_and_capped: resist per type and per pool, the 75 percent cap, and a hand-worked shield and hull split.
  • test_a_split_shot_lands_a_third_of_each_type: the Human thirds against an NPC.
  • test_a_jammed_ship_still_takes_hits_on_its_shield: Jam pauses the refill and not the pool.
  • test_hull_refills_after_ten_seconds_out_of_combat and test_a_hit_restarts_the_refill_clock: the ten second rule and what restarts it.
  • test_unclamp_breaks_free_with_a_short_immunity: the stopped move goes again.
  • test_a_wormhole_arrival_has_a_grace_that_a_lock_or_shot_ends: coming in 0.5.3.

Not pinned, or thin

  • No test fires many shots and counts the misses. The hit chance is pinned as arithmetic, and the live roll is reached only through the Siege and Titan damage tests.
  • Crit is built and tested as arithmetic, but no item gives crit chance, so live fights never crit.
  • The live Clamp does not roll. A chance function for powers exists and is tested, and the Clamp button does not call it.
  • A test pins the second gun's damage a second. Nothing pins that the pair rolls once a cycle.
  • Ships and NPCs each run their own shield and hull split. A test checks the NPC path against the split function, and none checks that the two paths agree.
  • The ship card adds hull line shifts to Evasion and Tracking, such as the Anchor's minus 3 points. The shot roll reads only the class evasion and the weapon's tracking. For a Line hull the two agree. For an Anchor or a Screen they do not. We found this while writing this post and have not fixed it.
  • The client self-test checks hex_grid.gd on its own. We found no check that compares it with rules.py.
  • The load check targets a 95th percentile step under 25 ms in a big fight. We did not rerun it for this post.
What we learned and what is next

Small lessons from three reports.

Jam stops a refill, not a shieldA report found that a Jammed ship's shield stopped taking hits. The rule was that Jam pauses the refill only. Version 0.4.1 fixed it, and a test holds it.
A timer needs a berth ruleJam counts down only in space, so a ship that berthed while jammed stayed jammed. Version 0.5.1 clears Jam at the berth.
A protection needs an endA report asked for protection after a wormhole arrival. We settled on five seconds that end the moment the ship locks or fires, and a 15 second cooldown after a cancelled jump. Coming in 0.5.3.

Where we would push next

  • Tune the stand-ins. Class evasion, the size step, tracking and the resist profiles are marked stand-in in the balance file, and testers set them.
  • Make the card and the roll agree for hull lines, then pin it with a test.
  • Decide where crit chance comes from, or leave it at zero on purpose.
  • Fire ten thousand shots in a test and compare the miss rate with the table above.
  • Bring the rules text up to date. Section 4.2 of the rules document still describes a battery that snaps full after ten idle seconds. The code trickles it back all the time, and the help topic says so.
  • Give ships and NPCs one damage path.
Join the open alpha

Come and take a lock with us.

Keel is free to play on PC, and the open alpha is where this fight lives. Find us in the Keel Discord, tell us what a lock, a Jam or a missed shot did to your evening, and file a report from inside the game when a number looks wrong.

Players whose reports become fixes are credited in game. Fit a Brace and a Jam, take a lock on a Layer 1 pocket, and watch the log.