3
\$\begingroup\$

Here is a simple text-based version of connect four I made. I have been building this in an attempt to improve my Java skills (and possibly mention on my resume).

My goals with this project are as follows:

  1. Clean, optimize, and restructure: code, classes, and game logic based on feedback.

  2. Transform this program from text-based to graphics based using JavaFX

  3. Add computer AI logic to face a challenging opponent (assuming this is feasible to implement at my current skill level)

ConnectFour.java:

import java.util.HashSet;
import java.util.Set;

public class ConnectFour {
    private final int[][] gameBoard;
    private static final int ROWS = 6;
    private static final int COLUMNS = 7;
    private static final int RED = 1;
    private static final int YELLOW = 2;

    public ConnectFour(Player playerOne, Player playerTwo) {
        this.gameBoard = new int[ROWS][COLUMNS];
        //Initialize each position in the game board to empty
        for (int i = 0; i < ROWS; i++) {
            for (int j = 0; j < COLUMNS; j++) {
                gameBoard[i][j] = -1;
            }
        }
    }

    public boolean makeMove(Player player, int column) {
        /* Since row position is determined by how many pieces are currently in a given column,
         we only need to choose a column position and the row position will be determined as a result. */

        //Decrement the column value by 1, as our array is zero indexed.
        column--;

        //Check if the column chosen is valid
        if (column < 0 || column >= COLUMNS) {
            System.out.println("Column choice must be between positive and be no greater than 6!");
            return false;
        }

        //Check if the column chosen is already full
        if (isColumnFull(column)) {
            System.out.println("That column is already full!");
            return false;
        }
        /*Otherwise, start from the bottom of the column and change the value in
        the first open row to the player's number*/
        else {
            for (int i = ROWS - 1; i >= 0; i--) {
                if (gameBoard[i][column] == -1) {
                    gameBoard[i][column] = player.getPlayerNumber();
                    break;
                }
            }
            return true;
        }

    }

    public int validateGameBoard() {
        //1.) Check each row for four sequential pieces of the same color
        //2.) Check each column for four sequential pieces of the same color
        //3.) check each diagonal(with more than four spaces along it) for four sequential pieces of the same color
        //Return -1 if no current winner
        //Return 0 if the board is full, indicating a tie
        //Return 1 if player one wins
        //Return 2 if player 2 wins

        if (isBoardFull()) {
            System.out.println("The board is full!");
            return 0;
        }
        int checkRows = validateRows();
        int checkColumns = validateColumns();
        int checkDiagonals = validateDiagonals();
        if (checkRows == 1 || checkColumns == 1 || checkDiagonals == 1) {
            return 1;
        } else if (checkRows == 2 || checkColumns == 2 || checkDiagonals == 2) {
            return 2;
        } else {
            return -1;
        }
    }

    private int validateRows() {
        //System.out.println("Now validating rows");
        //To validate the rows we do the following:
        //1.) For each row, we select a slice of 4 columns.
        //2.) We place each of these column values in a hash set.
        //3.) Since hash sets do not allow duplicates, we will easily know if our group of 4 were the same number(color)
        //4.) We repeat this process for each group of four columns in the row, for every row of the board.
        for (int i = 0; i < ROWS; i++) {
            for (int j = 0; j < COLUMNS - 3; j++) {
                Set<Integer> pieceSet = new HashSet<Integer>();
                pieceSet.add(gameBoard[i][j]);
                pieceSet.add(gameBoard[i][j + 1]);
                pieceSet.add(gameBoard[i][j + 2]);
                pieceSet.add(gameBoard[i][j + 3]);
                if (pieceSet.size() == 1) {

                    if (pieceSet.contains(RED)) {
                        //Player One Wins
                        return RED;
                    } else if (pieceSet.contains(YELLOW)) {
                        //Player Two Wins
                        return YELLOW;
                    }
                }
            }
        }

        return -1;
    }

