Lobsters retrocomputing - 03 Sep 2026

Page 4 of 5

The result of this multiplication is the new rotation matrix for the current ship, in player 2's frame of reference. In other words, the multiplication above is a rotation matrix representation of the transformation that we applied to the orientation vectors in the previous section, i.e. this one:

sidev_x -> [ side2v_x side2v_y side2v_z ] . [ sidev_x sidev_y sidev_z ]

sidev_y -> [ roof2v_x roof2v_y roof2v_z ] . [ sidev_x sidev_y sidev_z ]

sidev_z -> [ nose2v_x nose2v_y nose2v_z ] . [ sidev_x sidev_y sidev_z ]

roofv_x -> [ side2v_x side2v_y side2v_z ] . [ roofv_x roofv_y roofv_z ]

roofv_y -> [ roof2v_x roof2v_y roof2v_z ] . [ roofv_x roofv_y roofv_z ]

roofv_z -> [ nose2v_x nose2v_y nose2v_z ] . [ roofv_x roofv_y roofv_z ]

nosev_x -> [ side2v_x side2v_y side2v_z ] . [ nosev_x nosev_y nosev_z ]

nosev_y -> [ roof2v_x roof2v_y roof2v_z ] . [ nosev_x nosev_y nosev_z ]

nosev_z -> [ nose2v_x nose2v_y nose2v_z ] . [ nosev_x nosev_y nosev_z ]

It's worth taking a deeper look at what this multiplication of rotation matrices represents. When you multiply two rotation matrices, each of which represents a rotation, then the result is a rotation matrix that represents the combined rotation of those two matrices. So our calculation, which looks like this:

[ sidev_x sidev_y sidev_z ] [ side2v_x roof2v_x nose2v_x ]

[ roofv_x roofv_y roofv_z ] . [ side2v_y roof2v_y nose2v_y ]

[ nosev_x nosev_y nosev_z ] [ side2v_z roof2v_z nose2v_z ]

represents the combination of the current ship's rotation matrix (on the left) and the transpose of player 2's rotation matrix (on the right).

The current ship's rotation matrix represents the orientation of the current ship. You can think of it as a rotation, like this. If we're sitting at the origin as player 1, staring down the z-axis in the standard Elite setup, and instead we want to look in the same direction as the current ship that's out there somewhere in the bubble, then we can turn our head until it is pointing in the same direction as that ship (ignoring any physical constraints on our neck muscles). This is the rotation that's encapsulated in the current ship's rotation matrix - it's the head rotation that we would have to do in order to look in the same direction as the current ship.

The transpose of player 2's rotation matrix is slightly harder to visualise. Player 2's rotation matrix is orthonormal (i.e. normal and orthogonal) and a property of orthogonal matrices is that the transpose of the matrix is the inverse (i.e. the reverse) of the rotation. As we just discussed, a ship's rotation matrix represents how we would have to turn our head in order to align our viewpoint with that of the other ship, so if we apply this concept to player 2's ship, we see that player 2's rotation matrix represents our head rotation when moving from player 1's point of view (along the z-axis) to align with player 2's point of view.

The transpose of player 2's rotation matrix represents this rotation in the opposite direction. So applying the transpose rotation is the same as rotating the universe around us in the opposite direction, so instead of us turning our head to align with player 2's view, we rotate the entire universe in the opposite direction until we are aligned with player 2's point of view. And mathematically, applying the transpose rotation is done by multiplying by the transpose of player 2's rotation matrix.

This fits with our virtual reality thought experiment in the previous section. When we have pulled the universe towards us and we are in player 2's ship, we then rotate the universe around us to look through player 2's view. Mathematically, this is the same as multiplying each ship's orientation in the bubble by the transpose of player 2's rotation matrix. So this is exactly what our rotation matrix multiplication is doing; it's the same maths as the orientation vector calculation, it's just expressed differently.

The easiest place to see this in operation is in the simplified calculation for player 1's ship, where the current ship's rotation matrix (i.e. the first matrix in the calculation above) is the identity matrix, as player 1 is aligned with the axes. So in this simplified case, we rotate the ship by applying this rotation matrix:

[ 1 0 0 ] [ side2v_x roof2v_x nose2v_x ] [ side2v_x roof2v_x nose2v_x ]

[ 0 1 0 ] . [ side2v_y roof2v_y nose2v_y ] = [ side2v_y roof2v_y nose2v_y ]

[ 0 0 1 ] [ side2v_z roof2v_z nose2v_z ] [ side2v_z roof2v_z nose2v_z ]

