joost

personal web page

Elite on the MSX1: the kill-points machine

← back to Elite on the MSX1

Your combat rank is decided by one number: a running total of kill-points that only ever goes up. This page is the full breakdown of how that number works, what every ship is worth, and which targets actually move the needle.

Addresses below are absolute Z80 addresses. CODE.COM loads at 0x0100, so addr = file_offset + 0x100. Everything here was read from the disassembly (tools/zdis.py) and confirmed against live openMSX RAM dumps; SHAPES.DAT relocates to 0xa857 at load, which matters below.

One counter, weighted by what you kill

The tally is a single 24-bit counter at 0xeb13/14/15 (low byte 0xeb13, high byte 0xeb15). The scorer at 0x32e7, called from the ship-destruction handler at 0x31d5, adds the destroyed ship’s own value to it:

0x32e7  ; kill scorer
  LD HL,(0xeb13) : LD A,(0xeb15)     ; running kill-points total (24-bit)
  LD E,(IY+0x18) : LD D,(IY+0x19)    ; THIS ship's value, from its blueprint
  ADD HL,DE : ADC A,0                ; total += ship value
  LD (0xeb13),HL : LD (0xeb15),A

IY is the killed ship’s blueprint pointer. The lookup that gets there is:

ship destroyed -> A = ship type (shipblock + 0x1a)
  CALL 0x2fe9 :  HL = 0xa857 + type*2      ; blueprint pointer table at 0xa857
                 IY = (HL)                 ; IY -> this type's blueprint
  scorer      :  tally += (IY+0x18/0x19)   ; the type's 2-byte kill value

So the reward is a per-ship-type field at blueprint offset +0x18/+0x19, not a flat +1. It is effectively each ship’s bounty, doubling as its rank award. The pointer table lives at 0xa857 because that is exactly where SHAPES.DAT loads: its first bytes are the blueprint pointer table, relocated to absolute addresses at load.

The nine thresholds

The rank index is a single byte at 0xeb12, running 0 to 8, that only ever increases and is saved in the commander file. Promotion is checked on every kill against a threshold table at 0x336d:

  LD A,(0xeb12)         ; current rank index (0..8)
  ADD A,A               ; x2 (2 bytes per entry)
  LD HL,0x336d + idx    ; -> threshold table
  ... SBC HL,DE / SBC A,C   ; compare 24-bit tally vs threshold
  JR c,not_promoted
  LD HL,0xeb12 : INC (HL)   ; promote one rank
  -> print "Right On Commander!"  (msg 0x3396)

Because the compare is one notch at a time against monotonically rising thresholds, you can never skip a rank. The full table:

RankKill-points to reachhexbytes at 0x336d
Harmless0 (start)n/an/a
Mostly Harmless2,0480x00080008 00
Poor4,0960x00100010 00
Average8,1920x00200020 00
Above Average16,3840x00400040 00
Competent32,7680x00800080 00
Dangerous131,0720x02000000 02
Deadly655,3600x0A000000 0a
E-L-I-T-E1,638,4000x19000000 19

The name shown next to “Rating” comes from the 9-entry table at 0x0f25 (LD A,(0xeb12) : LD HL,0x0f25 : RST 0x10), high-bit-terminated strings Harmless … E-L-I-T-E.

The totals roughly double up to Competent (2k, 4k, 8k, 16k, 32k), then jump hard (131k, 655k, 1.64M). That final stretch alone is more than the entire climb before it. The old legend of “8, 16, … 6400 kills to Elite” is just these thresholds divided by 256, which assumes an imaginary “standard kill” worth 256 points; no real ship is worth that, so count in kill-points instead.

What every ship is worth

The kill-value field was read straight from live memory after SHAPES.DAT loaded, by walking the pointer table at 0xa857 and reading blueprint[type]+0x18 for each entry:

Kill-pointsShip or objectSpawns in play?
0Missile, Cargo Canister, Asteroid, Alloyyes (objects, no value)
3Orbit Shuttleyes (trader)
10Transporteryes (trader)
20Thargonyes
40Pythonyes (trader)
50Viper, Moray Star Boatyes (Viper is police)
60Cobra Mk1, Boa Class Cruiser, Mambayes
80Cobra MkIII, Sidewinderyes
90Escape Capsule, Anaconda, Krait, Geckoyes
100Fer-de-Lance, Adderyes
110Asp MkIIyes
150Thargoid, Wolf MkIIyes
500Space Stationonly in the invasion mission
1000Cougarmission only
5000Constrictormission only (unique)

Ships are spawned by writing the type to 0xf7e7. Traders come from 0x2bc7 (types 14 to 17) and 0x2b5b (28, 29); pirates from 0x3326/0x3331 (types 18 to 27); the police Viper (type 0x0c) from the periodic hostile launch at 0x3176; mission ships Cougar (30) and Constrictor (31) from the scripted table at 0x1f4f. Types 8 to 11 are not ships at all: they are the wireframe “E-L-I-T-E” letters in the intro demo (spawned by 0x1e1a as 8,9,10,11,8), which is why they carry value 0 and garbage names.

The spread among killable targets is over 1600 to 1. Non-combat objects and the peaceful traders at the bottom barely register, which is exactly why rank progress feels erratic and cannot be farmed on soft targets.

