8
\$\begingroup\$

I am still pretty new to Python. After spending time on Code Wars and copying a handful of projects from Al Sweigart's Big Book of Small Python Projects, I wanted to build something completely from scratch and settled on a version of Battleship versus the computer. I have the code working as I intended, but would appreciate feedback on anywhere I'm not following best practices, and any other feedback you feel is worth sharing with a beginner Python programmer.

My intent was to base the code on classic Battleship, so 5 ships of length 5, 4, 3, 3, 2 that can only be placed vertically or horizontally on a 10x10 grid. I wanted the computer to make random guesses until it hits, and then decide "like a person." I decided to give players the choice between selecting a randomized board and placing their ships manually. And I wanted to validate user input. I believe I've achieved these things based on how the code runs for me. I've been through a couple iterations and made it about as efficient as I can figure out at my level, I think.

import random, sys, time
EMPTY = ' '
SHIP = 'S'
HIT = 'X'
MISS = 'O'
SHIPNAMES = {'Aircraft Carrier': 5, 'Battleship': 4, 'Cruiser': 3, 'Submarine': 3, 'Destroyer': 2}
ROWS = 'abcdefghij'
COLS = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '10']

def main():
    print('''
Ready to play Battleship? Your opponent has placed the ships listed
below on their 10x10 board in secret. Their lengths are as shown,
and they may be placed vertically or horizontally. You will attempt
to hit their ships by entering coordinates in L# format, e.g. A1. Your 
opponent will also be working to find and sink your ships, so choose
wisely!

The ship types:
          ''')
    for name, length in SHIPNAMES.items():
        print('{}: length {}'.format(name, length))
    print('''
To begin, decide whether you would like to (P)lace your own ships or
(C)hoose from a series of auto-generated ship placements.''')
    playerBoard = []
    while True:
        boardSetup = input('   Input P or C >  ')
        if 'p' in boardSetup.lower():
            playerBoard = manualBoard()
            break
        elif 'c' in boardSetup.lower():
            playerBoard = chooseBoard()
            break
    print()
    time.sleep(1)
    print("Great! Let's get started. Your turn first!")
    print()
    time.sleep(1)
    aiBoard = getRandomBoard()
    playerGuesses = getEmptyBoard()
    aiGuesses = getEmptyBoard()
    playerLog = {'recentSink': [False], 'recentHit': '', 'recentHits': [], 'lastMove': (), 'ships': {}}
    aiLog = {'recentSink': [False], 'recentHit': '', 'recentHits': [], 'lastMove': (), 'ships': {}}
    while True:
        displayBoard(playerBoard,playerGuesses)
        print()
        playerGuesses, aiBoard, aiLog = makeMove(playerGuesses, aiBoard, aiLog, turn='Player')
        print()
        time.sleep(1)
        if aiLog['recentSink'][0] == True:
            print('Hit! You sank their {}!'.format(aiLog['recentSink'][-1]))
            print()
            time.sleep(1)
            if len(aiLog['recentSink']) == len(SHIPNAMES)+1:
                displayBoard(playerBoard, playerGuesses)
                print()
                print("That's the last one. Congratulations, you win!")
                sys.exit()
        elif aiLog['recentHits'] and aiLog['lastMove'] == aiLog['recentHits'][-1]:
            print('Hit! Nice job, keep it up!')
        else:
            print("Oof, that's a miss. Better luck next time!")
        print()
        time.sleep(1)
        print("It's the computer's turn. Let's see how it goes...")
        time.sleep(1)
        aiGuesses, playerBoard, playerLog = makeMove(aiGuesses, playerBoard, playerLog, turn='AI')
        print()
        if playerLog['recentSink'][0] == True:
            print('Hit! The computer sank your {}. Time for revenge?'.format(playerLog['recentSink'][-1]))
            if len(playerLog['recentSink']) == len(SHIPNAMES)+1:
                print("Oof, that's the last one. The computer wins, better luck next time!")
                response = input("Enter Y if you'd like to see the computer's board...")
                if 'y' in response.lower():
                    displayBoard(aiBoard)
                sys.exit()
        elif playerLog['recentHits'] and playerLog['lastMove'] == playerLog['recentHits'][-1]:
            print('The computer hit your {} at {}{}. Ruh roh!'.format(playerLog['recentHit'],ROWS[playerLog['recentHits'][-1][0]].upper(),COLS[playerLog['recentHits'][-1][1]]))
        else:
            print("The computer attacked {}{} and missed. You're safe this time!".format(ROWS[playerLog['lastMove'][0]].upper(),COLS[playerLog['lastMove'][1]]))
        time.sleep(2)

def getEmptyBoard():
    board = []
    for row in range(10):
        board.append([])
        for col in range(10):
            board[row].append(EMPTY)
    return board

def getRandomBoard():
    board = getEmptyBoard()
    ships = [(name, length) for name, length in SHIPNAMES.items()]
    while ships:
        currentShip, shipLength = ships.pop(0)
        while True:
            row, col = random.randint(0,9), random.randint(0,9)
            if board[row][col] != EMPTY:
                continue
            direction = random.randint(1,4)
            if direction % 2 == 0:
                val = 1
            else:
                val = -1
            coords = []
            if direction < 3:
                for val in range(shipLength):
                    if row+val < 0 or row+val >= len(board) or board[row+val][col] != EMPTY:
                        break
                    else:
                        coords.append((row+val,col))
                if len(coords) != shipLength:
                    continue
                else:
                    for r, c in coords:
                        board[r][c] = currentShip
                    break
            else:
                for val in range(shipLength):
                    if col+val < 0 or col+val >= len(board) or board[row][col+val] != EMPTY:
                        break
                    else:
                        coords.append((row,col+val))
                if len(coords) != shipLength:
                    continue
                else:
                    for r, c in coords:
                        board[r][c] = currentShip
                    break
    return board

def chooseBoard():
    while True:
        board = getRandomBoard()
        displayBoard(board)
        print()
        print('Enter Y to keep, N to generate a new board...')
        keepboard = input('> ').lower()
        if 'y' in keepboard:
            return board
        elif 'q' in keepboard:
            sys.exit()
        time.sleep(1)
        print()

def manualBoard():
    while True:
        print()
        print("Let's set up your board. At any time you may enter Q to quit, R to restart.")
        print()
        time.sleep(1)
        ships = {key[0]: key for key in SHIPNAMES.keys()}
        board = getEmptyBoard()
        while ships:
            displayBoard(board)
            print()
            print('Choose which ship to place from below by entering its first letter.')
            for key, name in ships.items():
                print('   {}: '.format(key), end='')
                print(name)
            while True:
                choice = input('>  ')[0].lower()
                if choice == 'r':
                    break
                if choice == 'q':
                    sys.exit()
                if ships.get(choice.upper()):
                    break
                else:
                    print()
                    print('Invalid input, try again.')
                    print()
                    time.sleep(0.5)
            if choice == 'r':
                break
            ship = ships.pop(choice.upper())
            print()
            print('Choose the starting coordinate for this ship, e.g. A5.')
            row = ''
            col = ''
            start = ''
            while True:
                choice = input('>  ').lower()
                if choice == 'r':
                    break
                if choice == 'q':
                    sys.exit()
                if len(choice) > 4:
                    print()
                    print('Input is too long, try again.')
                    print()
                    time.sleep(0.5)
                    continue
                row = ''
                col = ''
                for char in choice:
                    if char.isalpha():
                        row += char
                    elif char.isnumeric():
                        col += char
                if row in ROWS and col in COLS:
                    start, row, col = choice.upper(), ROWS.find(row), COLS.index(col)
                else:
                    print()
                    print('Input is invalid, try again.')
                    print()
                    time.sleep(0.5)
                    continue
                if board[row][col] != EMPTY:
                    print()
                    print('You already have a ship there! Try again.')
                    print()
                    time.sleep(0.5)
                    continue
                else:
                    break
            if choice == 'r':
                break
            print()
            print('Choose the direction to place the ship from {}. (W)'.format(choice))
            print('                                             (A)(S)(D)')
            direction = ''
            while True:
                choice = input('>  ')[0].lower()
                if choice == 'r':
                    break
                if choice == 'q':
                    sys.exit()
                if choice in 'wasd':
                    if choice == 'w' and row - (SHIPNAMES[ship]-1) >= 0:
                        clear = True
                        for val in range(SHIPNAMES[ship]):
                            if board[row-val][col] != EMPTY:
                                clear = False
                                break
                        if clear:
                            direction = 'w'
                            break
                        else:
                            print()
                            print('There is another ship in the way in that direction, try again.')
                            print()
                            time.sleep(0.5)
                            continue
                    elif choice == 's' and row + (SHIPNAMES[ship]-1) <= len(board):
                        clear = True
                        for val in range(SHIPNAMES[ship]):
                            if board[row+val][col] != EMPTY:
                                clear = False
                                break
                        if clear:
                            direction = 's'
                            break
                        else:
                            print()
                            print('There is another ship in the way in that direction, try again.')
                            print()
                            time.sleep(0.5)
                            continue
                    elif choice == 'a' and col - (SHIPNAMES[ship]-1) >= 0:
                        clear = True
                        for val in range(SHIPNAMES[ship]):
                            if board[row][col-val] != EMPTY:
                                clear = False
                                break
                        if clear:
                            direction = 'a'
                            break
                        else:
                            print()
                            print('There is another ship in the way in that direction, try again.')
                            print()
                            time.sleep(0.5)
                            continue
                    elif choice == 'd' and col + (SHIPNAMES[ship]-1) <= len(board):
                        clear = True
                        for val in range(SHIPNAMES[ship]):
                            if board[row][col+val] != EMPTY:
                                clear = False
                                break
                        if clear:
                            direction = 'd'
                            break
                        else:
                            print()
                            print('There is another ship in the way in that direction, try again.')
                            print()
                            time.sleep(0.5)
                            continue
                    else:
                        print()
                        print('There is not enough room to place the ship in that direction, try again.')
                        print()
                        time.sleep(0.5)
                        continue
            if choice == 'r':
                break
            wasd = {'w': 'up', 's': 'down', 'a': 'left', 'd': 'right'}
            print()
            print('You have chosen to place your {} starting from {} and going {}.'.format(ship, start, wasd[direction]))
            print('Enter Y to accept this and continue. Enter N to restart at the ship selection prompt.')
            choice = input('>  ')[0].lower()
            if choice == 'q':
                sys.exit()
            if choice == 'r':
                break
            if choice != 'y':
                ships[ship[0]] = ship
                continue
            if direction in 'wa':
                inc = -1
            else:
                inc = 1
            for val in range(SHIPNAMES[ship]):
                if direction in 'ws':
                    board[row+val*inc][col] = ship
                else:
                    board[row][col+val*inc] = ship
        if choice == 'r':
            continue
        return board
    

def displayBoard(board,guesses=None):
    labels = []
    if guesses:
        for row in range(10):
            labels.extend(board[row])
            labels.extend(guesses[row])
        for i, space in enumerate(labels):
            if space != EMPTY:
                labels[i] = space[0]
        print('''
                PLAYER BOARD                                    GUESSES
     1   2   3   4   5   6   7   8   9  10         1   2   3   4   5   6   7   8   9  10
   +---+---+---+---+---+---+---+---+---+---+     +---+---+---+---+---+---+---+---+---+---+
 A | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |   A | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |
   +---+---+---+---+---+---+---+---+---+---+     +---+---+---+---+---+---+---+---+---+---+
 B | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |   B | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |
   +---+---+---+---+---+---+---+---+---+---+     +---+---+---+---+---+---+---+---+---+---+
 C | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |   C | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |
   +---+---+---+---+---+---+---+---+---+---+     +---+---+---+---+---+---+---+---+---+---+
 D | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |   D | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |
   +---+---+---+---+---+---+---+---+---+---+     +---+---+---+---+---+---+---+---+---+---+
 E | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |   E | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |
   +---+---+---+---+---+---+---+---+---+---+     +---+---+---+---+---+---+---+---+---+---+
 F | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |   F | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |
   +---+---+---+---+---+---+---+---+---+---+     +---+---+---+---+---+---+---+---+---+---+
 G | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |   G | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |
   +---+---+---+---+---+---+---+---+---+---+     +---+---+---+---+---+---+---+---+---+---+
 H | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |   H | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |
   +---+---+---+---+---+---+---+---+---+---+     +---+---+---+---+---+---+---+---+---+---+
 I | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |   I | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |
   +---+---+---+---+---+---+---+---+---+---+     +---+---+---+---+---+---+---+---+---+---+
 J | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |   J | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |
   +---+---+---+---+---+---+---+---+---+---+     +---+---+---+---+---+---+---+---+---+---+'''.format(*labels))
    else:
        for row in range(10):
            labels.extend(board[row])
        for i, space in enumerate(labels):
            if space != EMPTY:
                labels[i] = space[0]
        print('''
     1   2   3   4   5   6   7   8   9  10
   +---+---+---+---+---+---+---+---+---+---+
 A | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |
   +---+---+---+---+---+---+---+---+---+---+
 B | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |
   +---+---+---+---+---+---+---+---+---+---+
 C | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |
   +---+---+---+---+---+---+---+---+---+---+
 D | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |
   +---+---+---+---+---+---+---+---+---+---+
 E | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |
   +---+---+---+---+---+---+---+---+---+---+
 F | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |
   +---+---+---+---+---+---+---+---+---+---+
 G | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |
   +---+---+---+---+---+---+---+---+---+---+
 H | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |
   +---+---+---+---+---+---+---+---+---+---+
 I | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |
   +---+---+---+---+---+---+---+---+---+---+
 J | {} | {} | {} | {} | {} | {} | {} | {} | {} | {} |
   +---+---+---+---+---+---+---+---+---+---+'''.format(*labels))

def getPlayerMove(guesses):
    while True:
        print('Enter your guess as a row letter and column number, e.g. A10.')
        print('Enter Q to quit.')
        guess = input('>  ').lower()
        if 'q' in guess:
            print('Thanks for playing!')
            sys.exit()
        if len(guess) > 4:
            print()
            print('Input is too long, try again.')
            print()
            time.sleep(0.5)
            continue
        row = ''
        col = ''
        for char in guess:
            if char.isalpha():
                row += char
            elif char.isnumeric():
                col += char
        if row in ROWS and col in COLS:
            row, col = ROWS.find(row), COLS.index(col)
        else:
            print()
            print('Input is invalid, try again.')
            print()
            time.sleep(0.5)
            continue
        if guesses[row][col] != EMPTY:
            print()
            print('You already guessed that! Try again.')
            print()
            time.sleep(0.5)
            continue
        else:
            return row, col

def getAIMove(guesses, log):
    if log['recentHits']:
        firstRow, firstCol = log['recentHits'][0]
        if len(log['recentHits']) > 1:
            recentRow, recentCol = log['recentHits'][-1]
            if recentRow == firstRow:
                options = []
                for num in [recentCol+1,recentCol-1,firstCol+1,firstCol-1]:
                    if 0 <= num <= 9 and guesses[recentRow][num] == EMPTY:
                        options.append(num)
                random.shuffle(options)
                if options:
                    return recentRow, options[0]
            else:
                options = []
                for num in [recentRow+1,recentRow-1,recentRow+1,recentRow-1]:
                    if 0 <= num <= 9 and guesses[num][recentCol] == EMPTY:
                        options.append(num)
                random.shuffle(options)
                if options:
                    return options[0], recentCol
        potentials = [(firstRow, firstCol+1), (firstRow, firstCol-1), (firstRow+1, firstCol), (firstRow-1, firstCol)]
        if len(log['recentHits']) > 1:
            for coord in log['recentHits']:
                newRow, newCol = coord
                potentials.extend([(newRow, newCol+1), (newRow, newCol-1), (newRow+1, newCol), (newRow-1, newCol)])
        options = []
        for pair in potentials:
            guessRow, guessCol = pair
            if pair not in log['recentHits'] and 0 <= guessRow <= 9 and 0 <= guessCol <= 9 and guesses[guessRow][guessCol] == EMPTY:
                options.append(pair)
        random.shuffle(options)
        if options:
            return options[0]
    while True:
        guessRow, guessCol = random.randint(0,9), random.randint(0,9)
        if guesses[guessRow][guessCol] == EMPTY:
            return guessRow, guessCol

