My latest retro game project is by far my most ambitious game project I've ever started for an 8-bit system: an on-rails shooter that is about flying in narrow corridors like those segments in Terminal Velocity. Except fugly.
The system is still the same - an MSX1 with a Z80A for the processor.
So that you know - I'm not a demo coder, and I'm pretty sure that if they can stand reading this cblargh to the end, they'll be screaming themselves hoarse for the mistakes I've done.
Personally, I prefer on-rails shooters to open world games. I also wanted to create my first action game in ASM, and an on-rails shooter was an interesting idea in that sense. But how to go about doing one? And more importantly, how to make it run fast enough to keep it playable?
In the 80s, there were many different approaches to do a 3D game on 8-bit systems. Here's just a few:
The graphics limitations of the chosen platform come to front again. I wanted to make the player weave their way around in a narrow corridor, which eliminated the Space Harrier, Gyruss and Penguin Adventure as the examples. Wireframe graphics would've been a step too far for me at this point. And if I hadn't forgotten about Wolfenstein 3D, I don't think my skills would've been sufficient to utilize that approach to any playable result.
In the end, I chose to use two-colour characters in a system with results that look at first glance like the sprite scaler approach: thin frames moving towards the player.
As I said, I'm not a demo coder and I can't coax the system to perform miracles.
Since I was intending to use text mode again, copying the display data from RAM to video RAM (VRAM) wouldn't take as long as it would in the hi-res graphics mode. Given that MSX1 CPU has to push the bytes into video memory one byte at a time instead of the video processor copying it from RAM on its own (see DMA and "blast processing") or just sharing the memory, this helps me along a lot. In other words - I made the problem a lot smaller, from rendering 256*192 to rendering just 32*24. Spare some rows for score and such, and it's 32*20.
A small, nice number. Even better than scaling down from 1080p to 720p, don't you think?
... but the system has only 15 real colours that cannot be changed. The colours won't help me tell apart what's near the player and what is not. What to do, what to do...
Rastering and fog/darkness effect, that's what. If a character is further away, it has more pixels with the background colour (say, black). Remember the bar section in Streets of Rage 2? The semitransparency of the light cones was done there with every other pixel being opaque, and together with composite blurring the image, it looked reasonably good. But in my case, I'm relying on it a lot more. At the same time, I'm limiting the level to be effectively of only one colour. I'll just need to create a number of characters with varying number of black pixels.
Scene in Streets of Rage 2, taken in the Steam "SEGA Mega Drive & Genesis Classics", but with some unfortunate filtering on and manually cropping the image quite a lot.
There's actually a pretty nice amount of research and proven conventions to this in printing. What I'm using is essentially dithering by means of patterning: take a greyscale value in one pixel, upscale that to a group of pixels and lit up a specific pattern of them. There are actually some specific patterns that are/were used in printing, but given how I'm limited to 8x8 pixels, I'll have to do with that. Still better than the 4x4 pixel grayscale patterning in, say, Windows 3.x and its ordered dithering...
But how many options should there be? 7, 8, 64? In the end, I chose 8 instead of 7, because that wasn't as much of an increase in computations than going with 7, and multiplication by 8 is faster and easier than multiplying by 7. (In hindsight, I vastly overestimated the importance of this, as the way the data was stored in the memory changed a lot.)
Then I thought, "32*20 is a rather low resolution. How about increasing that?" The end result was that I split the characters vertically in half. This increased the number of characters I'd have to define to 8*8=64, which is still manageable; it's well below the cap of 256 and leaves plenty of space for capital letters and numbers.
At this point, I got the renderer up and running. The background updated one whole frame per second. Oops, I did it again.
No, this is not a video. Although it's got almost as much action as a recording of the proto. The red flying lizardbreath is the supposed player character.
You might be crying out, "1 FPS?! Absolutely unplayable with controls that delayed!" You'd be kind of wrong.
Even if I don't redraw the background for each refresh, the main thing is that the hardware sprites will still move smoothly, hopefully at either 50 or 60 FPS (PAL vs NTSC, you know). It's just the background's updates that'll be jerky. But hey, Konami didn't mind it (Knightmare, Nemesis, Salamander, ...) either back then. The worst part is that I can't soft-upgrade the game (at least not easily) for MSX2 or MSX2+ to use their hardware more efficiently, if I wanted to. I doubt this would satisfy TotalBiscuit, but I can set it for myself as a goal.
Back to talking about framerates.
In the 80s, tying game speed to framerates appears to have been more of a rule than an exception. That's (one reason) why the European releases of NES titles are often considered inferior to the NTSC/Japanese releases: they usually run slower and not at the "intended" pace. I still have to figure out how to make the game play the same on both types of systems without doubling every 5th or 6th frame. There's another problem still: if I synchronize the reading of player input with the interrupts that occur after each screen has been drawn (60 or 50 times a second), I must be done with reading the input, moving the sprites and such before the next frame, while also having rerendered in regular RAM a part of the screen for the next update.
Given that I need to be ready to read the joystick input as usual and to avoid skipped frames, I needed to distribute the rendering tasks over multiple refreshes. That's not really interesting, so I won't go deep into that. I only defined a number of "action points" the renderer can handle in one refresh before going to wait for the next refresh.
Then, the rendering. First, I used a front-to-back renderer that is more or less a raytracer. In hindsight, this was a very bad idea, and I should feel bad. And I do. But to keep up with the diary-style narrative, we'll go with this one. It should be obvious that recomputing where the ray lands in the next frame is stupidly costly in terms of CPU time. Instead, I created a table that tells "this X/Y coordinate lands in this X/Y coordinate in a frame at this distance" on a PC and included that in the program as-is. But at this point, the second part of this two-or-more-parter becomes relevant: space.
Until next time, with a bit more maths.
(EDIT: Rephrased parts for clarity)