Csci 210 Lab: Tetris II

(Laura Toma adapted from Eric Chown based on a lab developed at Stanford by Nick Parlante)

Overview

In this second part of the Tetris lab, you will implement class Board, the class that handles the game board. The player code is provided for you. You can find the Java code for this lab here.

Board

In the objected-oriented design of a tetris game, the board class does most of the work, that is:
  1. Store the current state of a tetris board.
  2. Provide support for the common operations that a client "player" module needs to build a GUI version of the game: add pieces to the board, let pieces gradually fall downward, detect various conditions about the board.
  3. Perform all of the above quickly. Our board implementation will be structured to do common operations quickly. Speed will turn out to be important.

The grid
The board represents the state of a tetris board. Its most obvious feature is the "grid" --- a 2D array of booleans that stores which spots are filled. The lower left-corner is (0,0) with X increasing to the right and Y increasing upwards. Filled spots are represented by a true value in the grid. The place() operation (below) supports adding a piece into the grid, and the clearRows() operation clears filled rows in the grid and shifts things down.

Widths and heights
The secondary "widths" and "heights" structures make many operations more efficient.

The widths array stores how many filled spots there are in each row. This allows the place() operation to detect efficiently if the placement has caused a row to become filled.

The heights array stores the height to which each column has been filled. The height will be the index of the open spot which is just above the top filled spot in that column. The heights array allows the dropHeight() operation to compute efficiently where a piece will come to rest when dropped in a particular column.

The main board methods are the constructor, place(), clearRows(), and dropHeight().

Board:: constructor

The constructor initializes a new empty board. The board may be any size, although the standard Tetris board is 10 wide and 20 high. The client code may create a taller board, such as 10x24, to allow extra space at the top for the pieces to fall into play (our player code does this).

As you know by now, a 2D array is really just a 1D array of pointers to another set of 1D arrays. To allocate the grid use new boolean[width][height].

Board:: int place(piece, x, y)

This takes a piece, and an (x,y), and sets the piece into the grid with the origin (the lower-left corner) of the piece at that location in the board. The undo() operation (below) can remove the most recently placed piece.

Return value: returns PLACE_OK for a successful placement. Returns PLACE_ROW_FILLED for a successful placement that also caused at least one row to become filled.

Error cases: It's possible for the client to request a "bad" placement -- one where part of the piece falls outside of the board or that overlaps spots in the grid that are already filled. Such bad placements may leave the board in a partially invalid state -- the piece has been partly but no completely added for example. If part of the piece would fall out of bounds return PLACE_OUT_BOUNDS. Otherwise, if the piece overlaps already filled spots, return PLACE_BAD . The client may return the board to its valid, pre-placement state with a single undo().

Board:: clearRows()

This method deletes each row that is filled all the way across, causing things above to shift down. New rows shifted in at the top of the board should be empty. There may be multiple filled rows, and they may not be adjacent. This is a complicated little coding problem. Make a drawing to chart out your strategy. Use JBoardTest (below) to generate a few of the weird row-clearing cases.

Implementation: The nicest solution does the whole thing in one pass --- copying each row down to its ultimate destination, although it's ok if your code needs to make multiple passes. Single-pass hint: the To row is the row you are copying down to. The To row starts at the bottom filled row and proceeds up one row at a time. The From row is the row you are copying from. The From row starts one row above the To row, and skips over filled rows on its way up. The contents of the widths array needs to be shifted down also.

By knowing the maximum filled height of all the columns, you can avoid needless copying of empty space at the top of the board. Also, the heights[] array will need to be recomputed after row clearing. The new value for each column will be lower than the old value (not necessarily just 1 lower), so just start at the old value and iterate down to find the new height.

Board:: int dropHeight(piece, x)

DropHeight() computes the y value where the origin (0,0) of a piece will come to rest if dropped in the given column from infinitely high. Drop height should use the heights[] array and the skirt of the piece to compute the y value quickly, in O(piece-width) time. DropHeight() assumes the piece falls straight down -- it does not account for moving the piece around things during the drop.

The board undo() abstraction

The problem is that the client code doesn't want to just add a sequence of pieces, the client code wants to experiment with adding different pieces. To support this client use, the board will implement a 1-deep undo facility. This will be a significant complication to the board implementation that makes the client's life easier. Functionality that meets the client needs while hiding the complexity inside the implementing class -- OOP encapsulation design in a nutshell.

The board has a "committed" state which is either true or false. Suppose at some point that the board is committed. We'll call this the "original" state of the board. The client may do a single place() operation. The place() operation changes the board state as usual, and sets committed=false. The client may also do a clearRows() operation. The board is still in the committed==false state. Now, if the client does an undo() operation, the board returns, somehow, to its original state. Alternately, instead of undo(), the client may do a commit() operation which marks the current state as the new committed state of the board. The commit() means we can no longer get back to the earlier "original" board state.

Here are the more formal rules.

Here is a little state change diagram which may help (or not).

Basically, the board gives the client the ability to do a single place, a single clearRows, and still get back to the original state. Alternately, the client may do a commit() which can be followed by further place() and clearRows() operations. We're giving the client a 1-deep undo capability.