    private int validateColumns() {
        //System.out.println("Now validating columns");
        //To validate the columns, we use a similar hash set validation process to the row validation.
        // The key difference is, for every column, we select a slice of 4 rows.
        // each time we grab one of these slices, we check the hash set exactly the way we did the the row validator
        for (int j = 0; j < COLUMNS; j++) {
            for (int i = ROWS - 1; i >= 3; i--) {

                Set<Integer> pieceSet = new HashSet<Integer>();
                pieceSet.add(gameBoard[i][j]);
                pieceSet.add(gameBoard[i - 1][j]);
                pieceSet.add(gameBoard[i - 2][j]);
                pieceSet.add(gameBoard[i - 3][j]);
                if (pieceSet.size() == 1) {
                    //We have a winner
                    if (pieceSet.contains(RED)) {
                        //Player 1 Wins
                        return RED;
                    } else if (pieceSet.contains(YELLOW)) {
                        //Player 2 Wins
                        return YELLOW;
                    }
                }
            }
        }
        return -1;
    }

    private int validateDiagonals() {
        //Start by moving across the first row(left to right), and check all diagonals that can fit more than 4 pieces.
        //System.out.println("Now validating diagonals left to right");
        //Validating the diagonals is more involved than the last two validations:

        /*First, move across the first row, validating all left diagonals (diagonals which connect the top row to the
        left most column)*/
        //Note that not every diagonal will contain 4 positions, so we can skip such diagonals
        for (int i = 3; i < COLUMNS; i++) {
            int j = 0; // Check each left diagonal in the first row
            int k = i;
            while (k - 3 >= 0 && j + 3 < ROWS) {
                Set<Integer> pieces = new HashSet<>();
                pieces.add(gameBoard[j][k]);
                pieces.add(gameBoard[j + 1][k - 1]);
                pieces.add(gameBoard[j + 2][k - 2]);
                pieces.add(gameBoard[j + 3][k - 3]);
                if (pieces.size() == 1) {
                    if (pieces.contains(RED)) {
                        return RED;
                    } else if (pieces.contains(YELLOW)) {
                        return YELLOW;
                    }
                }
                j++;
                k--;

            }

        }

        /*Then we move down the right most column and validate each diagonal
        which connects this column to the bottom row*/
        //Note that our previous top row diagonal validator will have checked the fist column's diagonal already
        for (int i = 1; i < 3;i++) {
            int j = i; // set the row number to change with i
            int k = COLUMNS - 1;// only traverse the last column

            while (j + 3 < ROWS && k - 3 >= 0) {
                Set<Integer> pieces = new HashSet<>();
                pieces.add(gameBoard[j][k]);
                pieces.add(gameBoard[j + 1][k - 1]);
                pieces.add(gameBoard[j + 2][k - 2]);
                pieces.add(gameBoard[j + 3][k - 3]);

                if (pieces.size() == 1) {
                    if (pieces.contains(RED)) {
                        return RED;
                    } else if (pieces.contains(YELLOW)) {
                        return YELLOW;
                    }
                }
                j++;
                k--;
            }
        }

        //System.out.println("Now validating diagonals right to left");

        /*Now we repeat the above process, but begin by validating each right diagonal(diagonals which connect
        the top row to the rightmost column*/
        //Note we can again ignore diagonals that are shorter than 4 board positions
        for (int i = COLUMNS - 4; i >= 0; i--) {
            //Moving across the top row from right to left, validate each diagonal
            int j = 0; //Move across the first row
            int k = i;// set the column number to change with i

            while (j + 3 < ROWS && k + 3 < COLUMNS) {
                Set<Integer> pieces = new HashSet<>();
                pieces.add(gameBoard[j][k]);
                pieces.add(gameBoard[j + 1][k + 1]);
                pieces.add(gameBoard[j + 2][k + 2]);
                pieces.add(gameBoard[j + 3][k + 3]);

                if (pieces.size() == 1) {
                    if (pieces.contains(RED)) {
                        return RED;
                    } else if (pieces.contains(YELLOW)) {
                        return YELLOW;
                    }
                }
                j++;
                k++;
            }
        }

       /* Lastly, move down the leftmost column and check each diagonal which connects the left most column
        to the bottom row*/
        for (int i = 1; i < 3; i++) {
            //validate each diagonal here
            int j = i;// set the row number to change with i;
            int k = 0;// before entering the while loop, begin at the first column(column 0);
            while (j + 3 < ROWS && k + 3 < COLUMNS) {
                Set<Integer> pieces = new HashSet<>();
                pieces.add(gameBoard[j][k]);
                pieces.add(gameBoard[j + 1][k + 1]);
                pieces.add(gameBoard[j + 2][k + 2]);
                pieces.add(gameBoard[j + 3][k + 3]);

                if (pieces.size() == 1) {
                    if (pieces.contains(RED)) {
                        return RED;
                    } else if (pieces.contains(YELLOW)) {
                        return YELLOW;
                    }
                }
                j++;
                k++;
            }

        }
        return -1;
    }