In other words, in this simplified case, we just set player 1's orientation to the transpose of player 2's rotation matrix. In terms of the calculation, this gives us exactly the same result as in the previous section, when we were rotating the orientation vectors:

sidev_x -> side2v_x

sidev_y -> roof2v_x

sidev_z -> nose2v_x

roofv_x -> side2v_y

roofv_y -> roof2v_y

roofv_z -> nose2v_y

nosev_x -> side2v_z

nosev_y -> roof2v_z

nosev_z -> nose2v_z

This makes intuitive sense, as player 1's orientation within player 2's view will have the inverse orientation to player 2's orientation within player 1's view. And that inverse relationship is represented by transposing the rotation matrix.

In summary, the transpose matrix rotates us into player 2's frame of reference, and we apply this to the current ship's rotation matrix by multiplication, giving us the same rotation step as before, but in terms of rotation matrices rather than orientation vectors:

[ sidev_x sidev_y sidev_z ] [ side2v_x roof2v_x nose2v_x ]

[ roofv_x roofv_y roofv_z ] . [ side2v_y roof2v_y nose2v_y ]

[ nosev_x nosev_y nosev_z ] [ side2v_z roof2v_z nose2v_z ]

In both of these calculations, the dot product is at the heart of the transformation calculation, and at the heart of the dot product is a whole load of arithmetic, so let's look at that next.

The arithmetic behind player 2's view

-------------------------------------

Given that Elite does a lot of rotational geometry already, you might assume that we can use one of Elite's many dot product routines for our two-player calculations - perhaps we could use LL51 from the wireframe backface-culling routine, or maybe TAS3 and TAS4 from the tactics and docking routines.

The problem is that all of these routines are optimised for their specific uses; for example, LL51 scales one of its vector arguments so that the magnitude fits into one byte, discarding any data bits that are lost, and both TAS3 and TAS4 simply ignore the low bytes of their 16-bit orientation vector arguments. This makes for fast maths in an environment where we're only really interested in the sign of the dot product and where the value of the dot product occurs within a range, rather than needing to know the exact value.

Unfortunately for two-player Elite, we need all the accuracy we can get, as otherwise player 2's view will jump around too much for a proper combat experience. I know this because the first time I managed to get any meaningful output in player 2's space view, I used TAS3 for the dot product maths, as I was only interested in proving the validity of the algorithm. The test worked (after an awful lot of debugging) and player 1's ship appeared in player 2's view for the very first time, which was a pretty satisfying experience after a couple of weeks of exploding wireframes. But although player 1's ship was in the right place and pointing in the right direction, it did jump around pretty badly; you could literally make out the accumulation of rounding errors in the way the ship stuttered when rotating.

Two-player Elite therefore has its own maths routines that are built around the highest-accuracy multiplication routine from the original code, namely MULT3. This routine multiplies a signed 24-bit number and a signed 8-bit number, returning the result as a signed 32-bit number like this:

K(3 2 1 0) = (A P+1 P) * Q

Note that the word "signed" here refers to Elite's use of sign-magnitude numbers, as opposed to the two's-complement approach of normal 6502 assembly language. The highest bit contains the sign, while the rest of the number contains the (positive) magnitude.

Two-player Elite contains the following high-accuracy maths routines:

- Add24 adds two 24-bit sign-magnitude numbers and returns the result as a 24-bit sign-magnitude number.

- Multiply16x16 multiplies two 16-bit sign-magnitude orientation vectors and returns the result as a 32-bit sign-magnitude number.

- Multiply16x24 multiplies a 24-bit sign-magnitude coordinate by a 16-bit sign-magnitude orientation vector and returns the result as a 32-bit sign-magnitude number.

The multiplication routines break the calculation down into steps, each of which calls MULT3 to do the maths. So, for example, if we consider using Multiply16x24 to multiply a 16-bit sidev orientation vector by a 24-bit x-coordinate, then we calculate the magnitude of the result like this:

|sidev_x_hi sidev_x_lo| * |x_sign x_hi x_lo|

= |sidev_x_hi| * |x_sign x_hi x_lo|

+ |sidev_x_lo| * |x_sign x_hi x_lo| >> 8

and then we apply the correct sign to the result by setting the highest bit as follows:

x_sign EOR sidev_x_hi

We do each multiplication using MULT3, which calculates the following:

K(3 2 1 0) = (A P+1 P) * Q