Commit() and undo() operations when the board is already in the committed state don't do anything. It can be convenient for the client code to commit() just to be sure before starting in with a place() sequence.

Client code that wants to have a piece appear to fall will do something like following.

place the piece up at the top of the board 
 
undo 
place the piece one lower 
 
undo 
place the piece one lower
...
detect that the piece has hit the bottom because place returns PLACE_BAD or PLACE_OUT_OF_BOUNDS
undo
place the piece back in its last valid position
commit
add a new piece at the top of the board

Undo() is great for the client, but it complicates place() and clearRows(). Here is one implementation strategy.

Undo() implementation strategy: Backups

For every "main" board data structure, there is a parallel "backup" data structure of the same size. place() and clearRows() operations copy the main state to the backup before making changes. undo() restores the main state from the backup.

Widths and Heights
For the int[] widths and heights arrays, the board has backup arrays called xWidths and xHeights. On place(), copy the current int contents of the two arrays to the backups. Use System.arraycopy(source, 0, dest, 0, length). System.arraycopy() is pretty optimized as Java runtime operations go.

Swap trick: For undo() the obvious thing would be to do an arraycopy() back the other way to restore the old state. But we can better than that. Cool trick: just swap the backup and main pointers.

// perform undo...

System.arraycopy(xWidths, 0, widths, 0, widths.length);	// NO


int[] temp = widths;	// YES just swap the pointers
widths = xWidths;
xWidths = temp;

This works very quickly. So the "main" and "backup" data structures swap roles each cycle. This means that we never call "new" once they are both allocated which is a great help to performance. So the strategy is arraycopy() for backup, and swap for undo().

Grid
The grid needs to be backed up for a place() operation...

Simple: The simplest strategy is to just backup all the columns when place() happens. This is an acceptable strategy. In this case, no further backup is required for clearRows(), since place() already backed up the whole grid.

Less Simple: The more complex (faster) strategy is to only backup the columns that the piece is in ---- a number of columns equal to the width of the piece (you do not need to implement the complex strategy; I'm just mentioning it for completeness). In this case, the board needs to store which columns were backed up, so it can swap the right ones if there's an undo() (two ints are sufficient to know which run of columns was backed up).

If a clearRows() happens, the columns where the piece was placed have already been backed up, but now the columns to the left and right of the piece area need to be backed up as well.

As with the widths and heights, a copy-on-backup, the swap-pointers-on-undo strategy works nicely.

Undo() implementation strategy: Alternatives

You are free to try alternative undo strategies, as long as they are at least as fast as the simple strategy above. The "articulated" alternative is to store what piece was played, and then for undo, go through the body of that piece and carefully undo the placement of the piece. It's more complex this way, and there's more logic code, but it's probably faster. For the row-clearing case, the brute force copy is probably near optimal---too much logic would be required for the articulated undo of the deletion of the filled rows. The place()/undo() sequence is much more common than place()/clearRows()/undo(), so concentrating on making place()/undo() fast is a good strategy.

sanityCheck()

The Board has a lot of internal redundancy between the grid, the widths, the heights, and maxHeight. Write a sanityCheck() method that verifies the internal correctness of the board structures: that the widths and heights arrays have the right numbers, and that the maxHeight is correct. Throw an exception if the board is not sane -- throw new RuntimeException("description"). Call sanityCheck() at the bottom of place(), clearRows() and undo(). A boolean static called DEBUG in your board class should control sanityCheck(). If debug is on, sanityCheck does its checks. Otherwise it just returns. Turn your project in with DEBUG=true. Put the sanityCheck() code in early. It will help you debug the rest of the board. There's one tricky case: do not call sanityCheck() in place() if the placement is bad -- the board may not be in a sane state, but it's allowed in that case.

Performance

The Board has two design goals: (a) provide services for the convenience of the client, and (b) run fast. To be more explicit, here are the speed prioritizations.
  1. Accessors: getRowWidth(), getColumnHeight(), getWidth(), getHeight(), getGrid(), dropHeight(), and getMaxHeight() Ñ these should all be super fast. Essentially constant time.
  2. The place()/clearRows()/undo() system can copy all the arrays for backup and swap pointers for undo(). That's almost as fast as you can get.

Milestone ---JBoardTest (The Mule)

Once you have Board somewhat written, you can use the provided JBoardTest class ("the mule") for testing. The mule is just a GUI driver for the board interface. It lets you add pieces, move them around, place, and clear rows. In the spirit of "transparency" debugging, it also displays the hidden board state of the widths and heights arrays. Use the mule to try out basic test scenarios for your board. It's much easier than trying to observe your board while playing tetris in real time. You can also write your own debugging helper code such as a printBoard() method that prints out all of the state of the board to standard output -- sometimes it's handy to have a log you can scroll back through to see all the state over time. The "drop" button in our applications will only work if there's a straight-down vertical path for the piece to fall from an infinite height. If there's an overhang in the way above the piece, drop will not do anything (look at the JBoardTest source code for "drop").

Here I've set up a case to test clearing multiple rows. You can look at the JBoardTest sources to see how it's implemented. It's a fairly thin layer built on the board.