    private boolean isColumnFull(int columnNumber) {
        /*Based on the way pieces are placed in a game of connect four, if the very first row of a column has
         a piece in it, the column must be full.*/
        if (gameBoard[0][columnNumber] == -1) {
            return false;
        } else {
            return true;
        }
    }

    private boolean isBoardFull() {
        //If any value in our board is -1, the board is not full
        for (int i = 0; i < ROWS; i++) {
            for (int j = 0; j < COLUMNS; j++) {
                if (gameBoard[i][j] == -1) {
                    return false;
                }
            }
        }
        //Otherwise the board is full
        return true;
    }

    public void printGameBoard() {
        System.out.println("==============================");
        //Display the number for each column
        System.out.println("1 2 3 4 5 6 7");
        for (int i = 0; i < ROWS; i++) {
            for (int j = 0; j < COLUMNS; j++) {
                if (gameBoard[i][j] == RED) {
                    System.out.print("R ");
                } else if (gameBoard[i][j] == YELLOW) {
                    System.out.print("Y ");
                } else {
                    System.out.print("- ");
                }
            }
            System.out.println();
        }
        System.out.println("==============================");
    }

    public void clearBoard() {
        //Reset all board positions to -1
        for (int i = 0; i < ROWS; i++) {
            for (int j = 0; j < COLUMNS; j++) {
                gameBoard[i][j] = -1;
            }
        }
    }
}

Player.java:

public class Player {

    private final String name;
    private static int counter = 0;
    private int playerNumber;

    //private Scanner scanner = new Scanner(System.in);

    public Player(String name) {
        //Initialize player number to increment based on how many instances there have been of the class

        this.name = name;
        this.counter++;
        this.playerNumber = counter;
    }

    public String getName() {
        return name;
    }

    public int getPlayerNumber() {
        return playerNumber;
    }

}

Main.Java:

import java.util.Scanner;

public class Main {

    private static Scanner scanner = new Scanner(System.in);
    public static void main(String[] args) {
      //Create two players and get their names from user input
        System.out.println("Welcome to Connect Four!");
        System.out.println("Player 1 please enter your name: ");
        String player1_name = scanner.nextLine();
        System.out.println("Player 2 Enter your name: ");
        String player2_name = scanner.nextLine();
        Player playerOne = new Player(player1_name);
        Player playerTwo = new Player(player2_name);
        System.out.println(playerOne.getName() + " will be red (R on the board)");
        System.out.println(playerTwo.getName() + " will be yellow (Y on the board)\n");
        ConnectFour connectFour = new ConnectFour(playerOne,playerTwo);
        connectFour.printGameBoard();
        System.out.println("\n");
        boolean hasWon = false;
        while(hasWon == false){
            System.out.println(playerOne.getName() + ", Please enter a column to make your move");
            int move =  scanner.nextInt();
            while(connectFour.makeMove(playerOne, move) == false){
                System.out.println("Please try again: ");
                move =scanner.nextInt();
           }
            connectFour.printGameBoard();
            int winner = connectFour.validateGameBoard();
            whoWon(winner);
            if(winner != -1){
                hasWon = false;
                break;
            }
            System.out.println(playerTwo.getName() + ", Please enter a column to make your move");
            move =  scanner.nextInt();
            while(connectFour.makeMove(playerTwo, move) == false){
                System.out.println("Please try again: ");
                move =scanner.nextInt();
            }
            connectFour.printGameBoard();
            winner = connectFour.validateGameBoard();
            whoWon(winner);
            if(winner != -1){
                hasWon = false;
                break;
            }
        }
    }

