Logic puzzles are my favourite.
I used to play battleships against the computer as a child, and I'd been wondering whether I could build the game myself and write a programme that could act as the computer, and perhaps even win occasionally.
So I thought I'd give it a go.
My first attempt was published July 2019. The game was built in React 15, and the computer logic was alright, but rarely won, largely because when it found a ship, it was inefficient in uncovering the whole ship.
I rebuilt the game from scratch in 2022 and carried on improving computer logic in 2023. My goals were to...
- Learn React Hooks – I'd never had an opportunity to learn them. I first learned React years before Hooks. (✅ Success – though nothing too complex!)
- Write more efficient logic – When I looked over the 2019 logic, it was pretty messy. The game logic is quite heavy, so there was plenty to neaten up. (✅ Success – though I'm sure I can do more)
- Test better – My tests in 2019 didn't consider as many edge cases as I'd like. It's tough to test the complex logic of Battleships, but I wanted to do TDD and think through each function in depth. (✅ Success – though, again, there's more I could do)
- Improve the computer strategy – The old code was inefficient at uncovering ships when it found them. Now, it's much faster at establishing where to search when it finds one. (✅ Definitely better!)
- Get the computer to win half the time – This was always the goal. I'm not quite there yet, but could be getting close... (❌ Closer though)
Head over here to play my version of Battleships 🎉
Found a bug, or got some feedback or ideas? Contact me here!
The front end
The front end was the easy part, really.
It's built in React 18 with Styled Components for styling. First, I span up a simple React app with create-react-app
.
The game conditionally renders different things depending on different states.
- If the game is in setup mode, the
Setup
component is rendered for the user to set up the game. - If the game is ongoing, two
Grid
components are rendered to represent the humans' and the computers' grids. - If the instructions are active, the
Instructions
component is rendered.
While in setup mode, the player can choose to alter the size of the grid, or change the number of ships which will be placed on the grid.
Playing the game
1. Setting up the grid
When we begin a game, we generate two grids representing parts of our ocean. There's one grid for the player and one for the computer. The grids are represented by an array of arrays.
- Each grid lives within a
Grid
React component. - Each row of the grid creates a unique
Row
within its grid. If the player has chosen a 10x10 grid, there will be 10Row
components. - Each cell of every row creates a unique
Cell
within its row in the grid. If the player has chosen a 10x10 grid, there will be 10Cell
components in every row – 100 per grid.
Logic peek
Here's the simple code generating the grid, which is being passed back to the React components:
const generateGrid = (width = 10, height = 10) => {
const result = [];
for (let i = 0; i < height; i++) {
const row = [];
for (let j = 0; j < width; j++) {
row.push({
isShip: false,
isDiscovered: false,
name: null,
length: null
});
}
result.push(row);
}
return result;
}
We can see that each cell is being given default information about itself, including whether or not it contains a ship, whether it's been discovered, what it's name is (the name of the ship), and the length of the ship.
2. Preparing a game
By default, five boats are placed at random onto each of the two empty grids. In standard Battleships, these boats are the Carrier, Battleship, Cruiser, Submarine and Destroyer.
Logic peek
Here's a look inside part of the createGame
function. The programme takes each boat and randomly tries to find a place to put it on the grid, where it doesn't fall off the edge or overlap with another boat.
boats.forEach(boat => {
while (true) {
let result = tryToPlace(gridA, boat, generateRandomCoordinates(width, height), generateRandomDirection());
if (result) {
gridA = updateGrid(gridA, result);
break;
}
}
});
The tryToPlace
function tries to position the battleship on a grid. Given a starting coordinate and direction (up, down, left, right), it checks if the entire length of the ship fits within the grid boundaries without crossing the edges. At the same time, it validates that none of the cells the ship would occupy already contains another ship. If both conditions are satisfied, it'll return the ship's details and the while loop above ends.
Details about which cells different ships are occupying are then passed back to the Grid
and down to their respective Cell
. The player can see the placement of their ships on their grid.
2. The player takes their first turn
The player begins by firing a figurative cannonball at the computer's grid, in their bid to discover their ships. They do this by clicking a cell.
The logic for the fire
action is pretty simple.
Each cell has a property .isDiscovered
at the start of the game, set to false
.
When we fire, we simply change the value to true
.
const fire = (player, grid, x, y) => {
...
grid[y][x].isDiscovered = true;
return grid;
}
If it's a hit – that is to say, they've fired on a ship – the cell will turn red. Otherwise, their cannonball splashes into the water, and the cell turns blue.
if (props.cell.isShip && props.cell.isDiscovered) {
return <CellContainer className='red'><img src={fire} alt='Explosion'/></CellContainer>
} else if (props.cell.isDiscovered) {
return <CellContainer className='blue'><img src={splash} alt='Splash!'/></CellContainer>
...
3. The computer decides its strategy
At the start of the game, the computer will decide on a strategy.
The computer knows that ships will always take up at least two cells, so it will begin examining even and odd cells on alternating rows, for example by searching in diagonal lines.
Based on the size of the grid, the computer will randomly generate an array of coordinates which form its plan. For example, it might begin [[0,0],[1,1],[[2,2],[3,3],[4,4],[5,5],[6,6]...]
It'll continue searching until it finds a ship.
4. The computer finds a ship
When the computer fires on a ship, the strategy changes. Before the computer finds a ship, the strategy might look something like this:
strategy = {
next: [],
plan: [[6,4],[5,5],[4,6],[3,7],[2,8]...],
lastTry: [7,5],
firstHit: null,
direction: null,
boatHits: []
}
The computer's original plan, generated in the previous step, is strategy.plan
.
But when the computer finds a cell which is a ship, it knows its next job is to work out which direction this ship is hiding in.
So it temporarily deviates from the plan in strategy.plan
.
It needs to check the cells adjacent to the cell with the ship.
So it adds the adjacent cells to the empty array in the strategy called strategy.next
. It also stores the coordinates of the first hit in strategy.firstHit
, and starts keeping track of the ship cells it has hit in strategy.boatHits
.
By default, cells in strategy.next
will always be checked first.
strategy = {
next: [[5,4],[7,4],[6,3],[6,5]],
plan: [[6,4],[5,5],[4,6],[3,7],[2,8]...],
lastTry: [7,5],
firstHit: [6,4],
direction: null,
boatHits: [[6,4]]
}
In this case, the adjacent cell below turns out also to be a ship. It can tell by comparing the coordinates of the first hit with the current hit.
The computer has worked out the direction it has to search in.
Now, it records this information in strategy.direction
and adds the next adjacent cell below to strategy.next
.
strategy = {
next: [[6,6]],
plan: [[6,4],[5,5],[4,6],[3,7],[2,8]...],
lastTry: [6,5],
firstHit: [6,4],
direction: "vertical",
boatHits: [[6,5],[6,4]]
}
Now, every time it hits a ship, it adds the next cell down to strategy.next
until the cannonball splashes.
strategy = {
next: [],
plan: [[6,4],[5,5],[4,6],[3,7],[2,8]...],
lastTry: [6,9],
firstHit: [6,4],
direction: "vertical",
boatHits: [[6,8],[6,7],[6,6],[6,5],[6,4]]
}
Then, it resets the strategy back to the plan and continues.
strategy = {
next: [],
plan: [[6,4],[5,5],[4,6],[3,7],[2,8]...],
lastTry: [6,9],
firstHit: null,
direction: null,
boatHits: []
}
Other computer tricks
There's much more I want to do with the computer's logic, but here are some of the tricks I've implemented:
- Re-strategising. When the computer has found all but two boat cells, it'll re-prioritise the plan. Now, it prioritises cells which have at least two undiscovered cells on either side.
- Avoiding lone cells If a cell is surrounded on all sides by discovered cells which are not ships, it'll skip searching them.
The end of the game
When either you or the computer have bombed all of their opponents' ships, the game's over! At the end, you'll get to see where all the ships were.
Don't mind the faint dots on the screenshot – there's a little pyro fireworks animation that runs at the end, made from CSS animations! That would be a whole blog in itself, so I won't discuss it here
The game's logic
The Game
react component lives with its other components in ./src/components
. It has a few methods which make all the logic run:
- handleSetup: Initializes a game with the chosen setup.
- handleFire: Handles the firing mechanic of the game when a player makes a move.
- handleComputerGo: Implements the computer's logic when it's the computer's turn.
- reset: Resets the game state.
- toggleDev: Toggles developer mode.
- toggleInstructions: Toggles the visibility of game instructions.
- handleUpdateGridSize: Updates the grid's size.
- handleUpdateBoats: Updates the boat configuration during setup.
We're using React state to store changing information about our game, and updating the state with React hooks, including things like...
- The width and height of the grid
- The grid of the player and computer
- Whose turn it is
- The computer's strategy
- If anyone has won yet
- The game setup, like grid size and number of boats
Under the hood, there are a plethora of functions, each tested with Mocha and Chai in its corresponding spec.js
file in src/logic/tests
Some learnings
1. Get modular
My first versions of logic functions like createGame
and computerStrategy
were sometimes enormous. I thought I was starting on the right foot by having all these functions, but I ended up modularising masssively.
Now I have a whole bunch of helper functions that I use all the time and save so much effort. Plus, everything's far more readable.
2. TDD, religiously
I did do TDD for this project. But sometimes I'd be tempted to make changes to the code without going to the tests first.
This turned out to be not a good idea. I'd break stuff without realising it was broken, and also get false negatives and positives which would remain undiagnosed for really too long.
Bonus: Deep cloning
Somewhat lazily, I avoided mutations in React by using the spread operator. I learned the hard way that this doesn't create deep copies, and I needed to use JSON.parse
with JSON.stringify
. I also learned that this won't work on some data types like functions, regular expressions and dates – but this was okay for my use cases.
That's it!
This has been my favourite personal project that I've worked on, which I suppose is why I keep coming back to it. I think it's because the computer logic for Battleships has so much scope to be extended. The job of a developer building this game is to spot in-game situations where there are opportunities for better decisions, and work out how to translate that into changes in the computer strategy.
If you're looking for a fun side-project and want to challenge the logical parts of your brain, this is a great, language-agnostic and tool-agnostic choice.
Found a bug, or got some feedback or ideas? Contact me here!