Precision Aim Trainer Game — Free HTML CSS JS Snippet
Precision Aim Trainer Game · Games · Plain HTML, CSS & JS · Live preview
For supporters
Source Code
Want the source code?
Play the game for free. Become a WebDevPuneet supporter to unlock the complete HTML, CSS and JavaScript source code, the exports (HTML, React, Vue, Angular, Tailwind) and Fork.
Extend with AI
Take This Snippet Further With AI
The AI prompt for this one is for Patreon supporters ($3 a month), along with a site with no ads. Supporters enter the passcode once and it is remembered in this browser.
What's included
Features
About this UI Snippet
Precision Aim Trainer Game — Time-Decaying Targets, Combo Multipliers and DOM-Based Hit Detection

An aim trainer is a genre of reflex game built around clicking small, often moving or shrinking targets as quickly and accurately as possible. This implementation spawns one target at a time inside a fixed arena, each shrinking visibly as it ages via a CSS keyframe animation, and rewards clicks that land early in a target's lifespan — while a stray click anywhere the target used to be, once it has fully shrunk away, resets a combo multiplier instead of scoring.
Why this differs from a reaction-time or click-speed test
A pure reaction-time tester measures a single interval between a stimulus and one click; a click-speed test measures raw clicks per second against no target at all. This game instead measures *spatial precision under a decaying time budget*: every target has a randomized lifespan (MIN_LIFE to MAX_LIFE milliseconds) during which it visually shrinks from full size to nearly nothing via an injected @keyframes target-shrink animation, and the score for a hit is directly proportional to how early — i.e., how large the target still was — when the click landed. This combines timing pressure with genuine point-and-click accuracy in a way neither a reaction tester nor a click-speed counter does.
Scoring: proportional to remaining life, multiplied by a combo streak
On a target click, performance.now() - born gives the target's exact age in milliseconds, converted to lifeFrac, the fraction of its lifespan remaining (1 for an instant hit, approaching 0 as it's about to expire). Base points scale from that fraction — Math.max(5, Math.round(10 + lifeFrac * 40)) — so a target hit the instant it spawns is worth up to five times more than one hit right before it disappears. Every successful hit also increments a combo multiplier (capped at 8x) that multiplies the base points for that hit and every subsequent one, so a run of consecutive successful hits compounds quickly, while any miss — clicking empty arena space, or a target expiring unclicked — resets combo back to 1x, punishing sloppy or panicked clicking.
Continuous spawning without overlap bugs
Rather than a fixed grid of possible positions, spawnTarget() picks a fully random x/y within the arena bounds (inset by half the target's own size so it never renders partially off-screen) and calls scheduleNextSpawn() at the end of every spawn, which queues the next target after a random 280-620ms delay — creating an unpredictable, continuously-refreshing stream of targets rather than a static pattern a player could memorize. An activeTargets Set tracks every currently-live target DOM node so that a click on a target already removed (e.g. by its own expiry timeout firing in the same tick as a click) is safely ignored via the activeTargets.has(target) guard, preventing double-scoring or errors from a race between the expiry timer and a click handler.
Score popups and a 30-second countdown
Every hit or miss spawns a small floating .popup element showing the exact points earned (green) or the word "miss" (red), animated upward and fading out via a CSS @keyframes pop-fade, giving immediate, readable feedback without interrupting play. A setInterval-driven countdown ticks timeLeft down from 30 seconds; when it reaches zero, endGame() clears all pending timers, removes any live targets, and rebuilds the overlay to show the final score with a "Play again" button, so a full round is always exactly 30 seconds regardless of how many targets were spawned or missed during it.
Step by step
How to Use
- 1Click Start to begin a 30-second runstartGame() resets score, combo, and the countdown, then calls spawnTarget() to place the first shrinking target in the arena.
- 2Click targets as early as possibleEach target shrinks continuously from full size toward nothing over its randomized lifespan. Points scale with how much of that lifespan remains when you click — earlier hits score far more.
- 3Build and protect your comboConsecutive successful hits raise a combo multiplier up to x8, multiplying every subsequent hit's points. Any miss — clicking empty space or letting a target expire — resets the combo to x1.
- 4Watch the score popupsEvery click spawns a floating green "+points" or red "miss" label at the click location so you can read exactly how each hit scored without checking the header stats.
- 5Play until the timer hits zeroA live countdown in the header ticks down from 30. When it reaches 0, endGame() stops spawning, clears the arena, and shows your final score with a Play again button.
- 6Tune the difficultyAdjust MIN_LIFE/MAX_LIFE for how long targets last, MIN_SIZE/MAX_SIZE for their size range, and the 280-620ms range in scheduleNextSpawn() for how densely targets appear.
Real-world uses
Common Use Cases
Got questions?
Frequently Asked Questions
On click, the code computes the target's age in milliseconds since it spawned (via performance.now() - born), converts that into a 0-to-1 remaining-life fraction, and derives base points as Math.max(5, Math.round(10 + lifeFrac * 40)) — so a near-instant hit can score close to 50 base points while a last-moment hit scores closer to the 5-point floor. That base amount is then multiplied by the current combo.
Two things reset combo to 1: clicking anywhere in the arena that is not an active target (handled by onArenaMiss, which checks e.target === arena), and letting any spawned target's expiry setTimeout fire because it was never clicked in time.
A target can be removed by two independent triggers — a user click or its own expiry timeout — that could both fire in quick succession. Checking activeTargets.has(target) before processing a click guarantees a target already removed by its expiry timer is never double-processed or scored after the fact.
spawnTarget() insets the random x/y range by half of that specific target's randomized size (plus a small margin) on every side, using rand(size / 2 + 6, rect.width - size / 2 - 6) and the equivalent for y, so even the largest possible target never renders partially outside the arena bounds.
Lower MIN_LIFE and MAX_LIFE to make targets shrink and expire faster, reduce MIN_SIZE and MAX_SIZE for smaller, harder-to-hit targets, or tighten the random delay range inside scheduleNextSpawn() (currently 280-620ms) to spawn targets more densely and force faster target-switching.
Yes — target and arena click handlers respond to standard click events, which fire on tap for touch devices without any additional touch-specific event listeners needed, though very fast successive taps may benefit from adding pointerdown handling for lower input latency.