Digital Fish Rain is a browser-based feeding arcade game built as a single self-contained HTML file with no external dependencies, no user accounts, and no server-side data collection. Animals fall through a tank as fixed 2Γ3 blocks; the player steers each one into the food bucket it actually eats before it reaches the bottom. This document describes the game's design principles, technical architecture, animal taxonomy and diet system, spawn fairness, scoring model, security posture, and responsive layout strategy.
Digital Fish Rain was built around three constraints, each deliberate:
The game loads as a single HTML page. There is no app to install, no account to create, no download to approve. Any device with a modern browser can play it immediately. This shapes every technical decision in the project β if it requires a build step, a server, or an external dependency, it doesn't belong.
No analytics, no advertising network, no social login. The leaderboard exists only in the player's own browser storage. The server never sees a score, never receives a name, and cannot correlate one session with another. This is not a compliance posture β it is the design. A game about catching fish has no business casting a data net.
Digital Fish Rain started as a falling-block game and was rebuilt around a different core loop: matching, not stacking. Every piece is a fixed 2Γ3 animal β there is no rotation and nothing to clear. The player's only decision is which of seven food buckets to steer into before the piece lands. Twelve marine species, each with its own real (or real-ish) diet, are rendered as either photo-illustrated sprites or bespoke Canvas 2D artwork, layered over a procedural water-sound ambient track. The result plays like a simple sorting game and looks like an aquarium.
The entire game β roughly 2,200 lines β lives in one index.html. CSS, JavaScript, and HTML structure are co-located. There is no bundler, no transpiler, no CDN link, and no node_modules directory. This choice is not a shortcut; it is a deliberate constraint that keeps the game auditable, portable, and offline-capable after first load.
All game graphics β the tank, the falling piece, the On Deck preview, and the Field Guide β are drawn with the HTML5 Canvas 2D API. Where a photo-illustrated sprite exists for a species, drawPieceFish() draws that image, contain-fit and centered in the piece's 2Γ3 footprint, mirrored horizontally for the piece's facing. Where no sprite exists yet β or while one is still loading β a hand-drawn vector renderer takes over: a per-species profile of body color, fin shape, tail style, and markings, bent along a procedural spine. Nothing is a plain colored rectangle.
The game loop runs on requestAnimationFrame, tracking elapsed time via performance.now() to compute frame delta, so drop timing is independent of frame rate. At each tick, the engine checks whether the gravity timer has expired and, if so, advances the active piece one row β unless it has reached the floor row above the buckets, in which case land() resolves the piece immediately: it reads which of the seven bucket lanes the piece's center column falls into, checks that bucket's id against the piece's diet list, awards points and a streak bonus (or spends a life), and spawns the next piece.
The leaderboard is persisted with a Store wrapper around localStorage that catches quota and security exceptions silently, and validates that whatever comes back out is actually an array before use β a tampered or corrupted value falls back to the seed list rather than crashing the page. Up to 20 entries are retained; the display shows the top 8. No other data is written to storage. The game works fully β minus leaderboard persistence β in private browsing mode where localStorage is blocked.
Twelve species inhabit the tank. Every piece is the same fixed 2Γ3 block β there is no rotation and no piece-shape variety. What varies is diet: each species is coded to a list of valid bucket ids, and landing in any bucket on that list is a correct catch. Several species eat more than one thing, which gives the player more than one safe landing spot.
| Type | Species | Diet (valid buckets) | # Buckets |
|---|---|---|---|
| W | Humpback Whale | Krill | 1 |
| D | Bottlenose Dolphin | Squid | 1 |
| T | Stingray | Shrimp | 1 |
| J | Bluefin Tuna | Mackerel | 1 |
| O | Puffer Fish | Shrimp | 1 |
| S | Striped Bass | Squid, Shrimp | 2 |
| I | Barracuda | Mackerel, Small Fish | 2 |
| G | Goliath Grouper | Shrimp, Small Fish | 2 |
| Z | Snapper | Shrimp, Small Fish, Squid | 3 |
| P | Ocean Sunfish | Squid, Zooplankton, Small Fish | 3 |
| Q | Giant Squid | Shrimp, Krill, Squid (+ Crabs β no bucket yet) | 3 |
| K | Orca | Small Fish, Mackerel, Squid, Seal | 4 |
All twelve species now draw from photo-illustrated sprites in animals/sprites/ β background-removed, cropped, and downscaled from source renders. Each one also keeps its original hand-drawn Canvas 2D renderer as a fallback, used only while its sprite is still loading or if it fails to load, so the tank is never missing a piece.
Every active species is drawn from a single shuffled bag containing all twelve types (ACTIVE_TYPES). When the bag empties, refillBag() rebuilds and reshuffles it with a FisherβYates shuffle. This guarantees that within any window of 24 spawns, no species appears more than twice and no species is absent entirely β a property purely random (Math.random() per spawn, no bag) selection does not have.
An earlier version of the game used a rarity-weighted intercept system (some species spawned far less often than others, independent of the shuffle bag) β a holdover from the game's falling-block predecessor, where rare oversized pieces were a deliberate spike in difficulty. That doesn't map onto a matching game: every piece here is the same size and takes the same amount of skill to place correctly, so there's no design reason for one species to be scarcer than another. A flat, equal-probability bag keeps the feeding pace predictable and fair.
Every piece resolves in exactly one of two ways when it lands: a correct catch or a miss. There are no line clears and no per-cell drop bonuses β soft drop and hard drop only control speed, not score.
Landing in any bucket on the piece's diet list scores. The streak counter increments on every correct catch and resets to zero on any miss, so payouts range from 120 (first catch of a streak) up to 200 (five or more in a row). A combo banner names each tier β Nice Catch!, Good Eats!, Tasty!, Well Fed!, and Feeding Frenzy! at the five-plus tier.
Landing in a bucket not on the piece's diet list β or letting a piece reach the floor row without a match β breaks the streak back to zero and costs one life. The player starts each run with 9 lives; the run ends at 0.
The drop interval starts at 900 ms and tightens every 8 correct catches: dropInterval = max(280, 900 β 60 Γ floor(catches / 8)). Speed is driven entirely by successful catches, not by elapsed time or lives lost, so a cautious player who takes their time between drops does not face a faster game than an aggressive one β only accuracy moves the needle.
Digital Fish Rain has no backend server, no API, no user authentication, and no payment system. The only external interaction is the browser loading a static HTML file over HTTPS. This eliminates entire categories of server-side vulnerabilities: SQL injection, broken authentication, insecure deserialization, and server-side request forgery are not applicable by construction.
Reviewed August 18, 2026 against the current codebase β every <script> block across all seven pages, every DOM-write call, every outbound link, and the CSP delivered by each page.
Options -Indexes in .htaccess) so a folder like /animals/ can never render a browsable file index.textContent/Number(), not innerHTML. One unnecessary innerHTML assignment was found in the guide's field-guide renderer β the string was developer-authored and not reachable by player input, so it wasn't exploitable today, but it was still an avoidable foot-gun for a future edit. Replaced with textContent. Zero remaining innerHTML, document.write, or eval/Function() calls in the codebase.getBoard() now verifies the deserialized leaderboard is actually an array before use, so a manually corrupted localStorage value degrades to the seed list instead of crashing the page.<meta> tags, but no .htaccess existed in the repository β only the per-page CSP <meta> tag was real. A <meta> tag cannot deliver frame-ancestors, X-Frame-Options, or Permissions-Policy at all, which meant the site had no actual clickjacking protection. A real .htaccess now sets CSP (with frame-ancestors 'self'), X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy, Cross-Origin-Opener-Policy, Cross-Origin-Resource-Policy, and HSTS, disables directory listing, denies dotfile access, and canonicalizes to https://www.digitalfishrain.com.<script src>, zero <link rel="stylesheet">, and zero third-party JavaScript anywhere in the seven pages. No package.json, no build step, nothing to go stale.rel="noopener noreferrer", verified across every occurrence.All seven pages carry an identical CSP restricting the page to same-origin content, with no external connections (connect-src 'none'), no plugin objects (object-src 'none'), no external form targets (form-action 'none'), and a locked base URI (base-uri 'self'). Because the game uses inline <script> and <style> blocks, 'unsafe-inline' is necessary; a future refactor to an external script file would allow replacing this with a hash-based CSP for stronger XSS protection. The .htaccess header version of the policy additionally sets frame-ancestors 'self', which β unlike every other CSP directive here β only takes effect when delivered as a real HTTP header, never via <meta>.
No analytics, no tracking pixels, no advertising network. The game collects nothing from the player. The leaderboard stores up to 20 entries (3-character initials + integer score) in the player's own browser. This data never leaves the device. The server receives standard HTTP access logs from the hosting provider β IP address, timestamp, resource path β which are outside the game's control and are described in the Privacy Policy.
The game layout is built on CSS Grid with two breakpoints:
| Breakpoint | Layout |
|---|---|
| > 880px | Three-column desktop: Catch Log on the left, game tank center, On Deck + Field Guide on the right. |
| 540pxβ880px | Single-column tablet: panels stack vertically below the tank. |
| < 540px | Two-column phone: the game tank spans the full height of three grid rows on the left; Catch Log and On Deck appear stacked in a 100px right column. A three-row explicit grid ensures each sidebar panel sizes its own row height independently rather than inheriting the tank's full height. |
The tank canvas is a fixed 28Γ18 cell grid at 22px per cell (616Γ396 raster), and the whole element scales down via CSS (max-width: 100%; height: auto) to fit whatever width its grid column has at the current breakpoint β the pixel geometry inside the canvas doesn't change, only its on-screen size does. The On Deck preview canvas, by contrast, computes its cell size adaptively per render β Math.min(28, Math.floor(previewWidth / 3), Math.floor(previewHeight / 2)) β since every piece is a uniform 2Γ3 block, that formula now always resolves to the same 28px cell, filling the 150Γ120 preview frame consistently regardless of species.
On touch-enabled devices the game canvas listens for touchstart, touchmove, and touchend events registered with passive: false so that e.preventDefault() can suppress the browser's default scroll behavior during gameplay.
| Gesture | Action | Implementation |
|---|---|---|
| Swipe left / right | Move piece | Horizontal displacement > 28px, larger than vertical β move(Β±1) |
| Drag down | Soft drop (continuous) | touchmove fires softDrop() every 28px of downward travel; a swipeLocked flag prevents the same gesture from also triggering a horizontal move at touchend |
| Double-tap (bottom 45%) | Hard drop | Two taps within 340 ms, both in the lower 45% of canvas height β hardDrop() |
A plain tap that doesn't meet the double-tap window is a no-op. Earlier versions used a single tap to rotate the active piece; rotation was removed once every piece became a fixed 2Γ3 block with nothing to rotate into.
On screens narrower than 540px, the desktop Reef Radio panel (inside the right sidebar) is hidden via CSS. A compact chip-based Reef Radio control (.mobile-radio) is displayed below the game canvas instead. It exposes the same four track options (Bubbles, Adrift, Fish Rain, Nightfall) as pill-shaped chips and a volume slider. Selecting a chip updates currentTrack, syncs the desktop panel's active state, and calls startMusic() if a game is in progress. The volume slider is bidirectionally synced with the desktop slider so that changing either one updates the other. Default volume on page load is 0 (muted) to respect mobile autoplay restrictions.
Digital Fish Rain has two independent audio systems. The default "Bubble Stream" track and all in-game event sounds are synthesized at runtime with the Web Audio API β nothing to load, no format to worry about. The other seven Reef Radio tracks (Adrift, Constellation Bay, Overcast, Fish Rain, Nightfall, Double Rainbow, Extension) are real recordings played back through local <audio> elements pointed at same-origin files in music/ β no CDN, no external host, same-origin only under the page's CSP.
A continuous underwater atmosphere is generated by scheduling randomized bubble events using the browser's audio context clock (actx.currentTime). Each bubble is a short sine oscillator with a gentle amplitude envelope and a pitch shift that mimics rising bubbles.
A correct catch triggers a short cascade of rising bubble tones, scaled by the current streak (more bubbles at a longer streak). A wrong bucket or a miss triggers a single low square-wave blip. Both are built from the same oscillator primitives as the ambient stream, so event sounds never clash tonally with the background atmosphere.
The AudioContext is not created until the player's first interaction (a click or keypress), per browser autoplay policy. A call to ensureAudio() at the start of every sound function handles lazy initialization and suspended-context resumption transparently.
Digital Fish Rain is developed and operated by Project Research LLC.
The game is free to play at www.digitalfishrain.com.
Legal documents: Privacy Policy · Terms of Use
Contact: admin@projectresearchllc.com · Contacts page