where both (A P+1 P) and Q are sign-magnitude numbers. We therefore do the entire calculation in steps, like this:

- Do the following calculation in MULT3:

- Copy the result from to K(3 2 1 0) to XX15(3 2 1 0), so we have the following:

- Discard the low byte of the result in XX15 and round up XX15+1 if bit 7 of XX15 is set, so we have this:

- Do the following calculation in MULT3:

- Calculate the following in Add24:

- If bit 0 of sidev_x_lo is set, do the following:

- Apply the following sign bit to the result:

K(3 2 1 0) = |x_sign x_hi x_lo| * |sidev_x_lo >> 1| << 1

XX15(3 2 1 0) = |x_sign x_hi x_lo| * |sidev_x_lo >> 1| << 1

XX15(3 2 1) = (|x_sign x_hi x_lo| * |sidev_x_lo >> 1| << 1) >> 8

K(3 2 1 0) = |x_sign x_hi x_lo| * |sidev_x_hi|

K(3 2 1 0) = K(3 2 1 0) + XX15(3 2 1)

K(3 2 1 0) = K(3 2 1 0) + |x_sign x_hi x_lo|

sidev_x_hi EOR x_sign

Step 1 includes right and left shifts because MULT3 expects sign-magnitude arguments, but sidev_x_lo doesn't have a sign bit as it is the low byte of the 16-bit sign-magnitude number (sidev_x_hi sidev_x_lo). So we shift right to insert a sign bit of zero into bit 7 (thus keeping it positive), do the call to MULT3, and then shift the result back. We then cater for the loss of accuracy in step 6, where we add one more |x_sign x_hi x_lo| to the result if bit 0 of sidev_x_lo is set.

So in essence, steps 1 and 2 do this:

XX15(3 2 1 0) = |x_sign x_hi x_lo| * |sidev_x_lo|

which means step 3 does this:

XX15(3 2 1) = |x_sign x_hi x_lo| * |sidev_x_lo| >> 8

so step 5 does this:

K(3 2 1 0) = K(3 2 1 0) + XX15(3 2 1)

= |x_sign x_hi x_lo| * |sidev_x_hi|

+ |x_sign x_hi x_lo| * |sidev_x_lo| >> 8

which is what we want to calculate.

Given these more accurate arithmetic routines, we can wrap up the coordinate and vector rotation maths into two macros, and can include these in the source whenever we want to rotate vectors or coordinates.

The first macro, ROTATE_VECTORS_16, takes the following arguments:

ROTATE_VECTORS_16 c, v1, v2, v3, w1, w2, w3

This rotates a 16-bit orientation vector in [ v1 v2 v3 ] by another 16-bit orientation vector in [ w1 w2 w3 ] and stores the 16-bit result in the vector c. The calculation is the dot product:

c = [ v1 v2 v3 ] . [ w1 w2 w3 ]

= v1 * w1 + v2 * w2 + v3 * w3

where v1, v2, v3 are INWK offsets (so they point to orientation vectors in the current ship data block in zero page), w1, w2, w3 are XX3 offsets (so we need to place the other orientation vector into the XX3 table before using the macro), and c is an offset into the newVectors block (a block where we can store the vector results from multiple rotations). We repeat this calculation nine times to multiply two full sets of orientation vectors (i.e. two rotation matrices).

The second macro, ROTATE_COORDINATE_24, takes the following arguments:

ROTATE_COORDINATE_24 c, v1, v2, v3, c1, c2, c3

This rotates a 24-bit coordinate in (c1, c2, c3) by a 16-bit orientation vector in [ v1 v2 v3 ] and stores the 24-bit result in the coordinate c. The calculation is the dot product:

c = [ v1 v2 v3 ] . [ c1 c2 c3 ]

= v1 * c1 + v2 * c2 + v3 * c3

where c1, c2, c3 and v1, v2, v3 are INWK offsets, and c is an offset into the newCoords block (a block where we can store the coordinate results from multiple rotations). We repeat this calculation three times to multiply a ship's coordinates by an orientation vector.

Finally, we also need a DivideBy96 routine that divides a 24-bit number by 96. Elite uses 96 to denote the length of the unit vector, as this enables fractional vector lengths to be represented, so whenever we multiply by an orientation vector, we need to divide the result by 96 to maintain the correct scale factor. Both of the macros above end with this division, and luckily we can reuse Elite's DVID3B2 routine for this, which has an entry point at DVID3B that calculates the following:

K(3 2 1 0) = P(2 1 0) / (S R Q)