def makeMove(guesses, board, log, turn):
    if turn == 'Player':
        row, col = getPlayerMove(guesses)
    else:
        row, col = getAIMove(guesses, log)
    log['lastMove'] = (row, col)
    if board[row][col] == EMPTY:
        guesses[row][col] = MISS
        log['recentSink'][0] = False
        if not log['recentHits']:
            unsunkShip = ''
            for ship, coords in log['ships'].items():
                if 0 < len(coords) < SHIPNAMES[ship]:
                    unsunkShip = ship
                    break
            if unsunkShip:
                log['recentHits'] = [log['ships'][unsunkShip][0]]
                log['recentHit'] = unsunkShip
        return guesses, board, log
    else:
        hitShip = board[row][col]
        guesses[row][col] = HIT
        board[row][col] = HIT
        if not log['ships'].get(hitShip):
            log['ships'][hitShip] = []
        log['ships'][hitShip].append((row, col))
        if SHIPNAMES[hitShip] == len(log['ships'][hitShip]):
            for coord in log['ships'][hitShip]:
                sunkRow, sunkCol = coord
                guesses[sunkRow][sunkCol] = hitShip[0]
            log['recentSink'][0] = True
            log['recentSink'].append(hitShip)
            unsunkShip = ''
            for ship, coords in log['ships'].items():
                if 0 < len(coords) < SHIPNAMES[ship]:
                    unsunkShip = ship
                    break
            if unsunkShip:
                log['recentHits'] = [log['ships'][unsunkShip][0]]
                log['recentHit'] = unsunkShip
            else:
                log['recentHits'] = []
            return guesses, board, log
        else:
            log['recentHits'].append((row, col))
            log['recentHit'] = hitShip
            log['recentSink'][0] = False
            return guesses, board, log

if __name__ == '__main__':
    main()
\$\endgroup\$
2
  • \$\begingroup\$ Very cool! But on the UX side, consider printing the previous turn's result after the new board (i.e., underneath it). As is, while we're in the middle of reading the previous outcome, it gets scrolled away to display the new board. \$\endgroup\$
    – tdy
    Commented Jun 7 at 19:17
  • \$\begingroup\$ Thanks for the advice, that's a great point! \$\endgroup\$ Commented Jun 9 at 1:43

1 Answer 1

7
\$\begingroup\$

Use more vertical whitespace:

It seems as if the code was manually formatted. Consider using black or ruff to format the code according to PEP-8. This would also save your time.

Simpler to use f-strings:

for name, length in SHIPNAMES.items():
    print('{}: length {}'.format(name, length))

The second statement can be written simpler as:

print(f'{name}: length {length}')

Use more functions:

Currently, the main() function is too long and responsible for a lot more than it should be.

It is responsible for:

  • taking and validating input.
  • board selection.
  • the main game loop.

Consider defining a function to take input, another to select the board, and another that runs the main loop, and some more helper functions.

manualBoard() is too long as well. Consider defining helper functions to deal with each keypress separately.

Use a list comprehension:

getEmptyBoard() can be replaced with a simple one-liner:

return [[EMPTY for _ in range(10)] for _ in range(10)]

You're already using a similar comprehension getRandomBoard().

In getRandomBoard(), you can also simplify:

if direction % 2 == 0:
    val = 1
else:
    val = -1

to:

val = 1 if direction % 2 == 0 else -1

Good job on the if __name__ == '__main__': guard.

\$\endgroup\$
1
  • 1
    \$\begingroup\$ Thank you for the feedback! I didn't know about the code formatting resources you shared and I look forward to using them. I also appreciate the simplifications you recommended, the guidance that f-strings are preferred, and the recommendation to out-source into more helper functions. I will keep all this in mind moving forward! \$\endgroup\$ Commented Jun 7 at 19:16

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

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