I wanted a daily puzzle that wasn't about words. The result is Sonar Puzzle: a free logic game where you find one enemy submarine in every coloured sector. Easy, Medium, and Hard are three boards, and every player on that day gets the same three.
The day is a fixed UTC+2 calendar date, not the phone's local midnight, and the device builds that day's board itself.
The game in 30 seconds
From 29 October 2026, Easy is 5×5. Earlier days stay 6×6. From 30 October 2026, Easy also keeps at least one one-cell sector. Medium is 8×8. Hard is 9×9. Each row, each column, and each sector holds one enemy, and two enemies cannot touch, even at a corner.
1. A daily puzzle without a puzzle server
The seed is the date plus the level. hashString lives in src/app/sonar/sonar-daily.ts. mulberry32 is the only random stream the generator may use:
export function dailySeed(date: string, level: LogicLevel): number {
return hashString(`sonar2|${date}|${level}`);
}
/** Mulberry32. Same seed, same sequence. */
export function mulberry32(seed: number): () => number {
let state = seed >>> 0;
return () => {
state = (state + 0x6d2b79f5) | 0;
let next = Math.imul(state ^ (state >>> 15), 1 | state);
next = (next + Math.imul(next ^ (next >>> 7), 61 | next)) ^ next;
return ((next ^ (next >>> 14)) >>> 0) / 4294967296;
};
}
A Math.random() on that path would split the daily. Specs stamp 23–31 October 2026 to known boards.
The hint is the solver
nextLogicHint returns one forced step. It does not pick a square at random.
- A mark that disagrees with the hidden enemy is a mistake.
- Once a row, column, or sector has its enemy, the other open squares there are allies.
- An open square that shares a row, column, sector, or a corner with a placed enemy is an ally.
- A row, column, or sector with one candidate left is the enemy.
- A square that every remaining candidate of a line or sector attacks is an ally.
- Confinement: one sector trapped in one line, or one line trapped in one sector; two sectors trapped in the same two lines, and the reverse.
- Depth: place the candidate on a copy and rerun the rules above without this step. If a row, column, or sector is emptied, that candidate is an ally.
techniqueGrade records the strongest rule a full solve needed. Easy may use only singles, the touch, and a finished line or sector. Medium must use a one-line confinement or the attack rule, and must not need a pair or depth. Graded Hard uses a pair confinement or depth. From 31 October 2026, acceptEasierHard requires a pair and rejects a walk that still records depth. A few missed seeds fall back to a checked 9×9. A second solution also rejects the board: countSolutions stops at two.
2. Angular on the front end
The rules are functions in src/app/sonar2/sonar2-logic.ts. That file does not import Angular, so specs call them directly.
The last full Karma run passed 712 tests in src/**/*.spec.ts and shared/**/*.spec.ts. npm run test:stamp is 14 node tests: 3 stamp the release file, 10 check hosting, and 1 checks the Karma timeout. The Firestore rules file has 19 tests.
The shell and the green ally flare travel an arc. prefers-reduced-motion skips that flight: the flare becomes a glow, and the shot shows at once. Cannon, flight, explosion, and splash are recorded clips; the double-tap click is synthesized. Both follow the mute toggle. Progress is stored under sonar-logic-v2.
3. Counters that can only rise by one
Hosting serves the built app. Anonymous totals live in stats/{date}: unique players, starters, finishers, flawless clears, and share counts. A random device id stays in localStorage and is never written. Each claim is taken once per day on that device, then the client increments one field.
function rose(next, prev, field) {
return next.get(field, 0) is int
&& prev.get(field, 0) is int
&& next.get(field, 0) == prev.get(field, 0) + 1;
}
playStatOk allows those fields, and a one-key bump inside sharesByType or shareArrivals, only when rose holds. Clients cannot read the document:
match /stats/{date} {
allow create, update: if usageWriteOk(date);
allow read, delete: if false;
}
The app deploy publishes Hosting and Firestore. The daily board, the friends list, and the challenge link are built in the browser. Google sign-in is optional.
4. The share is a line, not a grid
shareLevelLine prints the level, one orange square per hit, a wave per extra shot, and the clock:
export function shareLevelLine(level: ShareLevel): string {
const name = LOGIC_LEVELS.find((entry) => entry.id === level.level)?.name ?? "Easy";
const hits = Math.max(0, Math.floor(level.hits));
const wrong = Math.max(0, Math.floor(level.missiles) - hits);
const marks = `${"🟧".repeat(hits)}${"🌊".repeat(wrong)}`;
const body = marks ? `${name} ${marks}` : `${name} –`;
const clock = level.time?.trim();
return clock ? `${body} ${clock}` : body;
}
logicShareText adds the mission number, score, rank, and https://sonarpuzzle.com/?r= plus a code. A flawless clear adds the word Flawless. Fleet Admiral appears only when all three daily levels were flawless. Friends load from one roster document, then each friend's career and today's result, cached on the device.
What I learned
-
Test the seed. One
Math.random()on that path splits the daily. - Depth feels like a guess. From 31 October 2026, Hard keeps a pair confinement and drops depth.
- Plus-one is enough for anonymous counters. The device id stays on the device.
- People paste a line, not the grid.
Try it
Today's boards are at sonarpuzzle.com. It is free to play, has no ads, and needs no sign-up. If Hard stalls, the hint is the next rule in that list.
Top comments (0)