Author: | Jakob Schmid |
---|---|
Commenced: | 2014-06-30 |
Language: | C++ |
Frameworks: | SDL 2.0 |
Tools: | Visual Studio, Clang, Vim, GrafX2 |
Note: this game is a clone of Tetris (1984) by Alexey Pajitnov.
In the late 80s - early 90s, before I got an Amiga, I had borrowed an Acorn Electron, a budget home computer from 1983, with a 1MHz 6502A and 32K RAM. There were plenty of games released for the machine, including some really great ports of 80s arcade games, not to mention the technical marvels of 'Elite', the seminal space sim by David Braben and Ian Bell, and the free-form action adventure 'Exile' with physics simulation by Peter Irvin and Jeremy Smith, both originally written for the Acorn. However, I didn't have any of those. I had two games: 'Arcadians', a great unlicensed clone of Galaxian, and 'Planetoid', an excellent Defender clone. I played Planetoid obsessively, and eventually ruined my Return key from shooting green aliens really fast.
Somewhere at my parent's house, there is a tape with a lot of bad BASIC games that I wrote in this period. My main motivation at the time was that I needed more games, forcing me to make them myself. The Electron came with the great BBC BASIC, and having programmed COMAL 80 for years, I quickly picked it up. I didn't really have any resources or people to ask, I figured most of the technical details out for myself. If the tape hasn't deteriorated, it should still contain, among others, a Pac-Man clone with horribly unfair AI, and a pretty cool Space Invaders clone.
Graphics was done the classic way by graphing paper and binary numbers. A slow process, but when your thing appeared on the screen, you felt pretty clever.
The Electron only had 1 sound channel, producing a beep of a given frequency and fixed amplitude. This primitive interface didn't stop me from composing a title theme for my Space Invaders clone by entering frequencies directly into the program. Now, saving was a horribly slow process of rewinding and recording the program to tape, a process that could take minutes! So when my Mom decided to test the main power switch, I had finished the title theme and not saved once.
...
I'm sure the second version I wrote was even better than the first one.
During this time, I went on a hiking trip to Norway with my dad. To avoid boredom, I started developing a Tetris game in my mind and wrote BASIC routines down on paper whenever we reached a cabin. When the trip was finished, I had a complete Tetris program.
When I got home, I typed the program into the machine, but alas - the whole design failed with this error [1]:
Too many FORs
The Electron had a fixed limit of nested FOR loops, which I had somehow broken, probably from iterating over tiles on the board and tiles in the tetrominoes. This would have forced me to rethink the design and make the program from scratch, but at that point I had lost interest, moving on to making other games instead.
Now, 20 or so years later, I spent a couple of days of my summer vacation to make my first actual Tetris game, for real this time.
I used C++ and the SDL 2.0 framework. I took me a day to get the basic game up and running, and a couple of days more to make something reasonably playable.
The graphics was inspired by the classic Tetris for the NES, and the game runs in 320x256 (scaled x3 by SDL).
Sound effects and a music replay routine was written from scratch:
I made the classic GameBoy Tetris theme 'Korobeiniki' by typing in note numbers from my memory of the theme (' _ ' means pause):
7, _, 2, 3, 5, _, 3, 2, 0, _, 0, 3, 7, _, 5, 3, 2, _, 2, 3, 5, _, 7, _, 3, _, 0, _, 0, _, _, _, _, 5, _, 8, 12, _, 10, 8, 7, _, 3, 5, 7, _, 5, 3, 2, _, 2, 3, 5, _, 7, _, 3, _, 0, _, 0, _, _, _,
which is then converted to frequencies:
frequency = base_frequency * 2^(note_number / 12);
The sound effect synth has three saw oscillators (for that brutal arcade sound) and implements this FM algorithm:
MODULATOR 1 -> MODULATOR 2 -> CARRIER
In version 0.6, I added drums, using enveloped sine generators for bass drum, a poor sounding 808-style cowbell, and the pitched part of the snare, and enveloped noise for hihats and snare. I got the noise algorithm from Blargg's emulation of the SNES SPC 700, as used in ZSNES and bsnes [3].
The game is playable now, complete with gradually increasing difficulty and high score saving. My teenage self would have been proud.
[1] | The manual for the BBC Micro, including the AcornSoft BASIC, can be found here. |
[2] | Wikipedia: FM Synthesis. |
[3] | Blargg's Audio Libraries. |
It has been fun to apply certain design patterns and technical ideas that has been on my mind lately on a real project.
Below are a few notes about the architecture of the game.
Warning: technical mumbo-jumbo!
The top-level game structure is derived from the MVC pattern:
Owner < > | .-------------+-------------. | | | Controller ----> State <---- Rendering write read
In this case, there are two state classes (game and input), with matching controllers, and two renderers, graphics and audio:
Owner < > | .-------------------------------------------. Input Ctrler | | write v .--------> Input <-----------. | read [State] read | | | | | Game Ctrler ------> Game <------- Renderer, write State read Audio
Game loop:
while () { input_controller.update(input); game_state.swap(); // swap double buffer game_controller.update_state(game_state, input); audio.update(game_state, input); renderer.render_screen(game_state); }
Double-buffering state enables analysis of changes between previous and current state:
class Game_state { struct State { int x = 4; }; State states[2]; State *current, *previous; Game_state() : current(&states[0]), previous(&states[1]) {} void swap() { if( current == &states[0] ) { states[1] = states[0]; // copy previous state current = &states[1]; previous = &states[0]; } else { // same as the above with 0 and 1 swapped } } }; input_controller.update(input); // Update state game_state.swap(); // prepare new state game_controller.update_state(game_state, input); renderer.render_screen(game_state);
Rendering can use derivations of state variables:
int dx = game_state.current->x - game_state.previous->x; if(dx != 0) play_sound();
This was my first game using the new 2011 version of C++. C++11 is a bit easier to use than previous versions, and it has been fun to check out a few of the new features.
Initializing a sequence contained within a class is easy with an initializer_list:
class Player { std::vector<int> seq; Player(std::initializer_list<int> seq) : seq(seq) {} }; Player theme( { 0, 1, 2, 3 } );
Returning a class or passing it to a function by value results in copying the data. If a class contains large amounts of data, this copying becomes a performance problem. Using a move constructor avoids copying by moving the data from one instance to another:
class Piece { int *data; Piece(int size) : data(new int[size]) {} // allocate // Move ctor Piece(Piece&& other) { data = other.data; // move data to this instance other.data = NULL; // other shouldn't free the memory on destruction } Piece(Piece& other) = delete; // don't use copy ctor void operator =(Piece& other) = delete; // don't use assignment ~Piece() { if(data) // don't delete when data moved to other instance delete[] data; } }; std::vector<Piece> pieces; pieces.push_back(Piece(3)); // Move construction avoids copying
Behaviour can be modified by setting a state function. The function type syntax is a bit hairy in C++, but works as expected:
class Game_controller { // Declare a new function type 'Mode_func' typedef bool (Game_controller::*Mode_func)(Game_state &, Input &); Mode_func mode_func; void set_mode(Mode_func new_mode) { mode_func = new_mode; } bool update_state(Game_state &game_state, Input &input) { return (this->*mode_func)(game_state, input); } // Modes: bool mode_run (Game_state &game_state, Input &input) { } bool mode_game_over (Game_state &game_state, Input &input) { } }; game_controller.set_mode(&Game_controller::mode_game_over); game_controller.update_state(game_state, input);
We start up the audio system:
SDL_AudioSpec want, have; // ... want.callback = audio_callback; want.userdata = this; dev = SDL_OpenAudioDevice(NULL, 0, &want, &have, 0); SDL_PauseAudioDevice(dev, 0); // start audio playing
The callback zeroes the buffer and then renders the different channels:
static void audio_callback(void *userdata, Uint8 * stream, int len) { Audio &a = *((Audio *)userdata); float * out = (float *)stream; int out_samples = len / sizeof(float); memset(stream, 0, len); // clear buffer a.fx.render (out, out_samples); a.theme.render(out, out_samples); a.bass.render (out, out_samples); }
Every instrument adds to the buffer instead of overwriting it:
void render(float *buf, int out_samples) { while (s < out_samples) { ph += freq; float o = sinf(ph * 6.28f); *(buf++) += o * amp; // left *(buf++) += o * amp; // right } }
Compilation on Mac OS X with clang:
Prerequisites:
libsdl 2.0 clang-3.4 libcxx
Install:
https://www.libsdl.org/download-2.0.php # sudo port install clang-3.4 libcxx
Normal compile:
clang++ -std=c++11 -stdlib=libc++ -framework SDL2 main.cpp
The executable created above only works on machines with SDL2 installed. For releases, we'll need to bundle SDL2 with our application.
The bundle is a directory hierarchy like this:
schmetris.app/ schmetris.app/Contents schmetris.app/Contents/Frameworks schmetris.app/Contents/Frameworks/SDL2.framework <- copy from /Library/Frameworks/ schmetris.app/Contents/Info.plist <- meta data schmetris.app/Contents/MacOS schmetris.app/Contents/MacOS/schmetris <- executable schmetris.app/Contents/Resources schmetris.app/Contents/Resources/tiles.bmp <- data files schmetris.app/Contents/Resources/font.bmp
A minimal Info.plist contains:
<?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"> <dict> <key>CFBundleName</key> <string>Schmetris</string> <key>CFBundleDisplayName</key> <string>Schmetris</string> <key>CFBundleIdentifier</key> <string>dk.schmid.schmetris</string> <key>CFBundleVersion</key> <string>0.2</string> <key>CFBundlePackageType</key> <string>APPL</string> <key>CFBundleSignature</key> <string>strs</string> <key>CFBundleExecutable</key> <string>schmetris</string> </dict> </plist>
Make release executable:
clang++ -c <- compile, don't link -std=c++11 <- C++11 -stdlib=libc++ -I../../include <- my includes main.cpp clang++ -rpath @executable_path/../Frameworks <- relative path from executable to SDL2 in bundle -framework SDL2 -lc++ main.o -o schmetris.app/Contents/MacOS/schmetris
In order to get compatibility with Windows XP using Visual Studio 2013, I enabled:
Properties:Configuration Properties:General:General:Visual Studio 2013 - Windows XP
My release executable depended on the following files from:
Microsoft Visual Studio 12.0\VC\redist\x86\Microsoft.VC120.CRT: msvcp120.dll <- VS2013 C++ library msvcr120.dll <- VS2013 C library
Also the SDL2.dll depends on:
msvcrt.dll <- C library (older version, found it in ``Windows/System32``):