Home > Game Development Walkthroughs > Tetris Revisited: Bells & Whistles 1

Tetris Revisited: Bells & Whistles 1


This is a follow-up from the 8-hour Tetris prototype article.

Now we have a working game prototype, I shall walk you through how to make a series of basic improvements. The full source code is available below (see the original article for other dependencies you need to install to compile the code; the game is based on my Simple2D graphics library).

Download: Source code (.cpp) | Source code (.h) | Executable

Time spent: 2.5 hours.

Graphical Tweaks

Grid

Modern Tetris implementations include a background grid to make piece placement easier. Drawing a grid is very easily achieved with two for loops: one to draw the horizontal lines and one to draw the vertical:

// Draw bucket grid
for (int y = 0; y < BucketSizeY; y++)
	renderer->LineWH(LeftSide, TopSide + y * SimpleTetris::BlockSize + 1,
					 BucketSizeX * SimpleTetris::BlockSize, 0,
					 Colour::DarkGray, 0.5f);

for (int x = 1; x < BucketSizeX; x++)
	renderer->LineWH(LeftSide + x * SimpleTetris::BlockSize, TopSide,
					 0, BucketSizeY * SimpleTetris::BlockSize,
					 Colour::DarkGray, 0.5f);

BucketSizeX and BucketSizeY contain the number of blocks that can fit in the bucket horizontally and vertically. SimpleTetris::BlockSize is the pixel width and height of each block. LeftSide and TopSide contain the top-left x and y positions of the bucket.

Rounded Pieces

To make the pieces look slightly less ugly, we change each block from being plotted as a normal rectangle to a rounded rectangle (a rectangle with curved corners). All we have to do is change the block drawing statement to call FillRoundedRectangleWH instead of FillRectangleWH, and add two arguments before the colour argument to specify the x and y radii of the corners:

renderer->FillRoundedRectangleWH(TetrisBucket::LeftSide + x * SimpleTetris::BlockSize,
						  TetrisBucket::TopSide + y * SimpleTetris::BlockSize,
					          SimpleTetris::BlockSize,
						  SimpleTetris::BlockSize,
						  SimpleTetris::BlockSize / 4,   // x radius
						  SimpleTetris::BlockSize / 4,   // y radius
						  Piece.ShapeColour);

Piece Silhouette

Modern Tetris implementations include a “silhouette” of the current piece. This is a greyed out rendering of the piece showing where it will land if left to fall in its current position.

The silhouette of a piece shows where it will land if it continues to fall from its current location.

To draw the silhouette in the right place, there are a couple of options. We could visualise the piece at the bottom of the bucket and move it up one row at a time until it is not touching any other pieces. A quicker-to-implement but less resource-efficient method is to simulate the piece falling until it can go no further. This is the approach I have used here. The current position of the piece is remembered, the simulation run, the piece drawn in outline and the original piece position restored.

// Draw a tetris piece's silhouette (at the lowest possible position it could fall)
void TetrisShape::DrawSilhouette()
{
	int oldPosY = PosY;

	MoveToBottom();
	Draw(false, true);

	PosY = oldPosY;
}

MoveToBottom() is exactly the same function that is called when the user presses the Fast Drop button when playing (which sends the piece immediately as far down as it will go). The arguments to Draw specify that we want the piece drawn in a silhouette outline. DrawSilhouette() is then called from the main scene drawing function on each frame.

Interface Additions

Pausing The Game

All single-player games should have a pause function. This is also very simple to add. The gameState variable stores the current game state (Menu, Playing, GameOver) etc. What is drawn and the available user actions depend on the game state. To allow pausing, we just add a new game state called Pause and provide a mechanism to toggle pause on and off in the part of the code which checks for user keypresses:

void SimpleTetris::OnKeyPress(WPARAM key)
{
	// Toggle pause
	if (gameState == Playing && key == 'P')
		gameState = Pause;
	else if (gameState == Pause && key == 'P')
		gameState = Playing;

The else if is important here. If we had just used another if without the else, the game could never be paused as the pause state would be reverted as soon as it was set.

Note that we only allow pause to be activated when the game is currently in progress – not, for example, on the menu screen. If the game is paused, we must also ignore other keypresses by adding a check for gameState before the code which handles the arrow keys and so on:

	// Only process keypresses if the game is in progress
	if (gameState != Playing)
		return;

	if (key == VK_LEFT)
		currentShape.MoveLeft();

	if (key == VK_RIGHT)
		currentShape.MoveRight();

	if (key == VK_DOWN)
		currentShape.MoveDown();
...

Finally, we must provide feedback to the user when the game is paused. We do this by adding some simple code that displays a message if the game is paused in the main drawing function:

	// Print pause message
	if (gameState == Pause)
	{
		gameOverFormat->SetTextAlignment(DWRITE_TEXT_ALIGNMENT_CENTER);
		Text(0, 200, "PAUSED", Colour::White, gameOverFormat);
	}

Note that we still want to render the actual game (the bucket, shapes etc.) even when the game is paused, so we don’t check for the pause state when drawing the rest of the game. Note also that printing the pause message must come after the rest of the scene has been drawn, otherwise it will get partially obscured by other drawn objects.

Main Menu and multiple games

The prototype version of Tetris started the game immediately when you ran the program, and the only way to retry when the game was over was to close the program and re-open it. This is not cool! Games should have menus and game over screens where applicable, and we achieve this by adding two more game states: Menu and GameOver.

When the program starts, the initial game state is set to Menu. The main drawing function chooses via an if statement whether to draw the main game screen or the menu, depending on the game state. Drawing the menu looks something like this:

// Display menu
if (gameState == Menu)
{
	gameOverFormat->SetTextAlignment(DWRITE_TEXT_ALIGNMENT_CENTER);
	Text(0, 50, "SimpleTetris", Colour::White, gameOverFormat);
	scoreFormat->SetTextAlignment(DWRITE_TEXT_ALIGNMENT_CENTER);
	Text(0, 115, "(c) Katy Coe 2012", Colour::White, scoreFormat);
	Text(0, 142, "www.djkaty.com", Colour::White, scoreFormat);

	FillRoundedRectangleWH(menuStartX, menuStartY, menuButtonSizeX, menuButtonSizeY, 20, 20, menuButtonColour);
	Text(0, menuStartY + 33, "Play", Colour::Yellow, scoreFormat);

	scoreFormat->SetTextAlignment(DWRITE_TEXT_ALIGNMENT_TRAILING);
	Text(0, ResolutionY - 30, "v1.1", Colour::White, scoreFormat);
}

The first 5 lines draw the title and copyright messages. The middle 2 lines draw a box that the user can click on to start a new game, and the last 2 lines draw the version number in the bottom-right corner of the screen.

Basic clickable button: the background colour of the Play button changes when the mouse hovers over it. Clicking on the button causes the game to start when the left mouse button is released.

The Play button has a colour of menuButtonColour. We want this colour to depend on whether the mouse is hovering over it or not, so we capture mouse movements using a Simple2D function hook as follows:

void SimpleTetris::OnMouseMove(int x, int y, WPARAM keys)
{
	if (gameState != Menu)
		return;

	// Toggle the menu button colour if the mouse is hovering over the rectangle
	if (x >= menuStartX && x < menuStartX + menuButtonSizeX
		&& y >= menuStartY && y < menuStartY + menuButtonSizeY)
		menuButtonColour = Colour::BlueViolet;
	else
		menuButtonColour = Colour::DarkBlue;
}

Note that mouse actions are ignored unless the game state is currently Menu. The code checks if the mouse pointer is within the Play button or not and sets its background colour appropriately.

We also need to capture mouse click events on the button:

void SimpleTetris::OnMouseButton(UINT button, int x, int y, WPARAM keys)
{
	if (gameState != Menu)
		return;

	if (x >= menuStartX && x < menuStartX + menuButtonSizeX
		&& y >= menuStartY && y < menuStartY + menuButtonSizeY
		&& button == WM_LBUTTONUP)
		newGame();
}

Once again the code ignores the mouse unless we are in the menu, checks if it is inside the Play button and calls a new function newGame() if the left mouse button has been released. Note we prefer to wait for the user to release the mouse button rather than starting the game as soon as they press it for ergonomic reasons.

We didn’t have to worry about re-initializing everything needed for the game when it could only be played once, but now we need to make sure everything is correctly prepared at the start of each new game, so we abstract this behaviour into newGame(), and remove it from the initial setup of the program, waiting instead until the user clicks on Play.

// Set up a new game
// Note this has to be called AFTER the brush resources are made
void SimpleTetris::newGame()
{
	int shapeNum;

	// Assign the first and second shapes (the screen shows what the next shape to drop will be)
	shapeNum = rand() % numShapeTypes;
	currentShape.SetShape(Pieces[shapeNum]);
	currentShape.Activate();

	shapeNum = rand() % numShapeTypes;
	nextShape.SetShape(Pieces[shapeNum]);

	// You are on level 1
	level = 1;

	// No score yet
	score = 0;

	// No lines cleared
	linesCleared = 0;

	// Empty the bucket
	bucket.Reset();

	// Set game state
	gameState = Playing;
}

The code should be fairly self-explanatory, except that unlike before we must also ensure the bucket is empty at the start of each game, which requires some new code in TetrisBucket as follows:

// Empty the bucket
void TetrisBucket::Reset()
{
	for (int y = 0; y < BucketSizeY; y++)
		for (int x = 0; x < BucketSizeX; x++)
			board[y][x] = -1;
}

When updating the game objects (namely, the moving piece), we now only want to do this if the game is in the Playing state, ie. not the menu, not paused and not game over. One if statement clears this up:

void SimpleTetris::UpdateObjects()
{
	// Do nothing if the game is over or paused
	if (gameState != Playing)
		return;

	if (currentShape.Update(level))
		newPiece();
}

Game Over

To test for the game over condition, we have to see if the next block that would be placed at the top of the grid collides with another block already present. This is done in newPiece() where a new piece is set up after the previous one has landed. Once currentShape has been updated with the new piece to put in play, we check for collisions with the bucket contents:

// If the new shape is already on another shape, it's game over!
if (bucket.IsCollision(&currentShape))
{
	// Set game over state
	gameState = GameOver;

	// We still want to show this shape, to show that the grid is full,
	// so we have to move it gradually upwards til there are no collisions
	while (bucket.IsCollision(&currentShape))
		currentShape.PosY--;
}

Note that we have to provide feedback to the user how the game ended, so to this end we still keep the new piece “in play”, and simply move it upwards beyond the top edge of the bucket until it is no longer colliding with anything. This allows the player to see visually that the bucket has overflowed. Remember, the game board is still drawn even in the game over state.

In the main drawing function, we provide additional feedback to the user when the game is over:

// Print game over message
if (gameState == GameOver)
{
	gameOverFormat->SetTextAlignment(DWRITE_TEXT_ALIGNMENT_CENTER);
	Text(0, 200, "EPIC FAIL", Colour::Green, gameOverFormat);
	scoreFormat->SetTextAlignment(DWRITE_TEXT_ALIGNMENT_CENTER);
	Text(0, 270, "Press space to continue", Colour::White, scoreFormat);
	scoreFormat->SetTextAlignment(DWRITE_TEXT_ALIGNMENT_LEADING);
}

Finally, we have to allow a game state transition from the game over state to the main menu via a user key press, which we add to the key press-handling function by checking for the space bar:

// Space pressed in game over state returns to the menu
if (gameState == GameOver && key == ' ')
	gameState = Menu;

Since the game essentially does nothing except draw the game board and game over message without updating any object positions or taking other user input once the game over state is entered, this essentially freezes the game until the user presses space, then causes it to go back to the main menu.

Gameplay Mechanics

Shift / Hold

The Shift / Hold mechanic of modern Tetris allows the player the option to switch the current falling piece for the piece shown in the Next Piece box. They can only do this once at a particular point, then may not use the switch feature again until the current (switched) piece has landed.

First we need to track if the user has switched out the current piece or not. We add a boolean variable held and set it to false both in newGame() and newPiece() (the latter is called when each new piece besides the very first one in a game comes into play).

Second, we accept a user key press (Shift key in this case) to do the switch, if it hasn’t already been used on this piece:

if (key == VK_SHIFT && !held)
	shiftShapes();

Performing the shift is conceptually simple – just swap which piece is in play – but in reality it is more problematic. First, the new piece must be in the same place in the bucket as the old piece. This not only means it has to be moved down to wherever the old piece was, but collision checking must be done and the piece moved around until it fits.

There are various solutions to the piece alignment problem. Here is what I came up with:

// Align a piece with the bottom-left of another piece (for shift/hold)
// Disallow if collision
bool TetrisShape::Align(TetrisShape other)
{
	PosX = other.PosX + other.firstUsedColumn() - firstUsedColumn();
	PosY = other.PosY + other.lastUsedRow() - lastUsedRow();

	// Check for out of bounds collisions and fix
	// We have aligned to the bottom and left so only the right-hand side
	// could be past the edge of the bucket

	if (PosX + lastUsedColumn() > TetrisBucket::BucketSizeX - 1)
		PosX = TetrisBucket::BucketSizeX - GetWidth();

	// Check for collisions with other pieces
	return !bucket->IsCollision(this);
}

TetrisShape::Align changes its own co-ordinates to be aligned to the shape specified in the argument according to the following rules:

  1. The first two lines make the left-hand side and bottom side of the two pieces line up in the bucket. This ensures that the switched piece will not be any lower down (but its top edge may be higher up) in the bucket than the original piece. It also ensures that it will not be off the left-hand edge of the bucket.
  2. This leaves the possibility that the switched piece might be off the right-hand side of the bucket. The next line of code moves the piece left so that its right-hand edge is aligned with the right-hand edge of the bucket, if this is the case.
  3. Finally we check to see if the switched piece is now sitting on top of any pieces already in the bucket, returning true if the piece can be placed and false if there is a collision.

Whether or not this matches how modern Tetris is actually implemented I don’t know, but it seems to work in a fluid way that feels natural.

The actual piece switching code called when the user presses Shift looks like this:

// Implement the shift/hold mechanic
void SimpleTetris::shiftShapes()
{
	// Align new current shape to be in the same place as the shifted shape
	bool ok = nextShape.Align(currentShape);

	// Disallow the shift if this causes a collision
	if (!ok)
		return;

	// Hold used this turn - don't allow again
	held = true;

	// Swap shapes
	TetrisShape tempShape;
	tempShape = currentShape;
	currentShape = nextShape;
	nextShape = tempShape;
	
	// Reset rotation of shifted shape
	nextShape.CurrentRotation = 0;

	// Activate new current shape
	currentShape.Activate();
}

Before swapping the pieces, we try to conceptually move the piece in the Next Piece box to be aligned with the current piece. If this is not possible because of a collision, nothing happens. Otherwise, held is set so the user can’t switch again until another piece comes into play, the pieces are swapped and the fall timer initialized on the new piece.

This would seem to be everything needed, but in fact we have now introduced a new problem: when a piece gets shifted into the Next Piece box, its previous position and rotation in the bucket is retained. This means that when we bring it into play, we have to move it back to the top of the bucket. We also have to reset the rotation of a shifted piece so it appears in the correct orientation in the Next Piece box. Similarly, if a shift is attempted but refused because of a collision, the refused piece takes on the current piece’s place in the bucket, so it must be moved to the top when it comes into play as well. The solution is simple:

// Set up a new piece
void SimpleTetris::newPiece()
{
	// Put the current shape in the bucket
	bucket.Add(currentShape, score, linesCleared, level);

	// Bring the next shape into play
	currentShape = nextShape;
	currentShape.MoveToTop(); // make sure piece is in the right place
	currentShape.Activate();

	// Shift/hold not used this turn
	held = false;
...
void TetrisShape::MoveToTop()
{
	// No rotation
	CurrentRotation = 0;

	// The X position of the shape will always be centerised in the bucket
	PosX = (TetrisBucket::BucketSizeX - 4) / 2;

	// The Y position of the shape must be such that the first solid block in the 4x4 grid appears on the top row of the bucket!
	PosY = -firstUsedRow();
}

This completes the implementation of the shift/hold mechanic.

Score Multipliers

Basic score multipliers are extremely easy to add to the mix. TetrisBucket::Add processes shapes that have finished falling and updates your score. By passing in a reference to the level the player is currently on, the score for filling 1, 2, 3 or 4 lines simultaneously can be scaled up relative to the level:

// Update score
linesCleared += newFills;

if (newFills == 1) score += 10 * level;
if (newFills == 2) score += 100 * level;
if (newFills == 3) score += 500 * level;
if (newFills == 4) score += 1000 * level;

newFills contains the number of lines that were filled (cleared) when the piece being processed landed.

Closing

I hope this gave some insight into how to add certain basic features to your games. Next time we will look at a modern solution to high score tables that is applicable to most generic games.

Advertisement
  1. No comments yet.
  1. No trackbacks yet.

Share your thoughts! Note: to post source code, enclose it in [code lang=...] [/code] tags. Valid values for 'lang' are cpp, csharp, xml, javascript, php etc. To post compiler errors or other text that is best read monospaced, use 'text' as the value for lang.

Fill in your details below or click an icon to log in:

WordPress.com Logo

You are commenting using your WordPress.com account. Log Out /  Change )

Facebook photo

You are commenting using your Facebook account. Log Out /  Change )

Connecting to %s

This site uses Akismet to reduce spam. Learn how your comment data is processed.

%d bloggers like this: