Search code examples
javacoding-style

When determining the winner in a Tic Tac Toe game, how do I avoid repetition?


Note: I am a beginner in Java (2 - 3 months of experience).

Doing a project on JetBrains/Hyperskill about making a Tic Tac Toe game, I found myself repeating quite a bit of code when trying to determine the winner of the game. To represent the game as a coordinate system (Thus 1,1 being at the bottom left and 3,3 at the top right) I am using a two-dimensional array. This is the function for determining the winner:

public String determineWinner() {
    int countX = 0; // amount of X's in a row
    int countO = 0; // amount of O's in a row

    for (int y = 0; y <= 2; y++) { // for all horizontal rows
        countX = 0;
        countO = 0;
        for (int x = 0; x <= 2; x++) { // loop through all x-coordinates
            String value = this.field[x][y]; 
            if (value.equals("X")) { // if the value at that coordinate equals "X", add 1 to the count
                countX++;
            }
            if (value.equals("O")) { // same here
                countO++;
            }
        }
        if (countX == 3) { // if the count is 3 (thus 3 X's in a row), X has won
            return "X wins";
        }
        if (countO == 3) { // same here
            return "O wins";
        }
    }

    // Same thing, but for all vertical columns
    for (int x = 0; x <= 2; x++) {
        countX = 0;
        countO = 0;
        for (int y = 0; y <= 2; y++) {
            String value = this.field[x][y];
            if (value.equals("X")) {
                countX++;
            }
            if (value.equals("O")) {
                countO++;
            }
        }
        if (countX == 3) {
            return "X wins";
        }
        if (countO == 3) {
            return "O wins";
        }
    }

    // Same thing, but for diagonal
    countX = 0;
    countO = 0;
    for (int i = 0; i <= 2; i++) {
        String value = this.field[i][i];
        if (value.equals("X")) {
            countX++;
        }
        if (value.equals("O")) {
            countO++;
        }
    }
    if (countX == 3) {
        return "X wins";
    }
    if (countO == 3) {
        return "O wins";
    }

    // Same thing, but for other diagonal
    countX = 0;
    countO = 0;
    for (int i = 0; i <= 2; i++) {
        String value = this.field[i][2-i];
        if (value.equals("X")) {
            countX++;
        }
        if (value.equals("O")) {
            countO++;
        }
    }
    if (countX == 3) {
        return "X wins";
    }
    if (countO == 3) {
        return "O wins";
    }

    if (this.getNumberOfMoves() == 9) { // if the number of moves equals 9, the game is over and it is a draw
        return "draw";
    }

    return "game not finished";
}

Currently, the code allows you to set a starting board (a starting arrangement for all the O's and X's) and then lets you do 1 move. After this, the game decides who is the winner or if it is a draw etc.

As one quickly notices, the function is way too long and it has quite a portion of repetition, yet I am unable to come up with any ways to shorten it.

Does anyone have any tips? Or any guidelines that apply to all code?