Which ships are worth your time (bang for buck)

Raw kill-points are only half the story. The blueprint also stores the combat stats that decide how much work each kill costs. The AI and combat code read these fields off the same IY blueprint pointer:

offsetfieldmeaningkey code
+0x0chull -> 0xf7e9laser hits to destroydamaged by SUB C at 0x297b, dies on borrow
+0x0dmax speedchase / flee / dodgeinit at 0x3068
+0x12behaviour &0x1c0 = passive trader, set = attacksAI gate 0x29fa
+0x13missiles &0x7f -> 0xf7earanged threatAI fires 0x29c4, self-zeroes 0x2a95
+0x18/19kill-valuekill-points + credit rewardscorer 0x32e7

A hit is a projected bounding-box test, not a stat lookup. Each frame the main loop at 0x1450 projects every ship (0x422e, which records the drawn half-width in 0xf800), then runs the laser at 0x28f6. The test at 0x4740 is:

|screenX| < size  AND  |screenY| < size   ->  hittable    ; size = (0xf800) = projected half-width

So hittability is angular size (model size over distance): a small ship is hard to hit at range and trivial point-blank, and orientation matters (edge-on is briefly smaller). The Sidewinder is the smallest, simplest model in the game (9 vertices, 15 edges), which is why it punches above its stats defensively.

Effort is laser-only by necessity: your Cobra carries at most 4 missiles (0xf776, capped at 0x5ed1) and cannot reload short of docking, so hull dominates the grind. Ranking by kill-points divided by effort, with

value = kill-points / ( hull * (1 + speed/40) )     ; speed 0 -> x1.0, 20 -> x1.5, 40 -> x2.0
shiphullspdmissptsvalueclass
Constrictor2554012750009.8mission (once)
Cougar25540710001.96mission (once)
Fer-de-Lance403011001.43pirate: big and easy to hit, fast, aggressive, rarer
Sidewinder40271801.19pirate: smallest ship, hard to hit at range, common
Mamba40321600.83pirate: fastest of the cheap trio
Cobra MkIII72303800.63trader (a crime to attack)
Anaconda112143900.60trader
Adder1122411000.56pirate
Thargoid2552011500.39hostile: richest farmable kill
Krait180300900.29pirate
Wolf MkII2554031500.29pirate
Asp MkII2004011100.28pirate: worst grind (tank plus fastest)
Gecko190302900.27pirate
Viper120300500.24police: small, low bounty

That produces three tiers:

The catch that governs everything: the best repeatable target is the Thargoid at 150 points against 255 hull (value 0.39), and Thargoids only appear in witchspace and missions. In day-to-day play the bread and butter is a pirate around 80 points. Here is what the climb costs at each rate:

To reachneeds (points)Thargoids at 150typical pirate at 80Orbit Shuttles at 3
Mostly Harmless2,0481426683
Deadly655,3604,3708,192218,454
E-L-I-T-E1,638,40010,92320,480546,134

So Elite is a roughly 11,000-kill climb no matter how you optimise, and over 20,000 on typical pirates.

Two things that look like bugs

“Right On Commander!” with no promotion

The promotion message is reached by fall-through from two different paths, and one of them does not touch your rank:

3313  JR c,0x331b          ; tally < threshold -> NOT promoted
3315  LD HL,0xeb12 : INC (HL) : JR 0x3320   ; promoted: bump rank, then print
331b  LD HL,0xe930 : INC (HL)               ; not promoted: bump a counter...
331f  RET nz                                ; ...return silently, UNLESS it wrapped 0xff -> 0x00
3320  LD HL,0x3396 : JP 0x39c8              ; print "Right On Commander!"  (shared)

0xe930 is bumped once per non-promoting kill, is never reset, and is never read anywhere except the commander-save at 0xa5c3. So every 256th non-promoting kill it rolls over, falls into the shared print, and you get “Right On Commander!” with no INC (0xeb12). It is most visible during the Deadly to Elite grind, where it fires dozens of times while your rating stays put.

“Stuck at Deadly”

There is no code path that traps the rank at Deadly. 0xeb12 is only ever incremented, never capped or reset, and the tally 0xeb13/14/15 is never clamped: its high byte can reach 0x19 (25), the Deadly to Elite threshold. Elite is fully reachable. It only feels stuck because that last step is 983,040 kill-points (more than the whole climb before it) and the phantom message above keeps firing throughout.

Legal status has nothing to do with kills. It is classified live at 0x62fc from the bounty byte 0xeb65:

Bounty 0xeb65Status
0Clean
1 – 49 (0x01–0x31)Offender
>= 50 (0x32)Fugitive

Names print from the table at 0x5aaa (Clean / Offender / Fugitive). You can be Deadly and a Fugitive at once, or Elite and squeaky Clean. For how that byte can be washed away in a few jumps or wiped by an escape capsule, see the exploits page.

How it compares to BBC Micro Elite

The thresholds are identical to the original BBC Micro Elite, and the top three even share the same high-byte constants (0x02, 0x0a, 0x19 = 2 / 10 / 25). The only difference is the implementation: BBC used inline CP compares in its STATUS routine, while this MSX port keeps the thresholds in the table at 0x336d and promotes incrementally. That is why an earlier search for CP 2/10/25 instructions came up empty here: the values live in data, not code.