Six ways an auto clicker gives itself away, using nothing but timestamps
I run a small click speed test, CPSBench. Once a site has a leaderboard, the first entries that look too good arrive almost before the real ones do. I can't see anyone's hand. The browser gives me a list of numbers: when
I run a small click speed test, CPSBench. Once a site has a leaderboard, the first entries that look too good arrive almost before the real ones do. I can't see anyone's hand. The browser gives me a list of numbers: when each click landed, relative to the first one. Everything below is about squeezing a verdict out of that list.
What the server actually receives
{
"tool": "cps", "mode": "10", "hits": 74, "ms": 10000,
"stamps": [0, 131, 262, 401, 527, 664, 790, 931, ...]
}
Hits are stamped with performance.now() on pointerdown, rounded to whole milliseconds. Before any of the checks run, the boring validation happens: count matches, first hit at zero, strictly increasing, nothing after the end, score recomputed on the server. A forged payload that fails those never reaches the interesting part.
What makes the rest work is that people and scripts produce differently shaped gap lists, even at the same average speed.
1. The metronome
The laziest auto clicker fires every N ms. Browser timing adds a millisecond of noise, but the coefficient of variation of the gaps (standard deviation over mean) stays tiny.
const gaps = stamps.slice(1).map((t, i) => t - stamps[i]);
const mean = gaps.reduce((a, b) => a + b) / gaps.length;
const sd = Math.sqrt(gaps.reduce((a, g) => a + (g - mean) ** 2, 0) / (gaps.length - 1));
if (sd / mean < 0.03) reject('machine-even gaps');
The steadiest person I could model still sits around 5 to 6 percent. Three percent is a comfortable line.
2. The favourite number
Round every gap to a whole millisecond and count. If one value covers more than 45% of the gaps, or two values together cover more than 75%, it's a timer, possibly one that alternates between two intervals to dodge check #1. Hands never repeat themselves that faithfully.
3. Flat-topped randomness
The smarter clickers pick each interval at random, say anywhere between 60 and 100 ms. The coefficient of variation looks human. The histogram doesn't: it's a flat plateau with hard edges, while human gaps pile up in a lopsided hump with a long tail of slow ones.
Two classic statistics catch that shape. A uniform distribution has zero skew and an excess kurtosis of about -1.2. People are skewed right and rarely go below -0.5.
const m2 = gaps.reduce((a, g) => a + (g - mean) ** 2, 0) / gaps.length;
const m3 = gaps.reduce((a, g) => a + (g - mean) ** 3, 0) / gaps.length;
const m4 = gaps.reduce((a, g) => a + (g - mean) ** 4, 0) / gaps.length;
const skew = m3 / m2 ** 1.5;
const kurt = m4 / m2 ** 2 - 3;
The trap here is butterfly clicking. Two fingers alternate on one button, so the gaps go short, long, short, long: two humps, and a two-humped histogram also has negative kurtosis. Luckily it also has strongly negative lag-1 autocorrelation (each gap predicts the opposite of the next one), while a random clicker has none. So the rule only fires when the autocorrelation is close to zero:
reject when
n ≥ 40andkurt < -1.1and|skew| < 0.35and|lag1| < 0.2
Before I added that last condition, 4% of simulated butterfly runs were being thrown out.
4. Nobody stays fresh
Go flat out for ten seconds and your last two seconds are slower than your first two. Every time. A macro tuned to look human usually forgets that.
For runs of 10 seconds or more above 11 clicks a second, I compare the first and last quarter of the hits. Less than 3% difference and a low coefficient of variation together means rejection. Either on its own happens to real people.
5. The ceiling
Some numbers aren't worth analysing. Every test and length has a cap on average speed, and anything above it is refused before the shape checks:
| test | 1 s | 10 s | 60 s |
|---|---|---|---|
| mouse | 22 | 19 | 14 |
| spacebar | 20 | 17 | 13 |
| drag click | 50 | 35 | 24 |
Drag clicking gets its own row because it really does produce 30+ clicks a second: the finger slides across the button and the switch bounces. It also produces gaps under 30 ms, which the drag test expects and the other tests treat as suspicious.
6. The replay
The cheapest cheat of all is to copy someone else's payload. I hash the first 60 gaps and keep the hash. A second run with an identical sequence gets refused, whoever sends it.
How well it works
I generated 1000 runs per case and per length (5, 10 and 30 seconds):
| simulated run | flagged |
|---|---|
| one finger, 3 to 9 CPS, with fatigue | 0 to 0.1% |
| jitter clicking, 12 CPS | 0 to 0.1% |
| butterfly, 13 CPS | 0% |
| fixed interval, any speed | 100% |
| two alternating intervals | 100% |
| uniform random interval | 52 to 93% (longer runs, more certain) |
| 15 CPS, near-even, no fatigue | 99 to 100% at 10 s and up |
| Gaussian noise at 9 CPS | about 0% |
That last row is the honest part. A script that adds normally distributed noise at a believable speed looks like a calm, consistent human, and nothing that only sees timestamps will tell them apart. The point was never to stop a determined person. It's to stop the free auto clicker everyone downloads first, and to keep impossible numbers off the board.
When a run fails, the player sees which check it failed, in plain words. Anyone who clicks like a robot and is human anyway can email me, and so far nobody has needed to.
Same data, friendlier use
The timestamps are also good for something other than suspicion. The jitter test runs a gentler version of the same maths to guess how you clicked:
If you've built anti-cheat for anything input-based, I'd like to hear which signals held up for you and which ones fell over the moment real users arrived.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.