Solution

  • DISCLAIMER: Sorry if my answer started getting sloppy towards the end. Also, I have a code at the bottom showing all the things I talked about in action.

    I think the simplest thing I can say is to use more methods and possibly classes. Firstly, one of the ways to avoid repetition in all of your codes is to write them using object-oriented programming. This is the idea of having multiple classes that all interact with the main class to assist in writing code. I won't talk about that here, but if you are interested in making your code neat and "clean", I highly advise looking that up. Also, there is a great book on the subject called Clean Code by Robert C. Martin. I will simply be showing how you can take advantage of methods to shorten your code and clean it up. One of the things you repeat the most is this

    if (countX == 3) {
            return "X wins";
    }
    if (countO == 3) {
        return "O wins";
    }
    

    Your countX and countO are different each time, so you rewrote it. I simpler and more efficient way to do this is to use a method. I would advise you to research the syntax for Java in you don't know how to make methods or classes, but you do use the syntax for the determineWinner() method so I will assume you understand it. You can make functions have parameters that are essentially inputs that can be accessed and modified throughout the function. (By the way, you cannot make methods inside methods in Java so you would need to place this next method outside somewhere else in the class.)

    public String checkCounts() {
        if (countX == 3) {
            return "X wins";
        }
        if (countO == 3) {
            return "O wins";
        }
        else return "N/A";
    }
    

    *You want to check to see if it returns "N/A" anytime you use the method with an if statement. If so, you should just ignore it since no one won.

    whoWon = checkCounts();
    //In the code I put at the bottom I will make whoWon a global variable, which is why I'm not defining it here.
    //It will be already defined at the top of the code.
    if (!whoWon.equals("N/A")) return whoWon;
    

    *The ! symbol means not, a.k.a if whoWon does NOT equal "N/A", return whoWon.

    This way, anytime you need to write out that if statement code, you can just write checkCounts and plug in the two variables that you just got from your Array. You would write checkCounts(); in this case. Now if you just say return checkCounts(); then the code will run all those if statements without you having to type them all and return the result. You actually repeat something else a lot too. These couple of lines

    String value = this.field[x][y];
    if (value.equals("X")) {
        countX++;
    }
    if (value.equals("O")) {
        countO++;
    }
    

    are quite similar to these lines

    String value = this.field[i][i];
    if (value.equals("X")) {
        countX++;
    }
    if (value.equals("O")) {
        countO++;
    }
    

    and these lines

    String value = this.field[i][2-i];
     if (value.equals("X")) {
         countX++;
     }
     if (value.equals("O")) {
         countO++;
     }
    

    so you can condense them all down into one method with three different inputs. The method will return either 0, 1, or 2. The goal is to check which one it returns with the given string input and then translate that to which variable to add 1 to.

    If it's 0, ignore, if it's 1, countX++, and if it's 2, countY++.

    public int checkString(String value) {
        int whichCount = 0;
        //if whichCount is 1, it means X
        //if whichCount is 2, it means O
        if (value.equals("X")) {
            whichCount = 1;
        }
        if (value.equals("O")) {
            whichCount = 2;
        }
        return whichCount;
    }
    

    Switch statements might be a little advanced, but they're pretty simple in concept. It's a bunch of if statements all at once in a very convenient syntax. The value inside the parenthesis is your input, or what to check. The cases say, when its equal to this, do this. When you needed to increment either countX or countY inside your for loops, you would write

    switch (checkString(this.field[coord1][coord2])) {
        case 1 -> countX++;
        case 2 -> countO++;
    }
    

    case 1 says, if addToCount() returns 1 then do the thing to the right of the arrow and case 2 says if it returns 2 to the thing to the right of that arrow. In your for loops, coord1 and coord2 could be anything from [x][y] to [i][i] to [i][2-i] so you can change that anytime you make the switch statement.

    Additionally, you can turn that switch statement itself into a method.

    public void adjustCounts(String stringFromArray) {
        switch (checkString(stringFromArray)) {
            case 1 -> countX++;
            case 2 -> countO++;
        }
    }
    

    You can also take a couple of lines off by shorting your if statements. If the thing inside the if statement is only one line long than you can just put in next to it.

    if (bool) {
       doSomething();
    }
    //Change that to this
    if (bool) doSomething();
    

    Another thing you repeat a lot is this

    countX = 0;
    countO = 0;
    

    I just made a very simple method that does that with no parameters.

    public void resetCounts() {
        countX = 0;
        countO = 0;
    }
    

    That's pretty much it for repetition, but I would argue your determineWinner method is still far too large. Even if you don't repeat any more code, taking large changes of it and separating it into smaller bites can make it easier to read and understand.

    I added in a bunch of methods that just contained your for loops. They will be at the very bottom of this final class I came up with. It's 85 lines long so it's technically only a 4 line improvement but it's a lot cleaner. Additionally, if you were to embed this in your actual class, and not just in a single method (because you can't put it all in one method) then it would be even more efficient because you would have access to all of the classes global variables. Here is the code I came up with, but I would highly recommend doing extra research on object-oriented programming to really improve your code.

    public class TicTacToe {
        String[][] field = new String[3][3];
        int countX, countO = 0; // amount of X's and O's in a row
        String whoWon = "N/A";
    
        public int getNumberOfMoves() {return 0;} //Whatever you method did that determined this. Obviously it didn't really just return 0.
        public String determineWinner() {
            String columns = checkColumnsForWinner();
            String rows = checkRowsForWinner();
            String diagonal1 = checkDiagonal(1, 0);
            String diagonal2 = checkDiagonal(-1, 2);
    
            if (checkForNA(columns)) return columns;
            if (checkForNA(rows)) return rows;
            if (checkForNA(diagonal1)) return diagonal1;
            if (checkForNA(diagonal2)) return diagonal2;
            if (this.getNumberOfMoves() == 9) return "draw"; // if the number of moves equals 9, the game is over and it is a draw
            return "game not finished";
        }
        public String checkCounts(int countX, int countO) {
            if (countX == 3) return "X wins";
            if (countO == 3) return "O wins";
            else return "N/A";
        }
        public int checkString(String value) {
            int whichCount = 0;
            //if whichCount is 1, it means X
            //if whichCount is 2, it means O
            if (value.equals("X")) whichCount = 1;
            if (value.equals("O")) whichCount = 2;
            return whichCount;
        }
        public void adjustCounts(String stringFromArray) {
            switch (checkString(stringFromArray)) {
                case 1 -> countX++;
                case 2 -> countO++;
            }
        }
        public void resetCounts() {
            countX = 0;
            countO = 0;
        }
        public String checkRowsForWinner() {
            for (int y = 0; y <= 2; y++) { // for all horizontal rows
                resetCounts();
                for (int x = 0; x <= 2; x++) { // loop through all x-coordinates
                    adjustCounts(field[x][y]);
                }
                whoWon = checkCounts(countX, countO);
                if (!whoWon.equals("N/A")) return whoWon;
            }
            return "N/A";
        }
        public String checkColumnsForWinner() {
            for (int x = 0; x <= 2; x++) {
                resetCounts();
                for (int y = 0; y <= 2; y++) {
                    adjustCounts(field[x][y]);
                }
                whoWon = checkCounts(countX, countO);
                if (!whoWon.equals("N/A")) return whoWon;
            }
            return "N/A";
        }
        public String checkDiagonal(int mutiply, int add) {
            resetCounts();
            for (int i = 0; i <= 2; i++) {
                adjustCounts(field[i][i*mutiply + add]);
            }
            whoWon = checkCounts(countX, countO);
            if (!whoWon.equals("N/A")) return whoWon;
            return "N/A";
        }
        public boolean checkForNA(String string) {return !string.equals("N/A");}
    }
    

    In regards to Object-Oriented Programming, the best example I could see you put into practice in this example is Abstraction. This is a very general concept but I think it would help a lot in this case. In my program above, I have a TicTacToe class, and all of my code in it. The problem is, you are seeing a lot of boilerplate to get the code to run. The biggest example is the 2D Array object you have. You have to do so many things to get X's or O's out of it. It would be much better (opinion) to make a new class, maybe called Board. It would contain a private 2D Array object, and public methods to get values from that object. Additionally, (this is really just my opinion) I would recommend using an enumeration instead of Strings for you Array values. For example

    public enum BoardValues {
        X,
        O,
        EMPTY
    }
    

    You could then create a class to place these board values in essentially a 3x3 Grid.

    public class Board {
        private BoardValues[][] values = new BoardValues[3][3];
    
        public BoardValues getValue(int x, int y) {
            return values[x][y];
        }
    
        public BoardValues[] getRow(int rowNumber) {
            BoardValues[] rowValues = new BoardValues[3];
            for (int i = 0; i < values.length; i++) {
                rowValues[i] = getValue(i, rowNumber);
            }
            return rowValues;
        }
    
        public BoardValues[] getColumn(int columnNumber) {
            BoardValues[] columnValues = new BoardValues[3];
            for (int i = 0; i < values.length; i++) {
                columnValues[i] = getValue(columnNumber, i);
            }
            return columnValues;
        }
    
        public void setValues(BoardValues[][] values) {
            this.values = values;
        }
    
        public void setValue(int x, int y, BoardValues value) {
            values[x][y] = value;
        }
    }
    

    Now instead of using that pesky old 2D Array you just create a board object and set and get it's values at will when needed. Also, I didn't add in getting diagonals but you still could quite easily, mine's just for proof of concept. This is Abstraction, probably the easiest of the OOP concepts to grasp, because it's so general. I am simply obscuring information you don't need to see when you're trying to code your game.