• Learn to code through self-directed projects

    How to learn to code?

    Coding encompasses a lot and what to learn depends on what you want to do.

    Like writing, you can write a joke, a poem, a textbook, or even a novel. Each requires different skills. If you want to write a novel, the best way to learn is probably by actually writing novels.

    The same goes for coding. You may want to code a script, a simple app, or a complex website like YouTube.

    The first step is to figure out what you want to do. If your goal is complicated, you may need to learn a lot before you can do it yourself.

    I found it helpful to pick a project and work towards completing it. There are many books and tutorials that teach the basics of coding. But without a clear goal, you may end up trying to endlessly learn without a purpose.

    Try to break down your project into smaller parts. Then, learn how to complete these parts one by one. You can use online resources, books, tutorials, or even start smaller projects to practice what you learn.

    Having someone with more experience to guide you can be very helpful. You can always send me a message if you need any help!

    Next, I’ll share how I learned to code while working on my first big project.

  • How I became a software engineer

    Background

    I studied finance in college and got a job in investment banking after school. I didn’t particularly like the work or the people. After that, I joined a healthcare company in a finance role. The work was interesting initially but not so much after a year or so.

    Sabbatical

    I wasn’t really sure what to do next and I had savings (~2 yrs runway) so I quit with nothing lined up.

    I felt like my mind had atrophied over the past several years so I really wanted to learn new things.

    I spent the next two months reading a lot of different books (psychology, history, sci-fi, etc).

    At some point, I got tired of reading and wanted to do something. One of the books I read was about learning new skills. I initially tried something related to art but didn’t really like it. Next, I tried learning to code.

    Learning to code

    The thing that interested me initially was a book called Automate the Boring Stuff with Python. I figured learning how to automate things might be useful regardless of what I did.

    I had fun working through the exercises and started working on a personal project that involved scraping some data.

    One of the a-ha moments for me was when a friend of mine who was an engineer looked over my code and offered suggestions on how to make it better. My code was one long script. There was something really appealing about seeing my long messy code become a few simple lines that almost read like English.

    After that, I wanted to do something more challenging. My friend was a boot camp grad and so he told me about the type of projects they work on.

    For the next several months I worked on a few web application projects and I learned about websites, servers, and databases.

    Career Change

    I tried to talk to people who were software engineers and tried to figure out what it took to break into the industry.

    Aside from actually being able to do things, it seemed like learning algorithms and data structures would also be important.

    I spent a couple of months working through a course (Stanford algorithms), a book (cracking the coding interview), and other online resources (interviewcake, leetcode).

    Then I started looking for a job.

    Job Hunt

    This was the hardest part of the transition. I had a really tough time getting interviews. I almost never heard back from applying online. I even reached out to recruiters and they didn’t really respond back. I think it’s understandable given I had no background or degree.


    This article on getting a job in tech was super helpful. I started networking aggressively. I tried to reach out to software engineers in my network or anyone who worked at a company that hired software engineers. I also continued to work on projects and prepared for whiteboarding interview questions.

    I did this for about 2-3 months and eventually, I landed a job. Since the time I quit, it was about a year in total.

  • Finding a job when the market is tough

    It seems like hiring has been slowing down lately.

    As a software engineer with some experience it used to be pretty easy to get involved in the hiring process since inbound from recruiters was pretty high.

    It’s been a couple of years since I looked for a job but if the market is still tight when I’m looking, I’ll consider the things I had to do to get my first software engineering job.

    Career Change

    I didn’t have a computer science degree. I studied finance in school and worked in that field after graduating. I didn’t like the work or the industry so quit my job with nothing lined up.

    At some point, I started learning to code and was hooked. Eventually, I started looking for a job. I had no experience and no CS degree. Recruiters ignored me and I almost never heard anything back from online applications.

    Networking

    My main strategy became to network aggressively. There was a really great article by Haseeb Qureshi that really helped me out.

    Basically, reach out to anyone and everyone. I went to meetups. I messaged people at companies I was interested in to ask them how they liked working there. I also reached out to other people that switched from other industries to ask them about their experience breaking into tech.

    Through this, I got great advice and it resulted in a few interviews. The person you reach out to doesn’t even have to work in a tech role. My first software engineering job came through someone I reached out to who went to my same university and was in a finance function.

    How to reach out

    I mostly reached out on Linkedin. Some things to keep in mind though. The message should be personalized and concise. You should be clear on what you’re asking for and why they can help you. e.g. “I saw you switched from finance to software engineering. I’m also making the same transition..”.

    You should also never feel entitled to someone else’s help. Show appreciation and leave them an easy out. “I’d really appreciate it if I could ask you some questions around A and B. I completely understand if you’re too busy though.”

    What I’d do today

    I’d probably make a list of companies I’m interested in and would reach out to either a hiring manager or engineer at the company. I’d give a brief intro and ask if we could set up some time to see if there’s a good fit.

    I believe doing this would help you stand out and increase your odds of getting involved in the hiring process than just submitting an online application.

  • BattleShip Part 11: Include columns and rows in display

    Planning on wrapping this up by implementing one more feature around displaying the row and column labels. The project isn’t “perfect” but I feel like it’s in a good place (imo).

    Previously, I was only showing the coordinates and the statuses but not the labels. i.e. (A-J or 1-10). This made figuring out the coordinates I wanted to attack harder.

    The primary logic for this should live in the Game class since it knows about how many columns and rows there are.

    @dataclass                        
    class BoardLayout:    
        coord_info: List[List[CoordInfo]]                                                              
        row_labels: List[str]                          
        column_labels: List[str]

    I’ll define a new class that will return the row and column labels.

    Since this impacts the Game class’ “public” API, sometimes what I like to do is create a new version.

        def get_board_layout_v2(self):
            coord_info = [
                [self._get_coord_status(row_index, col_index) for col_index in range(self.col_length) ]
                for row_index in range(self.row_length)
            ]
    
            row_labels = [index_to_letter(i) for i in range(self.row_length)]
            col_labels = [str(i + 1) for i in range(self.col_length)]
    
            return BoardLayout(coord_info, row_labels, col_labels)

    This lets your work on the functionality independently without changing the GameCli. Let’s add a unit test for it too.

        def test_get_board_layout_v2_with_no_hits(self):
            ship_coords = {
                (0,0): 'A',            
            }
            game = Game(ship_coords, 2, 2)
    
            layout_without_hit = game.get_board_layout_v2()
            empty_coord = CoordInfo(CoordStatus.EMPTY, None)
            expected_coords = [
              [empty_coord, empty_coord],
              [empty_coord, empty_coord]
            ]
            expected_layout = BoardLayout(expected_coords, ['A', 'B'], ['1', '2'])
            self.assertEqual(expected_layout, layout_without_hit)

    Later when I’m done, I can update the caller to use the new one.

        def _display_board_layout(self):
            board_v2 = self.game.get_board_layout_v2()
    
            col_header = '  ' + ','.join(board_v2.column_labels) + '\n'
            rows = '\n'.join([ board_v2.row_labels[idx] + ' ' + ','.join([str(coord) for coord in row]) for idx, row in enumerate(board_v2.coord_info)])
            self.view_manager.display(col_header + rows)

    When the old method is no longer being used, we can remove it.

    We should also update our tests.

        def test_game_cli_show(self):
            game = Mock()
            view_manager = Mock()
            user_inputs = ['show']
    
            cli = GameCli(user_inputs, game, view_manager)
    
            hit = CoordInfo(CoordStatus.SHIP_HIT, 'A')
            missed = CoordInfo(CoordStatus.SHIP_MISSED, None)
            empty = CoordInfo(CoordStatus.EMPTY, None)
    
            coords_info = [
                [hit, missed],
                [empty, empty]
            ]
            layout = BoardLayout(coords_info, ['A', 'B'], ['1', '2'])
            game.get_board_layout_v2.return_value = layout
    
            cli.run()
    
            expected_board_text = '  1,2\nA A,x\nB  , '
            view_manager.display.assert_has_calls([call(expected_board_text), call(expected_board_text), call('Game is over')])

    The logic for converting the board into a string is a little more complicated now. If we wanted to we could extract this functionality into a separate function if we wanted to.

    For some of my other tests, I’m not actually looking to test the board rendering logic i.e. in my test_game_cli_attack test I can use an ANY helper which basically checks we attempted to display anything.

        def test_game_cli_attack(self):
            game = Mock()
            view_manager = Mock()
            user_inputs = ['a1']
    
            cli = GameCli(user_inputs, game, view_manager)
    
            layout = BoardLayout([], [], [])
            game.get_board_layout_v2.return_value = layout
    
            game.attack.return_value = AttackResult.SUCCESS
            cli.run()
    
            game.attack.assert_called_with('a', '1')
            view_manager.display.assert_has_calls([call('Ship hit!'), call(ANY), call('Game is over')])

    Project changeset.

    Anyway, our project is working now. It handles bad inputs and shows us the state of the board.

      1,2,3,4,5,6,7,8,9,10
    A x,x,x,x,x,x,x,x,x,x
    B  , , , ,x, , , , , 
    C x,x,x,x,x,x,x,x,x,x
    D  , , , ,x,x,x, , , 
    E x,x,x,x,x,x,x,x,x,x
    F  ,B, , ,B, , , , , 
    G  , , ,C,C, , , , , 
    H  , , , , , , , , , 
    I x,x,x,x,x,x,x,x,x,x
    J  , , , ,x, , , , , 

    We also have unit tests that verify a lot of the functionality.

    $ python3 -m unittest test/test* -v
    test_attacks_return_correct_result (test.test_game.TestGame) ... ok
    test_game_with_all_ships_hit_game_over (test.test_game.TestGame) ... ok
    test_game_with_invalid_input_throws_error (test.test_game.TestGame) ... ok
    test_game_with_unhit_ships_not_over (test.test_game.TestGame) ... ok
    test_get_board_layout_v2_with_no_hits (test.test_game.TestGame) ... ok
    test_get_board_layout_with_hits (test.test_game.TestGame) ... ok
    test_get_board_layout_with_no_hits (test.test_game.TestGame) ... ok
    test_coordinate_returns_attack_action_and_coordinate (test.test_game_cli.TestGameCli) ... ok
    test_exit_returns_exit_action (test.test_game_cli.TestGameCli) ... ok
    test_game_cli_attack (test.test_game_cli.TestGameCli) ... ok
    test_game_cli_show (test.test_game_cli.TestGameCli) ... ok
    test_invalid_input (test.test_game_cli.TestGameCli) ... ok
    test_invalid_input_returns_invalid_action (test.test_game_cli.TestGameCli) ... ok
    test_show_returns_show_action (test.test_game_cli.TestGameCli) ... ok
    test_get_valid_ship_placements (test.test_ship_placer.TestShipPlacer) ... ok
    test_no_valid_placements_throws_exception (test.test_ship_placer.TestShipPlacer) ... ok
    test_placed_ships_position_matches_ship_sizes (test.test_ship_placer.TestShipPlacer) ... ok
    test_ship_too_large_cannot_be_placed (test.test_ship_placer.TestShipPlacer) ... ok
    test_valid_placment_sets_coordinates (test.test_ship_placer.TestShipPlacer) ... ok
    
    ----------------------------------------------------------------------
    Ran 19 tests in 0.029s

    Anyway, hope this was helpful.

  • BattleShip Part 10: Tests for Game Cli

    Next, I’m going to add some unit tests for the GameCli class. The primary thing this class currently does is print output. To make testing a little easier, I’ll define a class that is in charge of printing instead of printing in the GameCli.

    import os
    
    class ViewManager:
    
        def clear(self):
            os.system('cls' if os.name == 'nt' else 'clear')
    
        def display(self, text):
            print(text)

    It will be pretty simple and also control clearing the terminal as well.

    Then I’ll replace all prints with the view manager. e.g.

        def run(self):
            '''Parses and applies user provided commands to the game'''
            for user_input in self.user_inputs:
                (action_type, action_info) = parse_user_input(user_input)
    
                self.view_manager.clear()
                if action_type == UserActionType.SHOW:
                    self._display_board_layout()
                elif action_type == UserActionType.ATTACK:
                    (row, col) = action_info
                    self._attack(row, col)
                elif action_type == UserActionType.EXIT:
                    self.view_manager.display('Ending game')
                    return
                else:
                    self._display_invalid_user_input_message()
    
                if self.game.game_over():
                    break
    
            self._display_board_layout()
            self.view_manager.display('Game is over')

    I can now define some simple tests. The first test just verifies the high-level logic. i.e. if there are no user inputs, the game displays the board and ends.

        def test_game_cli(self):
            game = Mock()
            view_manager = Mock()
            cli = GameCli([], game, view_manager)
            game.get_board_layout.return_value = []
    
            cli.run()
    
            view_manager.display.assert_has_calls([call(''), call('Game is over')])

    Next, I’ll test the show command.

        def test_game_cli_show(self):
            game = Mock()
            view_manager = Mock()
            user_inputs = ['show']
    
            cli = GameCli(user_inputs, game, view_manager)
    
            hit = CoordInfo(CoordStatus.SHIP_HIT, 'A')
            missed = CoordInfo(CoordStatus.SHIP_MISSED, None)
            empty = CoordInfo(CoordStatus.EMPTY, None)
    
            game.get_board_layout.return_value = [
                [hit, missed],
                [empty, empty]
            ]
    
            cli.run()
    
            view_manager.display.assert_has_calls([call('A,x\n , '), call('Game is over')])

    Notice I don’t need to use a real game or view_manager. With mocks, I can specify how they should behave. i.e. when get_board_layout is called, it will return a specific board layout. Then I can check that the board is converted to the expected output format is the provided parameter to the display method.

    I’ll add some other tests to verify some of the other expected input/outputs.

        def test_game_cli_attack(self):
            game = Mock()
            view_manager = Mock()
            user_inputs = ['a1']
    
            cli = GameCli(user_inputs, game, view_manager)
    
            game.get_board_layout.return_value = []
            game.attack.return_value = AttackResult.SUCCESS
            cli.run()
    
            game.attack.assert_called_with('a', '1')
            view_manager.display.assert_has_calls([call('Ship hit!'), call(''), call('Game is over')])
    
    
        def test_invalid_input(self):
            game = Mock()
            view_manager = Mock()
            user_inputs = ['blah']
    
            cli = GameCli(user_inputs, game, view_manager)
    
            game.get_board_layout.return_value = []
            cli.run()
    
            view_manager.display.assert_has_calls([call('Invalid user input'), call(''), call('Game is over')])

    Project changeset.

  • BattleShip Part 9: Unit tests for Game class

    Now that our Game class is mostly functional, we can add some unit tests.

    The first test I’ll write is just checking the game over functionality for a newly created game.

        def test_game_with_unhit_ships_not_over(self):
            ship_coords = {    
                (0,0): 'A',             
                (0,1): 'A'              
            }
            game = Game(ship_coords, 5, 5)  
            self.assertFalse(game.game_over())   

    Next, I’ll verify attacking all the ship coordinates results in a game over. You’ll notice, I redefined ship_coords and created a new game object. You’ll usually want to create tests that are independent so you can run a single test or multiple tests without them affecting each other.

        def test_game_with_all_ships_hit_game_over(self):
            ship_coords = {
                (0,0): 'A',
                (0,1): 'A'
            }
            game = Game(ship_coords, 5, 5)
            game.attack('a', '1')          
            game.attack('a', '2')         
            self.assertTrue(game.game_over()) 

    You also usually want to test specific things. If you write a test that does too many things, it can become harder to understand and maintain.

        def test_attacks_return_correct_result(self):      
            ship_coords = {                              
                (0,0): 'A',                                      
                (0,1): 'A'
            }                                               
            game = Game(ship_coords, 5, 5)
            success = game.attack('a', '1')
            missed = game.attack('b', '1')
            out_of_bounds = game.attack('b', '100')
                                          
            self.assertEqual(success, AttackResult.SUCCESS)
            self.assertEqual(missed, AttackResult.MISSED)
            self.assertEqual(out_of_bounds, AttackResult.INVALID)

    It’s also a good idea to test failure cases, not just the happy path.

        def test_game_with_invalid_input_throws_error(self):
            ship_coords = {
                (0,0): 'A',
                (0,1): 'A'
            }
            game = Game(ship_coords, 5, 5)
            with self.assertRaises(ValueError) as context:
                game.attack('a', 'z')

    Having our Game configurable for different parameters makes testing some cases a lot easier. i.e. we can create a much smaller board.

        def test_get_board_layout_with_hits(self):
            ship_coords = {
                (0,0): 'A',
            }
            game = Game(ship_coords, 2, 2)
    
            game.attack('a', '2')
            game.attack('a', '1')
    
            empty_coord = CoordInfo(CoordStatus.EMPTY, None)
            hit_coord = CoordInfo(CoordStatus.SHIP_HIT, 'A')
            missed_coord = CoordInfo(CoordStatus.SHIP_MISSED, None)
    
            expected_layout = [
              [hit_coord, missed_coord],
              [empty_coord, empty_coord]
            ]
            layout = game.get_board_layout()
            self.assertEqual(expected_layout, layout)

    A common practice for good unit testing is only testing the public api or the methods other classes directly use. Here it’s attack, game_over, get_board_layout. Private methods I’ve prefixed with an underscore i.e. _get_coord_status and _on_board.

    We also usually don’t want to directly access internal fields of the class such as ship_coords.

    This lets us update how we do things internally without affecting the users of this class. For instance, if we decided it makes sense to keep track of all the ships on a matrix instead of a dictionary, we could make that change and the classes that use Game don’t need to be changed.

    Thoughts on Testing

    Overall I think writing tests like this are worthwhile. I think they save you time in the long run because you don’t need to verify code works manually. If code is easily testable it tends to be organized well. Finally, as you make changes or build new functionality, it provides some level of safety since it verifies certain things continue to work as expected.

    That being said, there are cases where you might not want to do this. If you’re building some small prototype that will be replaced or writing some kind of script that doesn’t have major consequences and won’t be reused, it might not be worth going through all this trouble. Also, if you’re just starting out and you’re not really sure how major parts of the code are going to be organized, you might want to wait until that shapes up a bit first.

    Finally, unit tests won’t cover everything. Sometimes, there might be issues when multiple components work together or you might see certain things in real-life that you didn’t expect.

    Project changeset.

  • BattleShip Part 8: Addressing boundary issue

    Running the project as-is, I noticed a problem with the output.

    $ python3 main.py 
    Your Move > show
    [ ,  ,  ,  ,  ,  ,  ,  ,  ]
    [ ,  ,  ,  ,  ,  ,  ,  ,  ]
    [ ,  ,  ,  ,  ,  ,  ,  ,  ]
    [ ,  ,  ,  ,  ,  ,  ,  ,  ]
    [ ,  ,  ,  ,  ,  ,  ,  ,  ]
    [ ,  ,  ,  ,  ,  ,  ,  ,  ]
    [ ,  ,  ,  ,  ,  ,  ,  ,  ]
    [ ,  ,  ,  ,  ,  ,  ,  ,  ]
    [ ,  ,  ,  ,  ,  ,  ,  ,  ]
    [ ,  ,  ,  ,  ,  ,  ,  ,  ]

    There are 10 rows but only 9 columns.

    Also, my validation check is too strict because it only allows a single letter for the attack command. 1-9. So I’ll relax this to allow any numeric ending.

    valid_attack_pattern = r'(^[a-z]{1})([0-9]+$)' 

    This causes a new issue since now I can enter coordinates outside the board.

     $ python3 main.py
    Your Move > show
    [ ,  ,  ,  ,  ,  ,  ,  ,  ]
    [ ,  ,  ,  ,  ,  ,  ,  ,  ]
    [ ,  ,  ,  ,  ,  ,  ,  ,  ]
    [ ,  ,  ,  ,  ,  ,  ,  ,  ]
    [ ,  ,  ,  ,  ,  ,  ,  ,  ]
    [ ,  ,  ,  ,  ,  ,  ,  ,  ]
    [ ,  ,  ,  ,  ,  ,  ,  ,  ]
    [ ,  ,  ,  ,  ,  ,  ,  ,  ]
    [ ,  ,  ,  ,  ,  ,  ,  ,  ]
    [ ,  ,  ,  ,  ,  ,  ,  ,  ]
    Your Move > a10
    Ship missed!
    Your Move > a11
    Ship missed!

    I’ll update the attack method of the game to return a more structured type instead of just a boolean.

    class AttackResult(Enum):
        SUCCESS = 1
        MISSED = 2                            
        INVALID = 3 

    Then I’ll update the attack method to return the attack result.

        def attack(self, row, col):
            coord = (row, col)
    
            if not self._on_board(row, col):
                return AttackResult.INVALID
    
            # already attacked that position
            if coord in self.attacked_coords:
                return AttackResult.MISSED
    
            self.attacked_coords.add(coord)
            if coord in self.ship_coords:
                return AttackResult.SUCCESS
    
            return AttackResult.MISSED
    
        def _on_board(self, row, col):
            row_index = letter_to_index(row) + 1
            col_index = int(col)
            return (
                col_index >= 1 and \
                col_index <= self.col_length and \
                row_index >= 1 and \
                row_index <= self.row_length
            )

    Now the _attack method in GameCli can properly handle the different cases.

        def _attack(self, row, col):                                                                                                                                                                                                              
            result = self.game.attack(row, col)                                                                                                                                                                                                   
            if result == AttackResult.SUCCESS:                                                                                                                                                                                                    
                print('Ship hit!')                                                                                                                                                                                                                
            elif result == AttackResult.MISSED:                                                                                                                                                                                                   
                print('Ship missed!')                                                                                                                                                                                                             
            elif result == AttackResult.INVALID:                                                                                                                                                                                                  
                print('Invalid coordinates!')                                                                                                                                                                                                     
            else:                                                                                                                                                                                                                                 
                raise Exception('There is an unhandled attack type') 

    One thing I found a little painful about doing this is in Game, I’ve been using raw user-provided input for keeping track of things like coordinates i.e. (“A”, “1”). It also requires the ship_placer to have to translate its output into this type of format.

    I’ll refactor Game to track things with a zero-based indexing instead of these raw values which will keep things a bit more consistent and I don’t have to translate between 0-based and 1-based indexing in a bunch of places.

    I’ll define a function that will translate the user input into integer coordinates.

    def to_coordinates(row, col):
        '''Translates user provided input to zero index based matrix coordates: e.g. A1 -> (0, 0).'''
        row_index = letter_to_index(row)
        col_index = int(col) - 1
        return (row_index, col_index)

    Then I can remove all this +1 business and keep things simpler.

        def get_board_layout(self):
            return [
                [self._get_coord_status(row_index, col_index) for col_index in range(self.col_length) ]
                for row_index in range(self.row_length)
            ]
    
        def _get_coord_status(self, row, col):
            coord = (row, col)
    
            if coord in self.attacked_coords and coord in self.ship_coords:
                return CoordInfo(CoordStatus.SHIP_HIT, self.ship_coords[coord])
    
            if coord in self.attacked_coords:
                return CoordInfo(CoordStatus.SHIP_MISSED, None)
    
            return CoordInfo(CoordStatus.EMPTY, None)
    
        def _on_board(self, coord):
            (row_index, col_index) = coord
            return (
                col_index >= 0 and \
                col_index < self.col_length and \
                row_index >= 0 and \
                row_index < self.row_length
            )

    Finally I update ship_placer to just return the integer-based coordinates. Since I changed the return type, I’ll also need to update the test.

    Project changeset.

  • BattleShip Part 7: Ship Placement Tests

    While running the code, I did find a bug where ships could overlap.

    As I started trying to write a test for the ShipPlacer class, I found it was a little hard to do. There is a bunch of randomizing involved and because the primary thing that happens is updating the ship coordinate state, it was a little hard to test things in isolation.

    I refactored ShipPlacer so it no longer directly owned any of the logic for generating coordinates for a random ship placement. The only thing this class now does is go through the ship placements provided by the _get_valid_ship_placements function and set the coordinates for the ship.

    class ShipPlacer:
    
        def __init__(self, ship_configs, rows, columns, ship_coords, get_valid_ship_placements = get_valid_ship_placements):
            self.ship_configs = ship_configs
            self.rows = rows
            self.columns = columns
            self.ship_coords = ship_coords
            self._get_valid_ship_placements = get_valid_ship_placements
    
    ...
    
        def _attempt_placement(self, ship_coords, ship_config):
            for potential_placement in self._get_valid_ship_placements(ship_coords, ship_config, self.rows, self.columns):
                if potential_placement is not None:
                    for coord in potential_placement:
                        ship_coords[coord] = ship_config['name']
                    return True
    
            return False

    This allows me to write a more simple unit test. I can control the results returned by _get_valid_ship_placements and verify it uses those values in the ship_coords.

                                                                                                                                                                                                                                                  
        def test_valid_placment_sets_coordinates(self):                                                                                                                                                                                           
            config = {                                                                                                                                                                                                                            
                'columns': 5,                                                                                                                                                                                                                     
                'rows': 5,                     
                'ships': [                     
                    {'name': 'B', 'length': 2},
                    {'name': 'C', 'length': 2}
                ]
            }
    
            def placement(*args, **kwargs):
                ship_config = args[1]
                if ship_config['name'] == 'B':
                    yield [(0,1), (0,2)]
                elif ship_config['name'] == 'C':
                    yield [(2,4), (3,4)]
    
            placement_fn = Mock(side_effect=placement)
    
            placer = ShipPlacer(config['ships'], config['rows'], config['columns'], {}, placement_fn)
            ship_coords  = placer.build_ship_coords()
    
            self.assertTrue(ship_coords[('a', '2')], 'B')
            self.assertTrue(ship_coords[('a', '3')], 'B')
            self.assertTrue(ship_coords[('c', '5')], 'C')
            self.assertTrue(ship_coords[('d', '5')], 'C')

    I can also write a unit test for the function that actually generates the possible ship placements and test it in isolation.

        def test_get_valid_ship_placements(self):
            ship_coords = {
                (0, 0): 'B',
                (0, 1): 'B',
            }
            ship_config = {'name': 'C', 'length': 2}
            placements = list(get_valid_ship_placements(ship_coords, ship_config, 2, 2))
    
            self.assertTrue(len(placements) > 1)
    
            # currently not deduping based on where we first placed the ship
            placement = placements[0]
    
            # ship can only be placed in the following cooords
            self.assertTrue((1,0) in placement)
            self.assertTrue((1,1) in placement)

    Project changeset.

  • BattleShip Part 6: Ship Placement

    The next major part of the project involves randomly placing ships on a board.

    I decided to construct a new component that handles the placement of ships and builds a set of ship coordinates which is then used by the Game class.’

    This component will be responsible for making sure ships are accurately placed on a board and we’ll assume Game receives a valid board.

        config = {
            'columns': 10,
            'rows': 10,
            'ships': [
                {'name': 'B', 'length': 2},
                {'name': 'C', 'length': 3}
            ]
        }
    
        ship_placer = ShipPlacer(config)
        ship_coords = ship_placer.build_ship_coords()
        game = Game(ship_coords)

    I’ll define a high-level configuration that would allow for specifying a different row or column counts and different ships.

    Ship Placer Class

    class ShipPlacer:                                              
                                             
        def __init__(self, config):                                
            self.ship_configs = config['ships']
            self.columns = config['columns']                  
            self.rows = config['rows']
            self.ship_coords = {}
                                 
        def build_ship_coords(self):  
            # create a copy of the list                         
            ships_to_place = self.ship_configs[:]
                                                                           
            while ships_to_place:             
                current_ship = ships_to_place.pop()              
                placed = self._attempt_placement(current_ship)
    
                if not placed:
                    raise Exception('Could not place all ships on board')
    
            # transform to coords expected by game e.g. A1 -> A10
            return {        
                (index_to_letter(row), str(col + 1)): ship_name
                for (row, col), ship_name in self.ship_coords.items()
            }

    The high-level logic involves going through each ship and trying to place it on the grid. For whatever reason, if we couldn’t, we’d throw an error. Afterward, we return the ship coordinates as the game expects them. e.g. (‘a’, ‘2’).

    For each ship, I first get all free coordinates on the board. I remove a random coordinate and try to place the ship on that coordinate. For that coordinate, I try a random direction until I’ve run out of options.

        def _attempt_placement(self, ship_config):
            available_coords = [
                coord                 
                for coord in get_coords(self.rows, self.columns)
                # exclude positions already placed
                if coord not in self.ship_coords
            ]
    
            if not available_coords:
                return False
    
            while available_coords:
                random_coord = available_coords.pop(random.randrange(len(available_coords)))
    
                directions = [direction for direction in Direction]
                while directions:
                    random_direction = directions.pop(random.randrange(len(directions)))
                    placed = self._place_ship(ship_config, random_coord, random_direction)
                    if placed:
                        return True
    
            return False

    When I attempt to place a ship, I calculate the coordinates the ship would be made up of based on the direction and actually store them in the ship_coords if they are all valid coordinates.

        def _place_ship(self, ship_config, coord, direction):
            '''Places ship on coordinate in specified direction if able to.'''
    
            ship_size = ship_config['length']
    
            # next_coord = lambda row, col, idx: None
            if direction == Direction.UP:
                next_coord = lambda row, col, idx: (row - idx, col)
            elif direction == Direction.DOWN:
                next_coord = lambda row, col, idx: (row + idx, col)
            elif direction == Direction.RIGHT:
                next_coord = lambda row, col, idx: (row, col + idx)
            elif direction == Direction.LEFT:
                next_coord = lambda row, col, idx: (row, col - idx)
            else:
                raise Exception('No valid direction provided')
    
            row, col = coord
            potential_coords = []
            for i in range(ship_size):
                potential_coords.append(next_coord(row, col, i))
    
            if all(self._valid_coord(coord) for coord in potential_coords):
                for coord in potential_coords:
                    self.ship_coords[coord] = ship_config['name']
                return True
    
            return False

    I ran this and at a glance, it seems like it’s working. There might be some bugs, but next step will be writing some more tests to verify some scenarios.

    Project changeset.

  • BattleShip Part 5: Game Mechanics

    For the next part, I’m going to build out the game mechanics. I initially thought I might want some kind of Game and Board class. Where the GameCli would interact with the Game and the Game would control the Board.

    Now that I’m thinking a bit more about how I would want both of these to work, I think having a Game and a Board class might be redundant. The Board class would basically have all the functionality of the game and the Game class basically wouldn’t do anything else.

    It might still make sense to break these out, but for now, I’ll just define a single Game class. It will be responsible for keeping track of the state of the game and returning information that can be used to show the board.

    Tracking Attacks

    I decided that I don’t really need a matrix to track where ships are and what position has been attacked.

    All attacks can be tracked in a set, attacked_coords. Also, I decided to just keep track of where ships are using a dictionary, ship_coords. In the original project, it would show different letters per ship, so I’ll keep an association of ship position to ship name.

    This keeps things pretty simple and simple is usually good.

    class Game:
        def __init__(self, ship_coords, col_length = 10, row_length = 10):
            self.attacked_coords = set()
            self.ship_coords = ship_coords
            self.row_length = row_length
            self.col_length = col_length
    
        def attack(self, row, col):
            coord = (row, col)
    
            # already attacked that position
            if coord in self.attacked_coords:
                return False
    
            self.attacked_coords.add(coord)
            return coord in self.ship_coords
    
        def game_over(self):
            attacked_ship_coords = self.attacked_coords.intersection(set(self.ship_coords.keys()))
            return len(attacked_ship_coords) == len(self.ship_coords)

    Displaying the board

    For the show command, I need to provide information that can be used to render the board.

    I’ll define a dataclass that the game can use to describe the state of the board.

    class CoordStatus(Enum):
        '''Status of a board coordinate'''
        SHIP_HIT = 1
        SHIP_MISSED = 2
        EMPTY = 3
    
        def __repr__(self):
            return self.name
    
    @dataclass
    class CoordInfo:
        status: CoordStatus
        ship_name: str
    
        def __repr__(self):
            if self.status == CoordStatus.SHIP_HIT:
                return self.ship_name
            return self.status.name

    Then in the Game class, I’ll add the following methods.

        def get_board_layout(self):
            return [
                [self._get_coord_status(index_to_letter(row_index), str(col_index)) for row_index in range(self.row_length) ]
                for col_index in range(1, self.col_length + 1)
            ]
    
        def _get_coord_status(self, row, col):
            coord = (row, col)
    
            if coord in self.attacked_coords and coord in self.ship_coords:
                return CoordInfo(CoordStatus.SHIP_HIT, self.ship_coords[coord])
    
            if coord in self.attacked_coords:
                return CoordInfo(CoordStatus.SHIP_MISSED, None)
    
            return CoordInfo(CoordStatus.EMPTY, None)


    Updating Game Cli

    Next, I’ll update the CLI to actually interact with the game.

    class GameCli:
        '''Manages interactions between the user and the game.'''
    
        def __init__(self, user_inputs, game):
            self.user_inputs = user_inputs
            self.game = game
    
        def run(self):
            '''Parses and applies user provided commands to the game'''
            for user_input in self.user_inputs:
                (action_type, action_info) = parse_user_input(user_input)
    
                if action_type == UserActionType.SHOW:
                    self._display_board_layout()
                elif action_type == UserActionType.ATTACK:
                    (row, col) = action_info
                    self._attack(row, col)
                else:
                    self._display_invalid_user_input_message()
    
                if self.game.game_over():
                    break
    
            self._display_board_layout()
            print('Game is over')
    
    
        def _display_board_layout(self):
            board = self.game.get_board_layout()
            for row in board:
                print(row)
    
        def _attack(self, row, col):
            hit = self.game.attack(row, col)
            if hit:
                print('Ship hit!')
            else:
                print('Ship missed!')
    
        def _display_invalid_user_input_message(self):
            print('TODO: display invalid message')

    I can initialize the game w/ a test board configuration and run the game.

        game = Game({                                                                                                                                                                                                                             
            ('a', '1'): 'B',                                                                                                                                                                                                                      
            ('a', '2'): 'B',                                                                                                                                                                                                                      
            ('c', '3'): 'D',                                                                                                                                                                                                                      
            ('c', '4'): 'D',                                                                                                                                                                                                                      
            ('c', '5'): 'D',                                                                                                                                                                                                                      
        })                                                                                                                                                                                                                                        
                                                                                                                                                                                                                                                  
        cli = GameCli(get_user_inputs(), game)                                                                                                                                                                                                    
        cli.run() 
    $ python3 main.py
    Your Move > a1
    Ship hit!
    Your Move > a2
    Ship hit!
    Your Move > c3
    Ship hit!
    Your Move > c4
    Ship hit!
    Your Move > c5
    Ship hit!
    [B, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY]
    [B, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY]
    [EMPTY, EMPTY, D, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY]
    [EMPTY, EMPTY, D, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY]
    [EMPTY, EMPTY, D, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY]
    [EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY]
    [EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY]
    [EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY]
    [EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY]
    [EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY, EMPTY]
    Game is over

    I’ll hold off on the tests for now. There’s still some functionality around creating a game board and placing ships on it that I still need to incorporate and that could affect how I organize the game class.

    Project changeset.