   private static void whoWon(int winner){
        if(winner == 0){
        System.out.println("It's a tie!");
        }

       else if(winner == 1){
           System.out.println("Player One wins!");
       }

       else if(winner == 2){
           System.out.println("Player Two wins!");
       }

       else{
           System.out.println("No winner yet!\n");
       }

   }

}
\$\endgroup\$

1 Answer 1

1
\$\begingroup\$

Based on your stated goals, I would offer the following suggestions.

Separate the game model from the game UI

You have a text-based game. You want a JavaFX based game. The text-based game is outputting messages to the console. When you have the JavaFX based game, you won't have a console, but will want to present game feedback in an entirely different way. Finally, if you have an AI which explores the game-space by playing a number of fake games starting at the current state of the board, you don't want any visible feedback - just an indication of whether the series of moves the AI had made results in winning or losing.

Separating the model from the UI will allow you to have all both a text-based game and a JavaFX game. It will allow you to write an AI which can play the game. It will do this because the game model will just maintain the state of the game, and determine when a player makes a winning move.

Maybe:

interface ConnectFour {
    enum Player { PLAYER_ONE, PLAYER_TWO }
    enum GameState { IN_PROGRESS, DRAW, WIN }

    void         reset();
    GameState    makeMove(Player player, int col) throws IllegalMoveException;
    List<Player> getColumn(int col);
}

Notice the interface doesn't have player names, nor does it assign colours to the players. That is a UI detail.

It also doesn't use weird numbers for status results, instead an enum is used. Any move is assumed to be valid; we don't need to return a boolean to indicate that the move was valid, so we can use the return to indicate if the player made the winning move. If PLAYER_ONE made the winning move, PLAYER_ONE wins. If PLAYER_TWO made the winning move, PLAYER_TWO wins. No need for separate enum values to distinguish the two at the game model. If the move is not valid, throw an exception. If the UI doesn't want to handle exceptions, then it should ensure the move is legal before asking the model to perform it.

Finally, we provide a function which will allow the UI to query the game board, so it can display it to the user. Perhaps as text. Perhaps as JavaFX elements. Or perhaps just to an AI which will have to process the information algorithmically. I've shown getting each column as a list. If a column has only two tokens, the list for the column will be length 2. No need to coerce EMPTY as some kind of fake Player enum value; empty locations are indicated by the shorter-than-maximum list length.

Then, you can write your UI's.

class ConnectFourConsole {
    ConnectFour game = new ConnectFourImpl();
    // ...
}

And,

class ConnectFourJavaFX {
    ConnectFour game = new ConnectFourImpl();
    // ...
}

Neither UI need worry about whether a player gets four-in-a-row horizontally, vertically, or diagonally. The model handles that.

Finally, your model implementation.

class ConnectFourImpl implements ConnectFour {
    // ... 
}

Implementation

Game Grid

The 2-d array for the game grid is fine, but I would use an enum for the data type. As an alternate, I like List<List<Player>> columns, where you can simply add Player tokens to the column's list as moves are made.

if (col < 0  ||  col >= columns.size())
    throw new IllegalMove("Invalid column");

List<Player> column = columns.get(col);

// Row we are about to play into (0 = bottom)
int row = column.size();

