Back to the feed

Calculating Angles on the Z80: Doing the Impossible at 3.5 MHz

Epsilon Games(EpsilonAlexey)
5/26/2025
Development
Article image

Hello, friends! (Or just readers who are curious to peek under the hood of the good old retro games). How often do we recall that magical time when every kilobyte, every cycle of the Z80 processor on our beloved Speccy counted? We squeezed the maximum out of those 3.5 MHz so that magic would happen on screen – sprites moved, bombs exploded, enemies ran...

And what does it take for all of this to move not just anyhow, but smartly? For example, so that a bullet flies straight to the target? Or an enemy walks toward the player along the shortest path? Right – you need to know the direction. And direction is, essentially, an angle!

But here's the catch! Our good old Z80 is an integer processor. No fancy coprocessors, no native floating-point operations, sines, cosines, or, heaven forbid, arctangents! Calculating the angle between two points (or, well, a vector (dx, dy) – the difference of coordinates) is no easy task for it. It's not LD A,B or ADD HL,DE. It's something from higher mathematics that, it would seem, can't be handled at 3.5 MHz without slowdowns.

That's exactly why old games often used clever tricks, workarounds, and simplifications. After all, every extra millisecond spent on calculations means a drop in framerate, less responsive controls, or simply the game becoming boring because the enemies are dumb and can't properly find you.

