One more game, one more post! My latest MSX game is called The Menace fro Triton, and it is a horizontal space shooter that takes inspiration from classics like Salamander or the Nemesis saga for MSX, as well as from more modern shooter games like Steredenn or Z-Exemplar. You can see a small game play video here:
I put a lot of work into this game, and I just wanted to explain a few of the interesting technical aspects I designed for it, just in case someone is interested. For this game, I set out to create an MSX game that used only 16KB of RAM, and with a cartridge of at most 48KB. I like setting constraints like these on the games I make for two main reasons: First, because it is fun! Without constraints, many things become easier (e.g., if I allow more RAM, I could have many memory buffers, making things easier; if I allow for MSX2, then I can use the additional capabilities of the MSX2 VDP to make the scroll easier, etc.), but I enjoy the "game" of figuring out if it's possible to make all I want to do within the constraints I set. Second (and most important), I have found that if I don't set a size limit on a game, I never finish them. My PC games go on forever in an endless spiral of adding more and more features. But with a small and finite amount of space, I know that as soon as the 48KB of the ROM are filled, that's it! No more content! So, that helps a lot in getting games actually finished. If I had decided to use a MegaROM, I'd probably would still be working on it, and at some point the project would get abandoned...
Anyway! While in previous games there was something obvious to talk about (3d rendering in ToP, content generation in XSpelunker and scroll in XRacing), the Menace from Triton does not have one key technical distinguishing feature, but it has a collection of smaller ones that might be interesting to explain. So, I wanted to talk about 4 technical details that at least I found interesting when designing this game:
- Text rendering routines: not sure if you noticed but the font in the game is not monospace. Each letter has a different width. This is a bit more complicated to set up than the usual "1 letter per tile", but allowed me to fit much more (and better looking) text!
- Content generation: I wanted to have lots of levels in the game, and I did not have enough space in the ROM for all of them. So, I resorted to content generation as a way to reduce space. The most interesting routine perhaps is the one that generates the star map.
- Scroll and Agendas: the core technique for smooth scroll is nothing to write home about (same idea as used in many other games before), but there are a few details that I find interesting such as the way tiles are precomputed to minimize space, and how updating all the different game elements (scroll, content generation, enemies, bullets, explosions, etc.) is orchestrated at run-time while maintaining the smooth scroll.
- Code compression: I really didn't want to do this, as it's a bit annoying to set up. But I ended up having to divide the source code into modules that are compressed in the ROM, and only decompressed when they are needed. This saved quite some space in the ROM, as, of all of my MSX games, this is the one that has the largest amount of source code, using too much space in the ROM.
Text Rendering in The Menace from Triton
- Enemy spawn probability: probability with which the land-based enemies are spawned in their marked positions in the map
- Bullet speed
- Fire rate for aggressive enemies, fire rate for medium aggressive enemies, fire rate for enemies that should not fire (even these fire in later levels of the game haha)
- Fire delay (minimum delay enemies need to wait before they can fire again)
- Enemy movement speed
- Base enemy hit points, hit points for medium enemies, hit point for large enemies
- Power pellet spawn probability (the probability with which normal enemies spawn power pellets), power pellet spawn probability for enemies that should spawn less power pellets.
- Target level length
- The basic technique is to use a semi-random walk on a 8-connectivity grid. The "canvas" where to draw the star chart is a 11*5 grid, with 8-connectivity (up/down/left/right/diagonals). Given a start and and end destination in this grid, a "semi-random walk" starts at the start position, and moves straight to the target position but with some small chance, it moves in a different direction than the shortest path.
- Generating the star chart with this "semi-random walk" is as simple as this:
- Step 1: defining the anchoring points
- point A: Ithaki
- point B: Aigai Nebula
- point C: a random point in a 4x4 space at the center of the canvas
- point D: a random point in the 4x2 top-left part of the canvas
- point E: a random point in the 4x2 bottom-right part of the canvas
- Step 2: generate the following semi-random walks, as shown in the figure below:
- Step 3: place a random planet type at each visited grid cell (there's 4 planet types: Moai, Water, Tech, Temple), and connectors in all the connections visited by the walks.
- The first boss (Polyphemus) is always spawned in the 3rd planet you visit, and the second and third bosses (Scylla and Charibdis) are spawned in random planets in two predefined columns of the map. The last boss (Triton) is always in the final planet, also called Triton.
Scroll and Agendas
- 27457 cycles (27457 max): Draw map
- 3794 cycles (3809 max): Update sprites in the VDP
- 849 cycles (2400 max): Choose next pattern for level generation
- 16314 cycles (20014 max): decompress chosen pattern for level generation (level patterns are compressed as they do not fit in RAM or ROM decompressed, so, when one needs to be used in the game, it has to first be decompressed)
- 7472 cycles (7472 max): copy decompressed pattern to the map buffer
- 2138 cycles (5327 max): background star field scroll
- 1157 cycles (1157 max): get keyboard/joystick input
- 2179 cycles (8275 max): update the player ship/options
- 6211 cycles (18047 max): update sprite-based enemies
- 2782 cycles (23575 max): update enemy bullets (max is high, as it includes handling the effect of bullets colliding with player/walls)
- ??? cycles (??? max): update tile-based enemies
- 3766 cycles (8180 max): update player bullets
- ??? cycles (??? max): update player secondary bullets (option bullets/missiles/torpedoes)
- ??? cycles (??? max): update tile-based explosions
- ??? cycles (??? max): update power pellets
- ??? cycles (??? max): update player/enemy/bullet/power pellet/explosion positions when scroll loops around in the circular buffer
- ??? cycles (??? max): music/SFX
- Draw map is only done in the even cycles
- The star field is only updated once every 4 cycles
- Player is updated at every frame (thus, player moves at a smooth 50/60 fps)
- Sprite-based enemies are only updated in the odd cycles (so, enemies move only at 25/30 fps)
- Tile-based enemies are only updated once each 16 frames
- Choosing the next PCG pattern and copying it to the circular buffer is spread across the 128 odd cycles:
- in cycle 1 we choose the pattern
- in cycle 3 we decompress it
- cycles 5, 7, 9, etc. we go through the new chosen pattern (2 rows at each cycle) and spawn any tile-based enemies that need to be spawned
- in cycles 131 and 133 the fully instantiated new chosen PCG pattern is copied to the circular map buffer (in 131 we make one copy, and in one edge case we need to copy the pattern twice; at the beginning and end of the circular buffer, for when scroll wraps around; this second copy if needed is done on cycle 133)
- During gameplay it is when I am more RAM constrained, as the scrolling engine needs some fairly large buffers. So, the in-game code had to be in ROM.
- An exception of this is while in boss-fights, as there is no more content generation going on, so, I don't need the content generation buffers.
- So, I chose a set of "core" routines (in-game code, music, graphics, etc.) and that went to the ROM: this was about 16KB-17KB.
- All the code for the intro/menu/star chart/password/weapon configuration menus is compressed in a single chunk. From 6291 bytes, this went down to 4232 bytes
- Each of the 4 bosses code is also compressed, and only decompressed right before the boss fight (you might have noticed a slight pause when the boss is spawned): the code for each of the 4 bosses went down from: 756 / 1003 / 1476 / 749 bytes to 593 / 712 / 949 / 548 bytes!
include "constants.asm"include "autogenerated/text-constants.asm"include "../triton.sym"keyboard_line_clicks: equ keyboard_line_state+1org NON_GAME_COMPRESSED_CODE_START; entry points:jp state_braingames_screenjp state_mission_screen_from_game_completejp state_mission_screen_from_game_failedjp state_gameover_screenjp generate_minimapjp enable_nearby_minimap_pathsjp get_minimap_pointerjp state_mission_boss_under_cursorinclude "state-story.asm"include "pcg-minimap.asm"include "state-title.asm"include "state-ending.asm"include "state-mission.asm"include "gfx-bitmap.asm"include "state-weapons.asm"include "state-gameover.asm"include "state-braingames.asm"include "state-password.asm"
Notice two things:
- See that "include "../triton.sym"" statement? So, I compile the main part of the code. Such compilation generates a symbol table (triton.sym), which is then included by the compressed code, so the compressed code can call any function from the main part of the code. I then compile the compressed code, compress it with pletter/aplib, choose the one that best compresses it, and add it to the ROM.
- The second part is that list of "jp ..." right before the list of includes that have the actual code. Those are the "hooks". Notice that the symbols present in the compressed code are not visible to the main code. So, I need a way to call those functions! So, if from the main part of the code, I want to call, say, the "generate_minimap" function, I just need to call it like this: "call NON_GAME_COMPRESSED_CODE_START+3*4", as each jump instruction uses 3 bytes. Of course, you I can define constants for these calls if I wanted, to make the code look prettier.
- Create placeholder (empty) files corresponding to the compressed code (e.g., empty files with the right name, so that the assembling process works)
- Compile the ROM (this just compiles the main part of the code, and includes the placeholder files instead of he actual compressed code). This also generates triton.sym.
- Compile the compressed code (using triton.sym), and compress it, overwriting the placeholders.
- Re-compile the ROM again, this time with the proper compressed code.