Recreating an old DOS game in C ++ 17
- Transfer
In 2016, I began work on a hobby project for reverse engineering the game Duke Nukem II and rebuilding its engine from scratch. The project is called Rigel Engine and is available in open source ( its page on GitHub ). Today, more than two and a half years later, on my engine you can already go through the entire shareware-episode of the original game with almost identical gameplay to the original. Here is a video with the passage of the first level:
What can he do? Rigel Engine works as a complete replacement for the original DOS (
It implements the game logic of all enemies and game mechanics from the Shareware episode, plus most of the menu system. In addition, you can import saved games and a high score table from the original game into it.
Moreover, the engine has advantages over the original:
So far I don’t think the Rigel Engine is completely “ready.” But this is a great stage in development and a good opportunity to write about the engine again (old posts published here and here ). Let's start by taking a look at the current state of the code and find out how I got to it.
At the time of writing, RigelEngine consists of 270 source files containing more than 25 thousand lines of code (no comments / empty lines). Of these, 10 files and 2.5 thousand lines are unit tests. A detailed breakdown of empty lines and comments is available here .
What is in all this code? A bit of common infrastructure and support functions, such basic things as rendering, and a bunch of small pieces of logic. In addition to all this, the largest parts are:
Of course, all this code needed to be written, and this leads us to the next question.
Although two and a half years have passed since the start of the project, I have not worked on it all this time. A couple of months I didn’t do a project at all, in some others I spent only a few hours on it. But there were times when I worked on the Rigel Engine quite actively. Looking at the commit schedule on Github, you can get an approximate idea of how my work was distributed over time:
According to the schedule, we see that 1081 commits were made to the master branch. However, even before the repository was created, I was working on a closed one, in which there were 247 more commits, which in total gives us 1328 commits. In addition, there were several prototype branches that I used for research and experimentation, but never combined with the main one; in addition, before merging, I sometimes compressed large commit stories into shorter ones.
I must also say that code writing was not the only part of the project - reverse engineering was another important part. I spent quite a few hours studying the disassembled code of the original executable in Ida Pro(in the free version), for taking notes, recording pseudocode and planning the implementation of the elements of my version. In addition, I actively tested the original game, launching it in DOSBox and on the original equipment (different machines 386 and 486 purchased on eBay). I collected test levels for separate observation of specific enemies and the study of game mechanics, recorded video clips using DOSBox, and looked through the frames frame-by-frame to confirm my conclusions made while studying assembler code. After the implementation of the enemy or game mechanics, I usually recorded a video clip from my version and compared it frame by frame with the original to confirm the accuracy of my implementation.
Here are some photos of my notes:
Reverse engineering camera control code. A large rectangle indicates the screen. The dashed lines show the areas that a player can move without moving the camera. If you're interested, the camera control code itself can be found here .
General notes to help you understand assembly code. Left - the order of updating the original game at a high level. On the right are notes on bit fields indicating the status of some game objects.
Transcription of assembler code into pseudocode. Usually I do it mechanically enough, transcribe without thinking about what the code is doing, and then use the version in pseudo-code to understand the underlying logic. And on the basis of it, I already come up with my implementation. See the finished code here .
Pseudo-code of a cleaned-up version of enemy logic. The headers indicate the state of the state machine, the code below explains what should happen in the respective states. It was created on the basis of raw pseudocode obtained by transcribing assembler code. Ready code can be found here .
In the end, the work on the project turned out to be very exciting, and I learned a lot from it: about reverse engineering, 16-bit x86 assembler, low-level VGA programming, strict restrictions that game developers had to face for PC games in the early 90's; in addition, I made many discoveries about the internal features of the original game and how strange and bizarre some of them were implemented - this topic in itself deserves a separate series of posts.
In addition to adding the last remaining functions and finalizing support for the registered version, I have several ideas for improving and expanding the capabilities of the Rigel Engine, not to mention cleaning and refactoring the code - as usual, the best way to create a software architecture becomes apparent only after the creation of this software is completed.
As for future improvements, here are some of the points that I thought about implementing:
I don’t have any roadmap for the future, so I can implement these points in any order. But before all this, the next step will be the integration of Dear ImGui to further assemble the options menu, which is not yet in the game; in addition, it will enable or disable the above improvements. In the end, I will say that I will be grateful for any assistance in working on GitHub !
What can he do? Rigel Engine works as a complete replacement for the original DOS (
NUKEM2.EXE
) binary . You can copy it to the game directory and it considers all the data from it, or specify the path to the game data as an argument to the command line. The engine is built and executed under Windows, Mac OS X and Linux. It is based on SDL and OpenGL 3 / OpenGL ES 2, and is written in C ++ 17. It implements the game logic of all enemies and game mechanics from the Shareware episode, plus most of the menu system. In addition, you can import saved games and a high score table from the original game into it.
Moreover, the engine has advantages over the original:
- No emulator or old hardware required, no settings needed
- No loading screens - select "new game" in the menu, press Enter, and immediately start the game
- Several sound effects can be played at the same time, which was impossible in the original
- There are no restrictions on the number of simultaneous effects of particles, explosions, and so on.
- Save files and highscore lists for each player
- Much more responsive menus
So far I don’t think the Rigel Engine is completely “ready.” But this is a great stage in development and a good opportunity to write about the engine again (old posts published here and here ). Let's start by taking a look at the current state of the code and find out how I got to it.
How much code is in the engine?
At the time of writing, RigelEngine consists of 270 source files containing more than 25 thousand lines of code (no comments / empty lines). Of these, 10 files and 2.5 thousand lines are unit tests. A detailed breakdown of empty lines and comments is available here .
What is in all this code? A bit of common infrastructure and support functions, such basic things as rendering, and a bunch of small pieces of logic. In addition to all this, the largest parts are:
- parsers / downloaders for 14 different file formats used in the original game - 2 thousand lines of code (LOC)
- behavior logic / game logic for 24 enemies / hostile objects - 3.8k LOC
- game logic for 14 interactive elements and game mechanics - 2k LOC
- player control logic - 1.2k LOC
- 154 configuration records (the health value of each enemy, the number of points received for collected items, etc.) - 1k LOC
- 31 specifications for destruction effects (effects triggered by the destruction of an enemy or other destructible object) - 254 LOC
- camera control code - 159 LOC
- game menu description language interpreter / cutscene - 643 LOC
- The HUD and other UI code is 818 LOC
- 5 screens / modes outside the menu, for example, the initial animation, bonus screen, etc. - 789 LOC
Of course, all this code needed to be written, and this leads us to the next question.
How much work did it take?
Although two and a half years have passed since the start of the project, I have not worked on it all this time. A couple of months I didn’t do a project at all, in some others I spent only a few hours on it. But there were times when I worked on the Rigel Engine quite actively. Looking at the commit schedule on Github, you can get an approximate idea of how my work was distributed over time:
According to the schedule, we see that 1081 commits were made to the master branch. However, even before the repository was created, I was working on a closed one, in which there were 247 more commits, which in total gives us 1328 commits. In addition, there were several prototype branches that I used for research and experimentation, but never combined with the main one; in addition, before merging, I sometimes compressed large commit stories into shorter ones.
I must also say that code writing was not the only part of the project - reverse engineering was another important part. I spent quite a few hours studying the disassembled code of the original executable in Ida Pro(in the free version), for taking notes, recording pseudocode and planning the implementation of the elements of my version. In addition, I actively tested the original game, launching it in DOSBox and on the original equipment (different machines 386 and 486 purchased on eBay). I collected test levels for separate observation of specific enemies and the study of game mechanics, recorded video clips using DOSBox, and looked through the frames frame-by-frame to confirm my conclusions made while studying assembler code. After the implementation of the enemy or game mechanics, I usually recorded a video clip from my version and compared it frame by frame with the original to confirm the accuracy of my implementation.
Here are some photos of my notes:
Reverse engineering camera control code. A large rectangle indicates the screen. The dashed lines show the areas that a player can move without moving the camera. If you're interested, the camera control code itself can be found here .
General notes to help you understand assembly code. Left - the order of updating the original game at a high level. On the right are notes on bit fields indicating the status of some game objects.
Transcription of assembler code into pseudocode. Usually I do it mechanically enough, transcribe without thinking about what the code is doing, and then use the version in pseudo-code to understand the underlying logic. And on the basis of it, I already come up with my implementation. See the finished code here .
Pseudo-code of a cleaned-up version of enemy logic. The headers indicate the state of the state machine, the code below explains what should happen in the respective states. It was created on the basis of raw pseudocode obtained by transcribing assembler code. Ready code can be found here .
In the end, the work on the project turned out to be very exciting, and I learned a lot from it: about reverse engineering, 16-bit x86 assembler, low-level VGA programming, strict restrictions that game developers had to face for PC games in the early 90's; in addition, I made many discoveries about the internal features of the original game and how strange and bizarre some of them were implemented - this topic in itself deserves a separate series of posts.
What's next
In addition to adding the last remaining functions and finalizing support for the registered version, I have several ideas for improving and expanding the capabilities of the Rigel Engine, not to mention cleaning and refactoring the code - as usual, the best way to create a software architecture becomes apparent only after the creation of this software is completed.
As for future improvements, here are some of the points that I thought about implementing:
- Smooth motion with interpolation. The game updates its logic about 15 times per second, and in the original game it is also the frame rate for rendering. On the other hand, the Rigel Engine can easily work with a frequency of 60 FPS and higher. At the moment, these additional frames do not give any advantages, but I think that they can be used for intermediate frames in order to realize smoother scrolling and movement of objects. The logic of the game will still work at the same speed, but the objects will move smoothly, and not “jump” with an increment of 8 pixels, as they are doing now. Earlier, I created a prototype of such a system, and it looks great, although it needs to be improved.
- Gamepad support. The original game has support for joysticks, and DosBox can emulate them on modern gamepads, but their setup can be difficult - preparation of the configuration and calibration in the game are required. Not to mention that not all controller buttons are supported, but to use the menu you still have to take the keyboard. Therefore, I believe that native controller support will significantly improve the gameplay.
- Sound enhancement. Currently, all sound effects have the same volume. Sound-producing objects, for example, force fields, become sharply audible when they hit the screen, and break off just as sharply. I was curious how they would sound if the volume of effects in the distance faded out. For example, we could barely hear the force field when it is not yet on the screen, and when approaching it would become louder.
- Remote camera / view most of the level. The game was not designed for this, so this opportunity can damage the gameplay - the player will begin to see enemies who are not active outside the screen, and the like. But I still wonder how it will look and play. In the end, players very often complained about this game for not being able to see a sufficient part of the level. It would be interesting to add the option to turn off the HUD or replace it with a more minimalistic one using transparency.
- Increase graphics resolution. This feature is often found in many ports / recreation of games, and it would be great to add it here. The engine already allows you to replace sprite graphics with your own images, but so far they cannot be of higher resolution, because everything is rendered into a small buffer with a subsequent increase in scale. First, you need to replace this approach so that scaling can be performed for individual objects.
I don’t have any roadmap for the future, so I can implement these points in any order. But before all this, the next step will be the integration of Dear ImGui to further assemble the options menu, which is not yet in the game; in addition, it will enable or disable the above improvements. In the end, I will say that I will be grateful for any assistance in working on GitHub !