But what if I told you that you can get a sufficiently accurate angle value for game purposes using only integer arithmetic and small tables? Without slowdowns and with the elegance worthy of a true assembly master! That's exactly what we'll talk about today. We'll go over the simplest (and crudest) approaches, hint at the complexities of more direct ones, and then I'll show how you can apply a bit of mathematics (but don't be scared, just a tiny bit!) and tables to make our Z80 calculate angles quickly and efficiently.

Get ready to dive into the world of integer magic!

Fast but crude... or accurate but slow?

How to calculate an angle?
How to calculate an angle?

When you need to find the angle of a vector (dx, dy) on the Z80, the first thought is "something simpler". The simplest thing is to look at the signs of dx and dy and determine which quarter (quadrant) the angle is in. Positive dx and dy? Then it's down-right (for Spectrum coordinates). Negative dx and positive dy? Down-left. This is instant, but gives accuracy within a 90-degree range, that is, +/- 45 degrees. For many tasks this is not enough. You can refine it by comparing the absolute values of dx and dy – this already gives 8 directions (45 degrees each). Fast, but still crude – like trying to aim while looking through a very blurry binoculars.

Quadrants and angles
Quadrants and angles

And what about the "direct" approach – angle = atan(dy / dx)? You need an arctangent! On a PC you take the atan2(x, y) function and you're done. On the Speccy? You could make an atan table and take a value from it by the index dy/dx. But there are two problems here:

1.
Dividing dy/dx on the Z80 is slow. Our processor can't divide "out of the box"; you need a subroutine, and that's tens or hundreds of cycles. We wanted speed, but we got slowdowns.
1.
How do you use dy/dx as an index for a table? The ratio dy/dx can range from zero to infinity! A 256-byte table can't possibly cover this entire range with the needed accuracy. Even if you cheat and calculate the angle only within 0-45 degrees (where 0 <= dy/dx <= 1), you still need to somehow scale this ratio, and for that you again need division or complex multiplication.

So the direct table-based atan(y/x) on the Z80 is neither the most elegant nor the fastest solution. We need to work around this division problem!

Our trick: logarithms and two tables

And here's our trick! Instead of calculating dy/dx (which involves division), let's recall the property of logarithms: log2(Y / X) = log2(Y) - log2(X). Division has turned into subtraction! And the Z80 does subtraction instantly. Bingo!

The idea is this:

1.
We have dx and dy. First we "normalize" them: make them positive (take the absolute value) and, if needed, swap them so that one coordinate (let's call it X) is always greater than or equal to the other (let's call it Y). This is needed to work only with angles from 0 to 45 degrees and to make the difference of logarithms convenient. We remember what transformations we made (the signs and whether there was a swap).
1.
Now we have positive X and Y, where X >= Y. We need to find log2(X) and log2(Y). And for that we have the first table: log2tab. It stores log2(number) for each number from 0 to 255, but not just like that, but multiplied by a scale (for example, 32) to preserve precision. We simply take log2tab[X] and log2tab[Y].
1.
Calculate the difference: log_difference = log2tab[X] - log2tab[Y]. This is fast subtraction!
1.
This "logarithm difference" is related to the ratio X/Y. The smaller the difference, the closer X and Y are (the angle is closer to 45 degrees). The larger the difference, the smaller Y is compared to X (the angle is closer to 0). We need a table that, based on this logarithm difference, tells us which angle between 0 and 45 degrees corresponds to it. This is our second table: atan2pow_tab. It stores angle values (scaled) indexed by the logarithm difference.

Total: Two small tables (256 bytes each), two lookups in the first table, one fast subtraction, one lookup in the second table – and we get a "base" angle in the 0-45 degree range! No slow division!

Putting it all together: the algorithm in plain terms

Here's how it looks as a whole, step by step:

1.
Input: We get dx and dy.
1.
Special cases: Check if dx=0 and dy=0 (zero vector, no angle) or if one of them is zero (angle 0, 90, 180, 270). Return the corresponding value quickly.
1.
Normalization: Take the absolute values x = abs(dx), y = abs(dy). Remember the signs of dx and dy (for example, in bits of a register). Compare x and y. If x < y, swap them (x = y_original, y = x_original) and remember that we swapped them (the "mirror" flag). Now we are guaranteed x >= y > 0, that is, both are not zero (except for the special cases we handled).
1.
Logarithm difference: Go to log2tab by index x, get logX_scaled. Go to log2tab by index y, get logY_scaled. Calculate k_scaled = logX_scaled - logY_scaled.
1.
Base angle: Use k_scaled as an index for atan2pow_tab. Take the value ang_0_45_scaled (a number 0-255 representing 0-45 degrees).
1.
Scaling: Scale ang_0_45_scaled to a 16-bit value (0-65535) that covers 360 degrees. Simply shift it left by 5 bits (<< 5). The result is a number representing the angle in 0-45 degrees, but now in 16-bit scale.
1.
Final assembly: Use the saved flags (signs of dx, dy and mirror) to "reflect" this 16-bit angle into the correct quadrant. If there was a "mirror" – subtract from 90 degrees (in 16-bit scale). If dx was negative – subtract from 180. If dy was negative – subtract from 360. This is done with simple 16-bit subtractions.

And here's our result – a 16-bit number that precisely represents the angle of the vector (dx, dy)!

Here's what the table generators look like in JS (just so it's clear what's in them) and an approximate implementation in Z80 assembly:

javascript
1// JS код для генерации таблиц
2const log2tab = new Uint8Array(256); 
3const atan2pow_tab = new Uint8Array(256);
4
5function prepareTables() { 
6  // log2 
7  let i = 0; 
8  for (i = 0; i < 256; i ++) { 
9    log2tab[i] = (i > 0) ? Math.floor(Math.log2(i) * 32) : 0; 
10  }
11
12  // atan(2^k)\
13  for (i = 0; i < 256; i ++) {\
14    atan2pow_tab[i] = (i > 0) ? Math.floor(Math.atan(Math.pow(2, - i / 32)) * 256 / (Math.PI / 4)) : 255;\
15  }
16}
17
18prepareTables(); // Генерируем таблицы 
19// Теперь log2tab и atan2pow_tab можно скопировать в ассемблерный код как байтовые данные
20

And here's the Javascript code for calculating the angle using our method.

javascript
1function logAngle(x, y) { 
2  // Получаем "чистые" значения x, y в диапазоне от 0 до 45 градусов 
3  if (x === 0 && y === 0) { 
4    // Частный случай для нулевого отрезка return 0; 
5  }
6  let is_x_neg = 0;
7  if (x < 0) {
8    is_x_neg = 1;
9    x = 0 - x;
10  }
11  let is_y_neg = 0;
12  if (y < 0) {
13    is_y_neg = 1;
14    y = 0 - y;
15  }
16  let is_mirr = 0;
17  if (x < y) {
18    // Exchange x and y
19    is_mirr = 1;
20    let t = x;
21    x = y;
22    y = t;
23  }
24  // Now X and Y equals to angle from 0 to 45\
25  let ang = 0;
26  if (y == 0) {
27    // Частный случай - суб-угол 0 градусов\
28  } else {
29    // Считаем угол по упрощённой формуле atan(2^k), где k = log2(x) - log2(y)\
30    ang = atan2pow_tab[log2tab[x] - log2tab[y]];
31  }
32
33  // Полученное значение помещаем в нужный квадрант\
34  // Чтобы сохранить точность, сдвигаем значение влево на 5 бит и тем самым расширяем до 16 бит
35
36  let ang16 = (ang << 5);
37  if (is_mirr) {
38    ang16 = 0x4000 - ang16;
39  }
40  if (is_x_neg) {
41    ang16 = 0x8000 - ang16;
42  }
43  if (is_y_neg) {
44    ang16 = 0x10000 - ang16;
45  }
46
47  //ang16 = ang16 & 0xff00; // Если нужна 8-битная точность
48
49  return ang16 * 360 / 65536; // Если нужно значение в диапазоне от 0 до 360
50}
51

And finally, fanfare! Our final Z80 Assembler code that does the same thing as the Javascript code above, but tailored for our beloved Speccy.

text
1; Calculate 11-bit angle value (aligned to 16 bit)
2; Input: HL = (Y1, X1), DE = (Y2, X2) - Start and end points of the vector, [0, 0xff], non-signed values.
3; Output: HL = angle value from 0 to 0xffff (0 = 0 degrees, 0xffff = 359.9 degrees), only the most 11 bits have value
4math_angle8x8
5	ld c, 0		; Keep flags here. C.0 = is_x_neg, C.1 = is_y_neg, C.2 = is_mirror
6	ld a, e
7	sub l		; A = dx
8	jr nc, .a1	; dx >= 0
9	; dx < 0
10	set 0, c	; is_x_neg = 1
11	ld e, a
12	xor a
13	sub e		; dx = 0 - dx
14.a1	ld e, a
15	; check dy
16	ld a, d
17	sub h
18	jr nc, .a2
19	; dy < 0
20	set 1, c
21	ld d, a
22	xor a
23	sub d
24.a2 ld d, a
25	or e	; A = dx | dy
26	jr z, .a3	; dx = 0, dy = 0, special condition
27	ld a, e
28	cp d
29	jr nc, .a5	; dx >= dy
30	; dx < dy, we need mirroring
31	; A = E here
32 	set 2, c
33	ld e, d
34	ld d, a
35	; D and E was exchanged
36.a5	ld a, d
37	or a
38	jr z, .a4
39	; All flags were set, let's calculate
40	ld l, e
41	ld h, log2tab >> 8	; Upper byte of the table address
42	ld a, (hl)			; A = log2(dx)
43	ld l, d
44	sub (hl)		; A = log2(dx) - log2(dy)
45	ld l, a
46	ld h, atan2pow_tab >> 8	; Upper byte of the table address
47	ld a, (hl)		; A = atan2pow_tab(log2(dx) - log2(dy))
48	; We got angle value from 0 to 45
49	; Now mirror this value back
50	ld l, a
51	ld h, 0
52	add hl, hl
53	add hl, hl
54	add hl, hl
55	add hl, hl
56	add hl, hl
57	bit 2, c
58	jr z, .a6
59	; Mirror 0..45 to 45..90 if flag was set
60	ld de, 0x4000	
61	ex de, hl
62	sub hl, de
63.a6	bit 0, c
64	jr z, .a7
65	; Mirror X
66	ld de, 0x8000
67	ex de, hl
68	sub hl, de
69.a7 bit 1, c
70	jr z, .a8
71	; Mirror Y
72	ld de, 0
73	ex de, hl
74	sub hl, de
75.a8	; Result in HL
76	; Main return point
77	ret
78.a3 ; Special case: dx = 0, dy = 0
79	ld hl, 0
80	ret
81.a4 ; Special case: dy = 0
82	ld hl, 0
83	jr .a6	; Let's use correct quadrant
84
85; Those tables should be aligned by 256 bytes in RAM
86	align 256
87log2tab
88	; Those values are calculated as floor(log2(i) * 32) where i = 0..255
89	db 0, 0, 32, 50, 64, 74, 82, 89, 96, 101, 106, 110, 114, 118, 121, 125
90	db 128, 130, 133, 135, 138, 140, 142, 144, 146, 148, 150, 152, 153, 155, 157, 158
91	db 160, 161, 162, 164, 165, 166, 167, 169, 170, 171, 172, 173, 174, 175, 176, 177
92	db 178, 179, 180, 181, 182, 183, 184, 185, 185, 186, 187, 188, 189, 189, 190, 191
93	db 192, 192, 193, 194, 194, 195, 196, 196, 197, 198, 198, 199, 199, 200, 201, 201
94	db 202, 202, 203, 204, 204, 205, 205, 206, 206, 207, 207, 208, 208, 209, 209, 210
95	db 210, 211, 211, 212, 212, 213, 213, 213, 214, 214, 215, 215, 216, 216, 217, 217
96	db 217, 218, 218, 219, 219, 219, 220, 220, 221, 221, 221, 222, 222, 222, 223, 223
97	db 224, 224, 224, 225, 225, 225, 226, 226, 226, 227, 227, 227, 228, 228, 228, 229
98	db 229, 229, 230, 230, 230, 231, 231, 231, 231, 232, 232, 232, 233, 233, 233, 234
99	db 234, 234, 234, 235, 235, 235, 236, 236, 236, 236, 237, 237, 237, 237, 238, 238
100	db 238, 238, 239, 239, 239, 239, 240, 240, 240, 241, 241, 241, 241, 241, 242, 242
101	db 242, 242, 243, 243, 243, 243, 244, 244, 244, 244, 245, 245, 245, 245, 245, 246
102	db 246, 246, 246, 247, 247, 247, 247, 247, 248, 248, 248, 248, 249, 249, 249, 249
103	db 249, 250, 250, 250, 250, 250, 251, 251, 251, 251, 251, 252, 252, 252, 252, 252
104	db 253, 253, 253, 253, 253, 253, 254, 254, 254, 254, 254, 255, 255, 255, 255, 255
105
106atan2pow_tab
107	; Those values are calculated as floor(atan(pow(2, - i / 32)) * 256 / (PI / 4)) where i = 0..255
108	db 255, 252, 248, 245, 241, 238, 234, 231, 227, 224, 220, 217, 214, 210, 207, 203
109	db 200, 197, 194, 190, 187, 184, 181, 177, 174, 171, 168, 165, 162, 159, 156, 153
110	db 151, 148, 145, 142, 140, 137, 134, 132, 129, 127, 124, 122, 119, 117, 115, 113
111	db 110, 108, 106, 104, 102, 100, 98, 96, 94, 92, 90, 88, 86, 84, 83, 81
112	db 79, 78, 76, 75, 73, 71, 70, 68, 67, 66, 64, 63, 62, 60, 59, 58
113	db 57, 55, 54, 53, 52, 51, 50, 49, 48, 47, 46, 45, 44, 43, 42, 41
114	db 40, 39, 38, 38, 37, 36, 35, 34, 34, 33, 32, 32, 31, 30, 30, 29
115	db 28, 28, 27, 26, 26, 25, 25, 24, 24, 23, 23, 22, 22, 21, 21, 20
116	db 20, 19, 19, 19, 18, 18, 17, 17, 17, 16, 16, 16, 15, 15, 15, 14
117	db 14, 14, 13, 13, 13, 12, 12, 12, 12, 11, 11, 11, 11, 10, 10, 10
118	db 10, 9, 9, 9, 9, 9, 8, 8, 8, 8, 8, 8, 7, 7, 7, 7
119	db 7, 7, 6, 6, 6, 6, 6, 6, 6, 5, 5, 5, 5, 5, 5, 5 
120	db 5, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 4, 3, 3, 3, 3
121	db 3, 3, 3, 3, 3, 3, 3, 3, 3, 2, 2, 2, 2, 2, 2, 2
122	db 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 1
123	db 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1
124

Why is this cool? In the end, what did we get by using this trick with logarithms and tables?

Speed! No slow division subroutines! All the magic is memory lookups and fast integer operations. The angle is calculated very quickly, without "slowing down" the game.

Size! Only 512 bytes for two tables, plus a bit of code. This fits most games, even on 48K ZX models.

Accuracy! We get an 11-bit angle value, which gives very decent resolution (about 0.175 degrees per unit). For game tasks this discreteness is more than enough! If you don't need such precision and want to fit into 8 bits (that would be about 1.4 degrees per unit), you can simplify the code a little more.

Elegance! Turning division into subtraction using logarithms is a beautiful mathematical solution, perfectly suited to limited hardware.

This algorithm is an excellent example of how you can solve seemingly complex tasks on the Z80 using smart mathematical ideas and efficient memory handling. It was exactly such discoveries that made it possible to create those very unforgettable games on the Speccy!

Conclusion So we've gone through this path – from crude quadrants and rough estimates to obtaining a sufficiently accurate angle value using tables of logarithms and arctangents of powers of two. We saw how a task that seems impossible without floating point and trigonometric functions "out of the box" is solved quickly and efficiently on our good old 3.5-megahertz Z80.

This algorithm is just one of many examples of the tricks and elegant solutions developers used (and still use!) for retro platforms. Every clock cycle, every byte of memory mattered. And instead of moping about the limitations, people came up with algorithms like these, turning apparent drawbacks into a reason for a creative approach.

It is in such tasks and their solutions that the whole beauty and art of developing for retro hardware manifests itself. It's not just coding, it's optimization at the edge of the possible, the search for non-obvious paths and the use of mathematical tricks that may have long been forgotten in the world of gigahertz and gigabytes.

I hope this article was for you not just a technical breakdown, but also a little journey into the world of those very solutions that made games on the ZX Spectrum so special. Perhaps you saw how you can apply this algorithm in your own projects? Or maybe you remembered some other clever ways of calculating angles or other functions on the Z80?

Don't hesitate to share your thoughts, ask questions, or tell about your discoveries in the comments under this post! Sharing experience is the most valuable thing in our small but friendly retro community.

Thank you so much for your attention! See you again in the world of retro programming!

With love for the Speccy and its magic,

P.S. All the code can be downloaded in our Git.

Comments (2)

Leave a comment

Log in to join the discussion.

Log in
E
Evgene L• 16.12.2025

<blockquote>направление – это, по сути, <em>угол</em>!</blockquote> Мне кажется всегда можно обойтись вектором. Причём, не нормированным, т.е. обойтись целочисленной арифметикой. Чтобы понадобился прям угол.. ну я даже не знаю, что за задача такая.

E
EpsilonAlexey Developer• 16.12.2025

Действительно, в чистом виде редко когда нужен именно угол. Разве что направление поворота какого-нибудь спрайта определить. Чаще всего нужны зависимые от угла штуки, например, sin и cos угла - чтобы посчитать компоненты вектора направления. Но иногда без него прям не обойтись. Например, если нужно протрассировать луч через игровое поле, или в алгоритме поиска кратчайшего пути по туннелю - там именно угол имеет решающее значение.