You Might Also Like
Battleship Ship-Finding Game — Free HTML CSS JS Snippet
Battleship Ship-Finding Game · Games · Plain HTML, CSS & JS · Live preview
What's included
Features
About this UI Snippet
Battleship Ship-Finding Game — Randomised Fleet Placement, Hit Detection & Accuracy Tracking

Battleship is one of the oldest "search and destroy" grid games, and building a working single-player version is a genuinely useful exercise in constraint-based random placement, coordinate-based state lookup, and win-condition detection. This snippet implements a complete, playable version entirely in vanilla JavaScript — a hidden fleet is randomly placed on an 8x8 grid with real collision and boundary checking, and the player fires at cells to locate and sink every ship.
Randomised fleet placement with retry-on-collision
The fleet consists of four ships defined in SHIP_DEFS — a 4-cell Carrier, two 3-cell ships (Cruiser and Submarine), and a 2-cell Destroyer — mirroring a simplified version of the classic fleet composition. placeFleet() places each ship in turn: it picks a random starting row and column, a random orientation (horizontal or vertical), computes the full list of cells the ship would occupy, and calls canPlace() to verify every one of those cells is within the 8x8 boundary and currently unoccupied on the board array. If the random placement collides with another ship or runs off the grid, the loop simply tries again with a fresh random position and orientation, up to 200 attempts per ship. Because ship placement order and position are both randomised independently for every new game, no two games have the same fleet layout. placeFleetSafe() wraps the whole process in a retry loop that restarts fleet placement entirely from scratch if any single ship exhausts its attempts (extremely rare on an 8x8 grid with this fleet size, but a real safeguard against ever leaving the game unplayable).
Board representation and coordinate lookup
The hidden board is a 2D array, board[row][col], where each cell holds either null (empty water) or the numeric index of the ship occupying it. This gives instant O(1) lookup of "what ship, if any, lives at this cell" via findShipAt(), which is the core operation every fired shot needs. Each ship object separately tracks its own cells array (its full list of occupied coordinates) and a hits Set of coordinate keys that have been successfully struck — using a Set rather than a counter means the sunk check, ship.hits.size === ship.length, is both correct and cannot be thrown off by a duplicate hit being counted twice.
Firing, hit/miss rendering, and the shot log
Every fired cell is recorded in a shotLog object keyed by a "row-col" string, storing either 'hit' or 'miss' — this both prevents firing at the same cell twice and drives the entire visual re-render. cellState() derives what a cell should look like purely from shotLog and the ship data: a miss renders as a pale dot on a darker water tile, a hit renders as a red cross, and a hit whose owning ship is now fully sunk gets an additional sunk class for a darker red treatment, visually distinguishing "you hit something" from "you finished it off." This derive-from-source-of-truth approach means the render function never needs its own separate tracking state — it always reflects shotLog and ships exactly.
Sunk detection and win condition
After every shot, isShipSunk() compares a ship's accumulated hit count against its length; when they match, a "Ship sunk!" toast names the specific ship. checkWin() runs after every shot and checks whether ships.every(isShipSunk) — only when every ship in the fleet independently satisfies the sunk condition does the game declare victory, at which point it reports the final accuracy percentage, calculated as hits / shotsFired, rounded to the nearest whole percent.
Why this is a strong constraint-placement exercise
The interesting engineering problem in Battleship isn't the UI, it's guaranteeing a valid random layout under real constraints (no overlaps, no out-of-bounds cells, both orientations supported) without ever falling into an infinite loop or producing an invalid board. The retry-with-attempt-cap pattern used here — try random placement, validate, retry on failure, cap total attempts — is broadly reusable any time you need procedurally generated content that must satisfy hard non-overlap constraints, from level generation to seating charts.
Build with AI
Build, Understand, Optimize, and Extend It With AI
Paste this snippet's HTML, CSS and JS into an AI coding assistant like Claude and ask it to explain exactly how placeFleet() avoids overlapping ships and what happens in the rare case a placement keeps failing — tracing through canPlace() and the retry-with-attempt-cap logic is a good way to understand defensive procedural generation. You could also ask it to add a simple "hunt and target" AI opponent that fires back at your own hidden fleet between turns, add a visual ship-placement phase where the player manually places their own fleet with drag-and-drop before the computer's fleet is generated, or extend the accuracy stats with a "shots remaining until guaranteed sink" probability hint based on which cells are already ruled out. Each is a natural next step once the core hit-detection and placement logic already works correctly.
Prompt to recreate it
Copy this into your AI assistant of choice to build the effect from scratch, or as a jumping-off point for your own variant:
Build a single-player Battleship-style grid game in plain HTML, CSS, and JavaScript with genuine randomised fleet placement — no frameworks, no libraries.
Requirements:
- An 8x8 (or similar) grid and a fleet of at least 4 ships of varying lengths (e.g. 4, 3, 3, 2 cells), placed with real random position and orientation (horizontal or vertical) selection per ship.
- A placement algorithm with real collision and boundary detection: no two ships may overlap, no ship may extend off the grid, and failed placement attempts must retry with a new random position/orientation rather than silently allowing an invalid placement.
- Click-to-fire interaction where each cell can only be fired at once; a hit renders with a distinct visual marker from a miss, and already-fired cells become non-interactive.
- Per-ship hit tracking so that when every cell of a specific ship has been hit, a "Ship sunk!" notification appears naming that specific ship (not just a generic hit message).
- Live stats showing total shots fired, total hits, and a calculated accuracy percentage (hits divided by shots fired), updating after every shot.
- Win detection that fires only when every ship in the fleet is fully sunk, showing a final summary with shot count and accuracy.
- A "New Game" action that re-places the entire fleet randomly and resets all stats and grid state cleanly.
- Ensure the game state (which ship occupies which cell, which cells have been hit) is the single source of truth, with all rendering derived from it rather than tracked separately.Want to tighten it up first? Run this prompt through the AI Prompt Studio to score it across 8 quality dimensions, catch anti-patterns, and tune the wording for Claude, ChatGPT, or Gemini before you paste it in.
Step by step
How to Use
- 1Fire at a cellClick any cell on the 8x8 grid. If it hits part of the hidden fleet, it turns red with a cross marker and the ship's hit count updates internally; if it misses, it darkens with a small dot marker and cannot be clicked again.
- 2Watch for the sunk notificationWhen every cell belonging to a specific ship has been hit, a toast message names that ship (e.g. "Destroyer sunk!") and its hit cells switch to a darker red sunk styling so you can visually distinguish finished ships from ones still in play.
- 3Track your shots and hitsThe HUD keeps a live count of Shots fired and Hits landed, with Accuracy calculated as hits divided by shots fired as a rounded percentage, updating after every single click.
- 4Sink the entire fleet to wincheckWin() runs after every shot and compares every ship's hit count against its length; once all four ships are fully sunk, a win toast reports your final shot count and accuracy percentage.
- 5Start a new gameClick "New Game" to call placeFleetSafe() again, which randomly re-places all four ships with fresh positions and orientations and clears the shot log, hit count, and accuracy back to zero.
- 6Adjust the fleet compositionEdit the SHIP_DEFS array to add, remove, or resize ships, and change GRID_SIZE for a larger or smaller board — both the placement algorithm and the render loop read from these constants directly.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
canPlace() checks every single cell a candidate ship placement would occupy against two conditions: the coordinates must fall within 0 to GRID_SIZE - 1 on both axes, and the corresponding board cell must currently be null (unoccupied). If either check fails for any cell in the candidate placement, the whole placement is rejected and placeFleet() tries a new random position and orientation, up to 200 attempts per ship before placeFleetSafe() restarts the entire fleet.
A Set of coordinate keys guarantees that firing at the same already-hit cell twice (which fireAt() actually prevents via the shotLog check) can never inflate a ship's hit count past its true length. It also lets isShipSunk() do an exact comparison, ship.hits.size === ship.length, which is more robust than an incrementing counter that could theoretically drift if hit-recording logic changed elsewhere in the code.
Yes. Increase GRID_SIZE for a larger board (the CSS grid-template-columns and cell sizing adapt automatically since they use repeat() and aspect-ratio), and add more entries to the SHIP_DEFS array with a name and length for each additional ship. The placement algorithm and rendering both read these constants directly with no hard-coded assumptions about fleet size.
Accuracy is hitsCount divided by shotsFired, converted to a percentage and rounded to the nearest whole number with Math.round(). It recalculates after every single shot via updateStats(), so it is always current, and the same calculation is reused for the final win-toast accuracy figure shown when the whole fleet is sunk.
fireAt() checks whether the cell's coordinate key already exists in shotLog and returns immediately without incrementing shotsFired or changing any state if it does. Already-fired cells also have no click behaviour visually since hit and miss cells lose the hover/pointer cursor styling in CSS, making it clear they are no longer interactive.