DEV Community

Cover image for Building "The Suspicion Meter": A CAPTCHA That Fails Everyone
Harish Kotra (he/him)
Harish Kotra (he/him)

Posted on

Building "The Suspicion Meter": A CAPTCHA That Fails Everyone

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    │        │    
│   │  └─────────────┘  └─────────────┘  └─────────────┘        │    
│   └───────────────────────────────────────────────────────────┘    
│                                                                    
└───────────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

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);
    }
});
Enter fullscreen mode Exit fullscreen mode

The Smoothness Algorithm

We calculate two key metrics:

  1. Speed Variance - How consistent is the user's movement speed?
  2. 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
}
Enter fullscreen mode Exit fullscreen mode

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);
        }
    }
});
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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
    }
});
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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');
}
Enter fullscreen mode Exit fullscreen mode

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');
}
Enter fullscreen mode Exit fullscreen mode

Performance Considerations

Since we're using requestAnimationFrame and multiple setInterval calls, performance is crucial.

Optimizations Used:

  1. Position Sampling: Only store last 30 positions
  2. Throttled Analysis: Analyze every 3rd mouse event
  3. Efficient Calculations: Use standard deviation instead of complex ML
  4. CSS Transitions: Hardware-accelerated animations
  5. 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
});
Enter fullscreen mode Exit fullscreen mode

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
};
Enter fullscreen mode Exit fullscreen mode

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
});
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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;
};
Enter fullscreen mode Exit fullscreen mode

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;
    };
});
Enter fullscreen mode Exit fullscreen mode

What Happens When Tampering Is Detected

The response is swift and total:

  1. Tamper warning screen appears - "ANOMALY DETECTED"
  2. All game state is destroyed - Set to null
  3. All intervals/timeouts cleared - Loop through and clear everything
  4. Event listeners removed - Body element is cloned
  5. 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);
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. Web Worker Isolation: Move scoring engine to a separate thread for even harder reverse-engineering
  2. Machine Learning Layer: Train a model on "natural" vs "bot" behavior (defeating the purpose, but interesting)
  3. Multiplayer Mode: Compare suspicion levels with other users
  4. Historical Data: Track how users behave across multiple sessions
  5. Mobile Support: Touch-based tracking for mobile devices
  6. Exportable Results: Let users share their "behavior profile"
  7. 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)