if (row == ROWS)
    throw new IllegalMove("Column Full");

// Move is valid.
column.add(player);

// Check for win by player, or full board, return
//   WIN, DRAW, or IN_PROGRESS respectively.

Checking for a Win

Adding 4 values to a Set and checking if the .size() is 1 is an interesting way of solving the "all 4 values match" problem. But it is may be easier to simply check if all 4 values match the player who just played. And it avoids the "4-blanks-in-a-row is not a win" issue, too.

With 6 rows, and 7 columns, the number of 4-in-a-rows you can get horizontally, vertically, or diagonally is (I think) 69. This is a lot of combinations to check. However, the only way the player could have achieved 4-in-a-row vertically is if it happened in the column that the player just played in. At the top. Exactly one possibility.

// Four-in-a-row Vertically?
if (row >= 3  && column.stream()
                       .skip(row - 3)
                       .allMatch(token -> token == player))
     return WIN;

The only way the player can win horizontally is if the the horizontal row is the row the player's piece landed in. At most 4 possibilities: the piece just added is at the start, 2nd, 3rd or last in the row of 4.

Diagonals are similarly constrained. The player's piece ended up at row,col. You just need to check row+i,col-i for i running from -3 to +3, as long as you don't fall off the game grid, which works out to at most 3 possible combinations. row-i,col+i gives at most another 3.

That reduces 69 four-in-a-row checks down to a maximum of 11, by only considering possibilities including the newly added piece.

Player.java

Your Player class has a final name, and a private static counter which is used to assign the player number when the Player is created. And comments indicate you return 1 if player one wins and 2 if player two wins.

What if you don't exit the game, but an new player wants to challenge the winner of the last match? Maybe this is JavaFX version. You need new Player(name) to allow the challenger to be named, which creates player.playerNumber == 3. Does your code still work? If so, your comments are unclear. If not, you've unnecessarily restricted your game to exactly two named players; if you want a different person to play, quit & restart the game!!!

Main.java

while(hasWon == false) {
   // Code to ask player 1 for move
   // Code to check for a winner, and exit
   // Code to draw board

   // Code to ask player 2 for move
   // Code to check for a winner, and exit
   // Code to draw board
}

Don't repeat yourself. There are two almost identical copies of the code in the while loop. Move the common code into a function.

while(hasWon == false) {
   processTurnFor(playerOne);
   // break if won
   processTurnFor(playerTwo);
}

Closer. But we are still explicitly handling playerOne and playerTwo. If we had a 4-player game, the code would still be ugly. Store the players in an array/list, and walk through the list, wrapping back to the start when you reach the end:

Player[] players = { playerOne, playerTwo };
player_index = 0;
while (hasWon == false) {
    processTurnFor(players[player_index]);
    player_index = (player_index + 1) % players.length;
}
\$\endgroup\$
2
  • \$\begingroup\$ Can you explain how the enums work in the interface? I know in other languages they represent a set of underlying constant integer values, but I don't think that's exactly what is happening here? Would the Player enum be made to replace my current Player.java class when it comes to using the players in the Implementation part of the code? Similarly, with Gamestate I understand there are three possible outcomes, but is it more than just a single integer value that is the underlying representation of each of those? \$\endgroup\$
    – dgr27
    Commented Oct 18, 2018 at 18:47
  • \$\begingroup\$ "Can [I] explain how enums works...?" No. That's beyond the scope of a Code Review, but see here. "Would the Player enum replace current Player.java class?" No. It doesn't have a name field, so your ConnectFourConsole would want its own Player class with a String name field and a ConnectFour.Player player field, so the Console can assign to one of its Players the ConnectFour.Player.PLAYER_ONE value. Rename the model's class from Player to Token to simplify the code by avoiding fully qualified class names. \$\endgroup\$
    – AJNeufeld
    Commented Oct 18, 2018 at 18:58

Not the answer you're looking for? Browse other questions tagged or ask your own question.