So in this case we don't need to roll our own function and DivideBy96 can just be a simple wrapper around a call to the DVID3B entry point with (S R Q) set to (0 0 96).

Target calculations

-------------------

One of the most important aspects of space combat is the ability to target your opponent with your sights, both for shooting lasers and for locking missiles onto targets. Not surprisingly, there's some work to be done to enable two different pilots to target each other (and each other's missiles, for those who prefer to blast enemy missiles out of the sky).

For player 1, nothing changes. The game's local bubble includes player 1 at the centre of the universe, and player 2 is spawned as a ship in slot #2. For two-player Elite, we can therefore reuse the single-player game's targeting routines for player 1, so the HITCH routine can be used to work out whether the ship in INWK is currently in player 1's crosshairs; similarly, the missile logic can be kept and used to manage player 1's missiles, as normal. You can read all about this process in the deep dive on being in the crosshairs.

But what about player 2? Can we just reuse the tactics code from the single-player game? After all, player 2 is spawned in the same way that NPC ships are spawned in the single-player game, and the tactics code works out whether or not an NPC player can hit the single player at the centre of the universe, so can we use this to detect whether player 2 can hit player 1?

The answer turns out to be no, because the code that works out whether an NPC's laser is on-target can get away with being considerably less accurate than the code that works out whether the player's laser is on-target. This is because in the original game, we can't see out of the NPC's cockpit, so we can't tell how accurately each NPC can point its laser.

It turns out that NPC lasers can be wildly off-target but can still register a hit; indeed, if you set player 2 to the AI Pilot in two-player Elite, you can see this in action, as the AI Pilot uses the exact same tactics code as in the original game; the AI Pilot's target can be quite a long way outside the laser sights but the tactics code still registers a hit. This can be changed to require more accuracy from the NPCs, but then you run into limitations in the part of the tactics code that moves NPC ships to point to their target, with the result that NPC ships hardly ever hit anything if you need them to line up their sights properly. For a real-world example of how the tactics routine is less than perfect, see the deep dive on the Elite Demonstration Disc, which applies the same tactics code to the self-playing demo, with predictably fatal consequences.

So for two-player Elite, we need to write our own targeting code for player 2. Luckily, there is a point where we can simply reuse the HITCH routine to work out whether player 2 is targeting another ship, and that's in part 5 of the DrawPlayer2View routine. By this point we have moved the current ship into player 2's frame of reference and have drawn it in player 2's view, so the INWK workspace is all set up with the current ship's coordinates, relative to player 2. So we can call HITCH at this point to see whether the current ship is in player 2's sights, and we can then process missile targeting and laser fire accordingly.

This approach works nicely for missiles, but it also works for player 1's ship. When we process slot #2 in DrawPlayer2View, which contains player 2's ship, the transformation process actually calculates the coordinates and orientation of player 1's ship in player 2's view, so we can call HITCH to work out whether player 2 is pointing at player 1. And because we are using the exact same routine to detect a hit, this makes the process equally fair for each player, and it has the added bonus of taking the ship's targetable area into consideration, so two-player ship choices automatically affect the ease of targeting your opponent.

For missiles, the only slight complication is the need to duplicate all of the missile status variables for player 2, so alongside variables like MSTG, which contains the current missile lock target (for player 1), we need to add variables like player2MSTG to track the lock target for player 2; see the miscellaneous section for more details.

Cheating with the sun and planet

--------------------------------

As discussed in the section on arithmetic, two-player Elite needs to be able to calculate dot products at a higher accuracy than the single-player game, otherwise close-quarters combat is a bit too jumpy. Unfortunately, even with the 24-bit accuracy of the new maths routines, the distant sun and planet can still wobble a bit too much for comfort, as any inaccuracies in the maths are amplified by the larger distances involved.

Luckily we can fix this with some good old smoke and mirrors, because the planet and sun are only there for aesthetic purposes; you can't fly towards them in two-player Elite, so they stay in the background and have no effect on gameplay (though they do have a major impact on immersion and finding your bearings, so they're still important). This lack of movement is achieved by a simple hack in part 6 of the MVEIT routine, so we only apply the player's velocity vector to close-by ships, and not to the planet or the sun.

(To make things a bit less of a mouthful, from now on I will only refer to the planet, but everything I say about the planet also applies to the sun. So when you see "planet", it means "planet and sun", just with fewer words.)

Next page | Previous page | More Lobsters retrocomputing | Headlines

Original: https://elite.bbcelite.com/hacks/two-player_elite/technical_information.html