Here I describe some of the technical challanges and design choices I've dealt with when writing the game engine and designing the levels. The main goal for me was learning how to make a game engine from scratch. The final product would have looked much better going with an existing game-engine and openGL rather than CPU-rendering. I have a math background but very limited programming experience, I did some Python before but this is my first project in C/C++.
Design principles and goals
When starting out I had a number of things I wanted to achieve:
- Code everything from scratch in C/C++, no external libraries
- Over 100 puzzle levels
- Serious tone to the presentation
- Each puzzle should bring something new, no repeating puzzles
- Puzzles shouldn't require massive trial and error to solve, the player should be able to find the idea to solve them
- The setting of the levels (not the puzzles themselves) should follow Dante's poem pretty closely
- Each circle of hell should bring some new mechanic
- Each circle should have its unique color scheme, lighting scheme, textures
- Each circle ends with a "boss puzzle" requiring mastery of the mechanic
- Avoid puzzles requireing timing or fast inputs.
- Minimal UI
- Over 60fps on an average CPU
- Playable without instruction
- Playable with only keyboard or mouse
- All level creation from an ingame editor
- Coherent menu-system with many configurable options
- Quotes from Inferno before every level
- Colorize art for each section of the game
- Steam integration with achievements
While experimenting with Python and pygame in I made what would be the prototype for this game, depicted to the left below. The game world is represented by a 3-dimensional array of blocks, vertices are projected to the screen and using pygame.gfxdraw.filled_polygon I could draw simple 3d world to the screen. All surfaces are a solid colored and there's no lighting engine.
The game at this point ran with python at about 80fps. All movements are discreete, so the game is playable on a 10fps framerate at this point. Originally I used a fixed isometric perspective, but by keeping track of the projection of three basis vector it was possible to allow for rotation and zooming of the game-world allowing for any orthographic perspective
(meaning an infinitely zoomed in view from infinitely far away, making all blocks look the same size regardless of the distance to the camera).
Above is a picture of the level containing the gate to hell in the python version (left) and an early version of the c++ version (right). The main differance is the lighting engine and that some blocks are textured. The c++ version still runs at a higher fps.
The first step when moving to C++ was to make the graphics engine, and to make it run fast. Using something highly optimized like OpenGL would give the best results here, but wanting to learn how things works I decided to write the engine from scratch.
The pixels that are drawn to screen every frame are stored in an array of size screen_width*screen_height. Each pixels is represented by an unsigned 32-bit integer. This corresponds to a color, if a pixel value is 10ff2a (in hex) this corresponds to an RGB-color value of (10,ff,2a)=(16,255,42). This uses only 24 of the 32 bits, but is still a good way to do this considering how sequential bits of memory are read.
All game objects are represented by 3d-vectors, with the basic block being of size 1x1x1. To draw these 3d objects on a 2d screen we need a mathematical projection formula pr: R3->R2. To do this I keep track of the projection of the game-world basis vectors E1=(1,0,0), E2=(0,1,0), E3=(0,0,1). Let e1,e2,e3 be the projection of these. For example, an isometric perspective corresponds to
e1=(sqrt(3)/2,-1/2), e2=(-sqrt(3)/2,-1/2), e3=(0,1). Then since orthographic projections are linear we can simply define a function
pr(x,y,z):=x*e1+y*e2+z*e3, and only recalculate e1,e2,e3 when the perspective is changed.
With the projection function in hand we can draw a block at (x,y,z) drawing its six surfaces as filled polygons. For example, the top surface of the block has corners in game world coordinates (x,y,z+1),(x+1,y,z+1),(x+1,y+1,z+1),(x,y+1,z+1), so by projecting these four points we get the corresponding polygon corners on the 2d-screen. We still need primitive functions to actually draw a solid-colored 4gon on the screen though.
To draw a line parallell to the x axis, we simply loop through all the corresponding pixels in the pixel buffer and change the values to the appropriate color. A triangle on screen with one side parallell to the x-axis has corners in (x0,y0), (x1,y1), (x2,y2) where y0=y1. These can be drawn by drawing a line at each y-coordinate between y0 and y2, the start and endpoints being expressible in an algebraic expression in x0,x1,x2,y0,y2.
Any triangle can be split into two trianges of the form above (split it with a line parallell to the x-axis through the vertex with y-coordinate between the other two y-coordinates min(max(y1,y2),y3)). Finally any 4gon can be split into 2 triangles (or generally, any n-gon can be split into n-1 triangles), so this allows us to draw block faces to screen.
The game also has primitive functions for drawing 3d-lines, spheres, cones, and cylinders - the projections of these consist of basic elements like circles, ellipses, lines and triangles.