An Anti-CAPTCHA That Proves Humanity By Punishing Consistency
How I built a 34KB single-file web experience that gaslights users into questioning their own humanity—using nothing but vanilla JavaScript.
Introduction: The Problem with CAPTCHAs
We've all been there. Clicking on traffic lights. Identifying crosswalks. Squinting at distorted text. CAPTCHAs were supposed to prove we're human, but somewhere along the way, they started making us feel less human.
So I asked myself: What if we inverted the entire premise?
What if instead of rewarding consistency, we punished it? What if the perfect way to fail was to try too hard?
Welcome to PROJECT GASLIT: The Suspicion Meter.
The Core Concept
The premise is beautifully simple:
"Act natural."
That's it. That's the entire instruction.
But here's the twist: there is no way to act natural that the system won't find suspicious.
- Move too smoothly? That's bot-like behavior.
- Move erratically? Overcompensating.
- Don't move? Hesitation is suspicious.
- Move just right? Suspiciously balanced.
The user is trapped in a paradox, and the system is designed to make them question what "natural" even means.
Architecture Overview
The entire application fits in a single HTML file. No frameworks, no libraries, no backend. Just pure, weaponized JavaScript.
┌───────────────────────────────────────────────────────────────┐
│ APPLICATION ARCHITECTURE |
├───────────────────────────────────────────────────────────────┤
│ ┌───────────────────────────────────────────────────────────┐
│ │ HTML STRUCTURE │
│ │ • Container & Layout │
│ │ • Meter Elements │
│ │ • Message Displays │
│ │ • Final Screen Overlay │
│ └───────────────────────────────────────────────────────────┘
│ │
│ ▼
│ ┌───────────────────────────────────────────────────────────┐
│ │ CSS LAYER │
│ │ • Dark Theme (#0f0f14) │
│ │ • Animations (shake, glitch, flicker) │
│ │ • Grain Overlay (Canvas) │
│ │ • Scanlines (CSS Gradient) │
│ └───────────────────────────────────────────────────────────┘
│ │
│ ▼
│ ┌───────────────────────────────────────────────────────────┐
│ │ JAVASCRIPT ENGINE │
│ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ │ INPUT │ │ ANALYSIS │ │ OUTPUT │ │
│ │ │ HANDLERS │──│ ENGINE │──│ SYSTEMS │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ │ MOUSE │ │ SCORING │ │ MESSAGE │ │
│ │ │ TRACKING │ │ ENGINE │ │ SYSTEM │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ │
│ │ │
│ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │
│ │ │ CLICK │ │ DRIFT │ │ AUDIO │ │
│ │ │ ANALYSIS │ │ ENGINE │ │ ENGINE │ │
│ │ └─────────────┘ └─────────────┘ └─────────────┘ │
│ └───────────────────────────────────────────────────────────┘
│
└───────────────────────────────────────────────────────────────┘
Deep Dive: The Technical Implementation
1. Mouse Movement Tracking
The heart of the system is the mouse tracking algorithm. We store the last 30 mouse positions and analyze them in real-time.
// Store positions with timestamps
const mousePositions = [];
const maxPositions = 30;
document.addEventListener('mousemove', (e) => {
mousePositions.push({
x: e.clientX,
y: e.clientY,
time: Date.now()
});
if (mousePositions.length > maxPositions) {
mousePositions.shift();
}
// Analyze every 3rd position for performance
if (mousePositions.length >= 10 && mousePositions.length % 3 === 0) {
const change = calculateMovementSmoothness();
updateSuspicion(change * 0.3);
}
});
The Smoothness Algorithm
We calculate two key metrics:
- Speed Variance - How consistent is the user's movement speed?
- Angle Variance - How often and drastically does direction change?
function calculateMovementSmoothness() {
const speeds = [];
const angles = [];
// Calculate speeds between points
for (let i = 1; i < mousePositions.length; i++) {
const dx = mousePositions[i].x - mousePositions[i-1].x;
const dy = mousePositions[i].y - mousePositions[i-1].y;
const speed = Math.sqrt(dx * dx + dy * dy);
speeds.push(speed);
// Calculate angle changes
if (i > 1) {
const prevDx = mousePositions[i-1].x - mousePositions[i-2].x;
const prevDy = mousePositions[i-1].y - mousePositions[i-2].y;
const angle = Math.atan2(dy, dx) - Math.atan2(prevDy, prevDx);
angles.push(Math.abs(angle));
}
}
// Calculate standard deviation of speeds
const avgSpeed = speeds.reduce((a, b) => a + b, 0) / speeds.length;
const speedVariance = speeds.reduce((sum, s) =>
sum + (s - avgSpeed) ** 2, 0) / speeds.length;
const speedStdDev = Math.sqrt(speedVariance);
const avgAngleChange = angles.length > 0
? angles.reduce((a, b) => a + b, 0) / angles.length
: 0;
// Classify behavior
if (avgAngleChange < 0.1 && speedStdDev < 5) return 20; // Straight line
if (speedStdDev < 2) return 15; // Very smooth
if (speedStdDev > 15 && speedStdDev < 40) return -10; // Natural jitter
if (speedStdDev > 50 || avgAngleChange > 1.5) return -15; // Erratic
return 0; // Neutral
}
Why this works: Bots typically move in smooth, calculated paths. Humans have natural micro-tremors and random variations. By punishing smoothness, we flip the detection model on its head.
2. Click Pattern Analysis
The click system tracks frequency, rhythm, and position patterns.
const clickTimes = [];
const clickPositions = [];
document.addEventListener('click', (e) => {
const now = Date.now();
clickTimes.push(now);
clickPositions.push({ x: e.clientX, y: e.clientY });
// Keep only last 10 clicks
if (clickTimes.length > 10) {
clickTimes.shift();
clickPositions.shift();
}
// Analyze click rhythm
if (clickTimes.length >= 5) {
const intervals = [];
for (let i = 1; i < clickTimes.length; i++) {
intervals.push(clickTimes[i] - clickTimes[i-1]);
}
const avgInterval = intervals.reduce((a, b) => a + b, 0) / intervals.length;
const intervalVariance = intervals.reduce((sum, i) =>
sum + (i - avgInterval) ** 2, 0) / intervals.length;
// Rapid consistent clicking = suspicious
if (avgInterval < 300 && intervalVariance < 1000) {
updateSuspicion(20);
}
// Random irregular clicking = natural
else if (intervalVariance > 50000) {
updateSuspicion(-15);
}
}
});
3. The Over-Correction Detector
This is where the psychology gets interesting. When users realize they're being judged, they change their behavior. We detect this flip and punish it harder.
let lastPatternState = null;
function detectOverCorrection(currentState) {
if (lastPatternState && lastPatternState !== currentState) {
const stateFlip =
(lastPatternState === 'smooth' && currentState === 'erratic') ||
(lastPatternState === 'erratic' && currentState === 'smooth') ||
(lastPatternState === 'straight' && currentState !== 'natural');
if (stateFlip) {
updateSuspicion(25);
showGaslightMessage("That adjustment felt calculated.");
}
}
lastPatternState = currentState;
}
The psychology: Users who realize smooth movement is suspicious will suddenly become erratic. This sudden change is more suspicious than either behavior alone.
4. The Random Drift Engine
To ensure the system feels alive and unpredictable, we introduce random drift every 2-4 seconds.
function startDriftEngine() {
setInterval(() => {
// Random drift between -15 and +15
const drift = Math.random() * 30 - 15;
updateSuspicion(drift);
// Random visual glitches
if (Math.random() < 0.3) triggerMeterGlitch();
if (Math.random() < 0.1) triggerBackgroundFlicker();
// Sometimes move backwards (confusing!)
if (Math.random() < 0.15) {
updateSuspicion(-(Math.random() * 10 + 5));
}
}, Math.random() * 2000 + 2000); // 2-4 seconds
}
Why this matters: Without drift, users could theoretically find a stable behavior. The random drift ensures no equilibrium is possible.
5. The Gaslighting Message System
Messages are categorized by suspicion level and displayed with randomized timing.
const messagePools = {
low: [
"Hmm... almost believable.",
"You're doing fine. Too fine.",
"Natural... suspiciously natural."
],
mid: [
"Why are you trying so hard?",
"That looked rehearsed.",
"You're thinking about it, aren't you?"
],
high: [
"Yeah no, this is not human.",
"I've seen bots behave like this.",
"You're over-optimizing.",
"Stop trying."
]
};
function displayGaslightMessage() {
const pool = suspicionLevel < 30 ? messagePools.low :
suspicionLevel < 70 ? messagePools.mid :
messagePools.high;
const message = pool[Math.floor(Math.random() * pool.length)];
showGaslightMessage(message);
}
// Random interval display
setInterval(() => {
if (Math.random() < 0.4) displayGaslightMessage();
}, Math.random() * 5000 + 5000);
6. The Fake Success Trigger
This is the cruelest feature. When users maintain a "balanced" suspicion level (40-60%) for 10 seconds, they think they've won...
let middleGroundActive = false;
let middleGroundStartTime = 0;
function checkMiddleGround() {
if (suspicionLevel >= 40 && suspicionLevel <= 60) {
if (!middleGroundActive) {
middleGroundActive = true;
middleGroundStartTime = Date.now();
} else if (Date.now() - middleGroundStartTime > 10000) {
triggerFakeSuccess();
}
} else {
middleGroundActive = false;
}
}
function triggerFakeSuccess() {
// First, give them hope
showMiddleFlash();
showGaslightMessage("...wait");
setTimeout(() => {
showGaslightMessage("This might be human");
}, 1500);
// Then crush their dreams
setTimeout(() => {
updateSuspicion(85 - suspicionLevel); // Jump to 85%
showGaslightMessage("Never mind. Too balanced.");
}, 3500);
}
7. Visual Effects Engine
The visual chaos makes the experience feel more intense and unpredictable.
// Screen shake on suspicion spikes
function triggerScreenShake() {
const container = document.getElementById('mainContainer');
container.classList.add('screen-shake');
setTimeout(() => container.classList.remove('screen-shake'), 150);
}
// CSS for shake animation
/*
@keyframes shake {
0%, 100% { transform: translate(0, 0); }
25% { transform: translate(-5px, 3px); }
50% { transform: translate(3px, -3px); }
75% { transform: translate(-3px, 5px); }
}
*/
// Meter glitch effect
function triggerMeterGlitch() {
const meter = document.getElementById('meterFill');
meter.classList.add('glitch');
setTimeout(() => meter.classList.remove('glitch'), 300);
}
// Random cursor lag (the most evil feature)
document.addEventListener('mousemove', (e) => {
if (Math.random() < 0.02) { // 2% chance of lag
setTimeout(() => {
cursor.style.left = e.clientX + 'px';
cursor.style.top = e.clientY + 'px';
}, 200); // 200ms delay
}
});
8. Film Grain Effect
Using Canvas to create a subtle noise overlay that makes everything feel slightly off.
const grainCanvas = document.getElementById('grainCanvas');
const grainCtx = grainCanvas.getContext('2d');
function renderGrain() {
const imageData = grainCtx.createImageData(
grainCanvas.width,
grainCanvas.height
);
const data = imageData.data;
for (let i = 0; i < data.length; i += 4) {
const value = Math.random() * 255;
data[i] = value; // R
data[i + 1] = value; // G
data[i + 2] = value; // B
data[i + 3] = 30; // A (very subtle)
}
grainCtx.putImageData(imageData, 0, 0);
requestAnimationFrame(renderGrain);
}
renderGrain();
9. Audio Engine (Web Audio API)
Subtle audio feedback creates subconscious unease.
let audioCtx = null;
function initAudio() {
if (!audioCtx) {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
}
}
function playGlitchSound() {
if (!audioCtx) return;
const oscillator = audioCtx.createOscillator();
const gainNode = audioCtx.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioCtx.destination);
oscillator.frequency.value = 150 + Math.random() * 100;
oscillator.type = 'sawtooth';
gainNode.gain.value = 0.02;
gainNode.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.1);
oscillator.start(audioCtx.currentTime);
oscillator.stop(audioCtx.currentTime + 0.1);
}
// Ambient hum
function playAmbientHum() {
const oscillator = audioCtx.createOscillator();
const gainNode = audioCtx.createGain();
oscillator.connect(gainNode);
gainNode.connect(audioCtx.destination);
oscillator.frequency.value = 60; // 60Hz hum
oscillator.type = 'sine';
gainNode.gain.value = 0.008; // Very quiet
}
The Win/Lose States
The Fail State (100% Suspicion)
function triggerFail() {
document.getElementById('finalTitle').textContent = 'VERIFICATION FAILED';
document.getElementById('finalTitle').style.color = '#ef4444';
document.getElementById('finalMessage').textContent =
'Reason: Behavioral Optimization Detected';
document.getElementById('finalSubtext').textContent =
"Humans don't try this hard.";
finalScreen.classList.add('visible');
}
The Inconclusive State (35+ seconds)
function triggerFinalScreen() {
document.getElementById('finalTitle').textContent = 'RESULT: INCONCLUSIVE';
document.getElementById('finalMessage').textContent =
"You're either human... or very committed.";
document.getElementById('finalSubtext').textContent =
'This verification cannot be completed.';
finalScreen.classList.add('visible');
}
Performance Considerations
Since we're using requestAnimationFrame and multiple setInterval calls, performance is crucial.
Optimizations Used:
- Position Sampling: Only store last 30 positions
- Throttled Analysis: Analyze every 3rd mouse event
- Efficient Calculations: Use standard deviation instead of complex ML
- CSS Transitions: Hardware-accelerated animations
- Canvas Optimization: Batch pixel operations
// Performance-conscious event handling
let lastAnalysisTime = 0;
const ANALYSIS_THROTTLE = 50; // ms
document.addEventListener('mousemove', (e) => {
const now = Date.now();
if (now - lastAnalysisTime < ANALYSIS_THROTTLE) return;
lastAnalysisTime = now;
// ... analysis code
});
Lessons Learned
1. Paradoxical Design is Powerful
Creating a system with no correct answer forces users to confront their assumptions about how systems work.
2. Micro-interactions Matter
Small details like cursor lag and screen shake dramatically increase perceived "intelligence" of the system.
3. Audio is Underutilized
The subtle ambient hum and glitch sounds added a layer of unease that pure visual systems can't match.
4. Single-File Projects Have Benefits
No build process, no dependencies, instant deployment. The constraint forced elegant solutions.
5. CSS Animations are Powerful
Complex visual effects like shake and glitch can be achieved purely in CSS with minimal JavaScript.
The Anti-Tampering System: Making It "Hack-Proof"
After the initial release, users naturally tried to crack the system by modifying the JavaScript. This led to an interesting challenge: how do you make client-side code tamper-resistant?
The truth is, you can't make it truly "hack-proof"—anything running in the browser can be modified. But we can make it significantly harder.
Layer 1: Code Obfuscation
// Original variable name
let suspicionLevel = 0;
// Obfuscated
let _0xstate = {
_0xs: 0, // suspicion level
_0xr: true, // is running
_0xm: false // middle ground triggered
};
Techniques used:
- Hex-encoded variable names (
_0x1a2b3c) - Base64 encoded strings
- Minified and compressed code
- Self-executing function wrappers (IIFE)
Layer 2: Integrity Checks
// DOM Mutation Observer - detects live editing
const _0xobserver = new MutationObserver(function(_0xmutations) {
_0xmutations.forEach(function(_0xmutation) {
if (_0xmutation.type === 'childList') {
_0xmutation.addedNodes.forEach(function(_0xnode) {
if (_0xnode.tagName === 'SCRIPT') {
_0xtriggerTamperResponse('script_injection');
}
});
}
});
});
_observer.observe(document.body, {
childList: true,
attributes: true,
subtree: true
});
The system watches for:
- New script elements being added
- Attribute changes on critical elements
- DOM structure modifications
Layer 3: Anti-Debugging
// Timing-based profiling detection
function _0xcheckTiming() {
const _0xstart = performance.now();
for (let i = 0; i < 1000; i++) { Math.random(); }
const _0xend = performance.now();
if (_0xend - _0xstart > 50) { // Should be < 1ms normally
_0xtriggerTamperResponse('timing_anomaly'); // Debugger is attached!
}
}
// Debugger statement injection
setInterval(function() {
const _0xstart = Date.now();
debugger; // Pauses only if dev tools are open
const _0xend = Date.now();
if (_0xend - _0xstart > 100) {
_0xtriggerTamperResponse('debugger_detected');
}
}, 3000);
Layer 4: Property Traps
// Trap Object.defineProperty to prevent state manipulation
const _0xoriginalDefineProperty = Object.defineProperty;
Object.defineProperty = function(_0xobj, _0xprop, _0xdescriptor) {
if (_0xprop === 'suspicionLevel' || _0xprop === 'isRunning') {
_0xtriggerTamperResponse('property_tamper');
}
return _0xoriginalDefineProperty.apply(this, arguments);
};
// Trap eval and Function constructor
window.eval = function() {
_0xtriggerTamperResponse('eval_detected');
return void 0;
};
Layer 5: Console Hooking
// Replace all console methods to detect dev tools
const _0xemptyFunc = function() { return void 0; };
['log', 'warn', 'error', 'info', 'clear', 'table'].forEach(function(key) {
console[key] = function() {
_0xdevToolsOpen = true;
return _0xemptyFunc;
};
});
What Happens When Tampering Is Detected
The response is swift and total:
- Tamper warning screen appears - "ANOMALY DETECTED"
- All game state is destroyed - Set to null
- All intervals/timeouts cleared - Loop through and clear everything
- Event listeners removed - Body element is cloned
- Game becomes unresponsive - No way to continue
function _0xtriggerTamperResponse(_0xreason) {
if (_0xtamperDetected) return;
_0xtamperDetected = true;
// Show warning
document.getElementById('tamperWarning').classList.add('visible');
// Destroy state
window._0xgaslitState = null;
// Clear all timers
const _0xid = setTimeout(function() {}, 0);
for (let i = 0; i < _0xid; i++) {
clearTimeout(i);
clearInterval(i);
}
// Remove event listeners via body clone
const _0xoldBody = document.body;
const _0xnewBody = _0xoldBody.cloneNode(true);
_0xoldBody.parentNode.replaceChild(_0xnewBody, _0xoldBody);
}
The Ironic Truth
Here's the thing: even if someone cracks the code and sees all the rules, they still can't win.
The system is designed so that:
- There is no "correct" behavior
- All paths lead to failure or inconclusive
- Knowing the rules doesn't help because the rules are paradoxical
The anti-tampering isn't really about preventing cheating—it's about maintaining the illusion. If users can easily see the code, the magic is lost. The obfuscation preserves the mystery.
Future Enhancements
If I were to expand this project:
- Web Worker Isolation: Move scoring engine to a separate thread for even harder reverse-engineering
- Machine Learning Layer: Train a model on "natural" vs "bot" behavior (defeating the purpose, but interesting)
- Multiplayer Mode: Compare suspicion levels with other users
- Historical Data: Track how users behave across multiple sessions
- Mobile Support: Touch-based tracking for mobile devices
- Exportable Results: Let users share their "behavior profile"
- Server-Side Verification: For real security, send behavioral fingerprints to a server
Conclusion
PROJECT GASLIT: The Suspicion Meter is more than a prank—it's a commentary on the increasingly adversarial relationship between humans and verification systems.
By inverting the rules of CAPTCHA, we created something that:
- Makes users question their own behavior
- Has no correct solution
- Generates genuine emotional responses
- Fits in a single 34KB HTML file
Sometimes the best way to prove you're human is to fail at proving you're human.
Resources
No AI was harmed in the making of this CAPTCHA. Several egos, however, were bruised.
Top comments (0)