Skip to content
Kaptcha docs

Kaptcha reference documentation

<kaptcha-box> is a single custom element implementing 84 verification challenges across 8 families, a 31-modifier friction engine, a progress-reporting protocol, and an optional never-ending mode. One JavaScript file, no dependencies, no assets, no network requirement.

87

Registered challenge definitions

31

Composable friction modifiers

27

HTML attributes

0

Dependencies, assets, network calls

Overview

Cloudflare estimates that a single CAPTCHA takes an average of 32 seconds to solve, and that humanity spends roughly 500 human-years per day proving it is not a robot. Kaptcha collects every documented source of that cost — the artificial fade delay, the ambiguous object boundary, the undisclosed case sensitivity, the re-spawning grid, the progress bar that regresses — into one component, exposes an intensity dial, and labels the result honestly.

Every mechanic it implements already exists somewhere on the live web, deployed in earnest, in front of something a person needed. Kaptcha's only innovation is putting them in one place and turning the dial up.

What it is for

  • Demonstrating dark patterns to designers, students, and regulators.
  • Entertainment, where the user has chosen to engage with it.
  • Frustration-tolerance research with informed participants.
  • Making the 500-years-per-day figure viscerally legible to one person at a time.

What it is not

!

Kaptcha provides no bot resistance.

Every challenge is generated and graded entirely in the browser. Any adversary with a debugger defeats the whole system in under a minute, and the completion token is unsigned and trivially forged. Nothing in this documentation should be read as a claim of security value. See Security position.

Deployment restrictions

!

Kaptcha must not be used to gate access to anything a user needs, is entitled to, or has paid for.

It must not be placed in front of authentication, checkout, support, account deletion, government services, healthcare, or any accessibility-critical path. Doing so is likely to breach consumer-protection and accessibility law in the EU, the UK, and the United States, and is in any case indefensible.

The component enforces a small number of hard limits that no configuration can override. They are listed under Safety limits and they exist because the distinction between satire and harm is not a matter of intent, it is a matter of what the code will actually do at maximum intensity.

Disclosed deception

One mechanic lies to the user. At any intensity above zero, a correct answer may be graded incorrect at most once per session. This reproduces the single most authentic property of real CAPTCHA. It is bounded, it is recorded in session history as falseNegative: true, it is visible through exportSession(), and it is disclosed here and in the README. Set cruelty="0" to disable it entirely.

Quick start

Two lines. There is no key to register, no service to contact, and no configuration step.

<script type="module" src="./kaptcha.js"></script>

<kaptcha-box
  reference="order-4471"
  levels="12"
  difficulty="standard"
  success-url="/welcome">
</kaptcha-box>

Listening for the outcome instead of navigating:

const box = document.querySelector('kaptcha-box')

box.addEventListener('kaptcha:complete', event => {
  const { token, elapsedMs, sufferingMs, attempts } = event.detail
  console.info(`Verified in ${(elapsedMs / 1000).toFixed(1)}s across ${attempts} attempts`)
})

box.addEventListener('kaptcha:abandon', event => {
  console.info(`Gave up at level ${event.detail.level}`)
})

Common configurations

<!-- Ordinary annoyance. The default. -->
<kaptcha-box levels="12" difficulty="standard"></kaptcha-box>

<!-- Timed, punishing, replaces the challenge on failure. -->
<kaptcha-box levels="20" difficulty="cruel" time-limit="45" fail-policy="replace"></kaptcha-box>

<!-- Never ends. Progress asymptotes toward 100% and never arrives. -->
<kaptcha-box loop-mode="hard" difficulty="inhumane" cruelty="85"></kaptcha-box>

<!-- Unwinnable by construction: every failure restarts the session. -->
<kaptcha-box loop-mode="hard" difficulty="catastrophic" fail-policy="restart"></kaptcha-box>
?

In loop-mode="hard" the escape link is rendered from level 1 and allow-escape="false" is ignored. A never-ending experience with no exit is a trap rather than a joke, and the component refuses to build one.

Installation

Copy kaptcha.js into your project and import it. It is an ES module with named and default exports, and it registers the element as a side effect, guarded against double registration.

Import forms

<!-- As a page script -->
<script type="module" src="/vendor/kaptcha.js"></script>

<!-- From a module -->
import KaptchaBox from '/vendor/kaptcha.js'

<!-- Named exports -->
import { KaptchaBox, ChallengeRegistry, CHALLENGES, DEFAULTS, Rng } from '/vendor/kaptcha.js'
KaptchaBox
The element class. Also the default export.
ChallengeRegistry
The registry used to add, look up, and filter challenge definitions.
CHALLENGES
The built-in catalog as a frozen array of definitions.
DEFAULTS
The default configuration object, mirrored by .env.json.
Rng
The seeded mulberry32 generator, so a host can reproduce a session's randomness.

Requirements

No build step, no bundler, no transpiler, no package manager. The file is served as-is and imported as-is. There are no images, fonts, audio files, or third-party resources: every visual is drawn on canvas and every sound is synthesised through Web Audio. The component functions with the network disconnected.

Node.js is not required — for anything, including verification. There is no npm, no lockfile, and no node_modules. The entire toolchain is python3, make, and a browser.

Toolchain

make serve       # project on http://127.0.0.1:18473/
make docs        # documentation on http://127.0.0.1:18473/docs/
make check       # scripts, pages, and configuration
make selftest    # mount every challenge and play a session in headless Chrome
make dump-env    # regenerate .env.json from the component defaults

Verification is three small Python programs in tools/ that parse rather than execute, so make check is safe against untrusted source and works offline.

ToolChecks
jscheck.pyAuthor header, delimiter balance with line numbers, relative import resolution, ES module form, forbidden constructs. Aware of comments, strings, template literals with nested interpolation, and regular expressions.
htmlcheck.pyTag balance, anchor targets, asset resolution, duplicate ids, and viewport zoom policy — the test that enforces the never-defeat-zoom rule on every page in the repository.
envdump.pyExtracts .env.json from the component defaults, or verifies the two have not drifted.
selftest.pyReads the browser harness output and enforces the acceptance criteria: every definition mounts, no uncaught errors, sessions complete, hard loop mode does not.

selftest.html sweeps all 87 registered definitions — composing, mounting, grading, and tearing each one down — then plays a finite session to completion and a hard-loop session that must not complete. Chrome or Chromium is the only optional extra in the whole project, and only for that one target; without it, open the page in any browser and read the result block.

Configuration

Kaptcha uses a single .env.json file as its central point of truth. It carries the development server port and the complete default set for every attribute. The component can dump its own defaults, and the defaults are sane and plug-and-play: the component runs correctly with the file absent.

make dump-env      # writes .env.json

# or from the browser console, on any page that has loaded the component
KaptchaBox.dumpDefaults()

The defaults are authored as a JSON literal inside the source, not as a JavaScript object literal. dumpDefaults() returns that literal verbatim, so it cannot drift from what the component actually uses and key order is preserved; make dump-env extracts the same literal by reading the file, which is why dumping the configuration requires no JavaScript runtime; and make check-env reports any drift between the two.

Precedence

  1. An attribute set on the element.
  2. .env.json, when the host page loads it and applies it.
  3. DEFAULTS inside kaptcha.js.

.env.json

{
  "server": {
    "host": "127.0.0.1",
    "port": 18473
  },
  "kaptcha": {
    "levels": 12,
    "difficulty": "standard",
    "cruelty": 55,
    "timeLimit": 0,
    "timeLimitJitter": 0,
    "attempts": 3,
    "failPolicy": "retry",
    "loopMode": "off",
    "theme": "auto",
    "lang": "en",
    "heading": "Verification required",
    "showProgress": "bar",
    "showTimer": true,
    "sound": true,
    "photosensitiveSafe": false,
    "motion": "auto",
    "allowEscape": true,
    "certificate": true,
    "autostart": true,
    "resume": false,
    "storageKey": "kaptcha",
    "debug": false
  },
  "progress": {
    "url": "",
    "headers": {},
    "events": "all",
    "interval": 0,
    "retryBackoffMs": [1000, 2000, 4000, 8000, 16000],
    "queueDepth": 64
  },
  "success": {
    "url": "",
    "method": "assign",
    "tokenField": "kaptcha_token"
  }
}

The file is generated from DEFAULTS rather than duplicated, so the two cannot drift. The development server binds 127.0.0.1:18473; the port is used consistently across the Makefile, this documentation, and the specification.

Attributes

All 27 attributes are observed and live. Changing one mid-session applies at the next challenge boundary unless noted otherwise. Every attribute has a property mirror in camelCase, so progress-url is box.progressUrl.

Identity and routing

AttributeTypeDefaultBehaviour
referencestring ≤ 128""Opaque caller identifier, echoed in every progress envelope. Control characters are stripped.
session-idstring ≤ 64generatedSession identifier. A UUID v4 is generated and reflected back onto the attribute when absent.
subjectstring ≤ 128""Secondary identifier — the user or form being gated. Echoed in the envelope.
progress-urlURL""Optional POST endpoint for progress envelopes. Empty disables telemetry entirely: no request, no queue, no storage.
progress-headersJSON object{}Additional request headers. A parse failure is logged and treated as {}.
progress-eventscsvallWhich events to transmit: any of start,challenge,attempt,pass,fail,timeout,progress,complete,abandon,despair, or all, or none.
progress-intervalint ms0Minimum interval between transmissions. Events inside the window are coalesced into one batched envelope.
success-urlURL""Navigated to on completion. Empty renders the completion state and fires kaptcha:complete only.
success-methodenumassignassign, replace, post, or none. post submits a generated form carrying reference, session id, and token.
token-fieldstringkaptcha_tokenField name for the token under success-method="post".

Session shape

AttributeTypeDefaultBehaviour
levelsint 1–99912Challenges to pass. Ignored under loop-mode="hard".
difficultyenumstandardmild, standard, cruel, inhumane, catastrophic. Sets the starting tier and the ramp rate.
crueltyint 0–10055Master intensity. Scales delays, tolerances, and every probability in Intensity scaling.
time-limitint seconds0Per-challenge limit. 0 is untimed. Applies to the challenge only, never to imposed delays.
time-limit-jitterint percent0Randomises each limit by ±N %. The displayed countdown reflects the jittered value, so two identical challenges have different limits.
attemptsint 1–993Attempts per challenge before the challenge is failed.
fail-policyenumretryretry new instance of the same type · replace a different type · regress lose a level · restart lose the session.
loop-modeenumoffoff, soft, hard. See Loop mode.
typescsv""Allowlist of challenge ids or family letters. Empty means all.
excludecsv""Denylist, applied after the allowlist.
seedintrandomPRNG seed. Reflected to the attribute when generated, so any session can be reproduced exactly.
autostartbooltrueBegin on connect. When false, call start().
resumeboolfalseRestore an interrupted session from storage on connect.
storage-keystringkaptchalocalStorage key prefix.

Presentation and safety

AttributeTypeDefaultBehaviour
themeenumautoauto, light, dark. auto follows prefers-color-scheme and updates live.
langenumenOnly en ships. The strings table is structured for expansion.
headingstring ≤ 80Verification requiredPanel heading.
show-progressenumbarbar, steps, count, none.
show-timerbooltrueWhether the countdown is visible. When false the limit still applies.
soundbooltrueMaster audio switch. Audio-dependent challenges are excluded when false.
photosensitive-safeboolfalseExcludes flicker challenges and caps all animation at 2.5 Hz. Forced true under prefers-reduced-motion.
motionenumautoauto, full, reduced.
allow-escapebooltrueRenders the escape link. Ignored — always true — under loop-mode="hard" and after five minutes of session time.
escape-urlURL""Where the escape link goes. Empty fires kaptcha:abandon and renders a terminal state.
certificatebooltrueOffer a downloadable certificate of humanity on completion.
debugboolfalseConsole instrumentation, visible challenge id, and Ctrl+Shift+K force-pass. Never enable in a deployment.

Properties

Every attribute has a mirrored property with type coercion. In addition, these are read-only:

PropertyTypeMeaning
statestringidle, running, paused, delaying, complete, abandoned.
levelint1-based index of the current challenge.
totalintlevels, or Infinity under hard loop mode.
elapsedMsintTotal session wall-clock.
sufferingMsintWall-clock minus imposed delay — the time the user was actually working.
historyarrayFrozen per-challenge records.
currentobject{ id, family, tier, cruelty, attempt, deadlineAt }, or null.

Methods

MethodReturnsBehaviour
start()Promise<void>Begins a session. No-op when already running.
pause()voidFreezes timers and animation. Imposed delays already in flight continue.
resume()voidUnfreezes.
reset()voidDestroys session state, clears storage, returns to idle.
skip()booleanAdvances one level. Returns false unless debug is set.
abandon()voidTerminal abandonment. Fires kaptcha:abandon and follows escape-url.
exportSession()objectFull session record: per-challenge timings, attempt logs, modifiers applied, frustration signals.
KaptchaBox.dumpDefaults()stringStatic. The default .env.json as formatted JSON.
KaptchaBox.register(def)voidStatic. Registers a custom challenge definition. See Custom challenges.
KaptchaBox.list()arrayStatic. All 87 registered ids with family, tier, and cruelty index.

Events

All events are CustomEvent, bubbles: true, composed: true, prefixed kaptcha:. Every detail carries { sessionId, reference, subject, level, total, ts } plus the fields below.

EventAdditional detailCancelable
kaptcha:ready{ challengeCount }no
kaptcha:start{ seed, difficulty, cruelty }no
kaptcha:challenge{ id, family, tier, timeLimitMs, modifiers[] }yespreventDefault() re-rolls the challenge, up to 8 times
kaptcha:attempt{ id, attempt, ok, detail }no
kaptcha:pass{ id, attempts, durationMs, sufferingMs }no
kaptcha:fail{ id, attempts, reason }no
kaptcha:timeout{ id, limitMs }no
kaptcha:progress{ fraction, displayedFraction }no
kaptcha:regress{ from, to, reason }no
kaptcha:despair{ signal, value }no
kaptcha:complete{ token, elapsedMs, sufferingMs, attempts, history }yespreventDefault() suppresses navigation
kaptcha:abandon{ level, elapsedMs, reason }no
i

fraction is the true progress. displayedFraction is what the user sees, which runs ahead of reality by displayedLead and may regress on failure. The gap between the two is the most honest metric this component produces, and it is the basis of the gradient sensitivity measurement.

Frustration signals

kaptcha:despair fires when heuristics indicate the user is losing composure. These are genuinely useful for research and are computed locally; no pointer trace or keystroke content ever leaves the browser.

SignalCondition
rage-click5 or more pointer-downs within 1,200 ms inside a 40 px radius.
thrashPointer path length above 4,000 px within 3 s with no successful interaction.
abandonment-hoverPointer exits the viewport top edge — the classic exit-intent signal.
keyboard-mash12 or more keydowns in 1,500 ms with entropy below 2.0 bits per character.
stallNo input event for 25 s while a challenge is active.
tab-flightDocument hidden 3 or more times within one challenge.
retry-spiralThe same challenge id failed 4 or more times in a session.

Progress protocol

When progress-url is set, the component POSTs JSON envelopes describing session progress. When it is empty, no network activity of any kind occurs — there is no queue, no storage, and no beacon.

<kaptcha-box
  reference="study-2026-08"
  subject="participant-17"
  progress-url="/api/kaptcha/progress"
  progress-headers='{"X-Study":"patience-v3"}'
  progress-events="challenge,fail,despair,abandon,complete"
  progress-interval="2000">
</kaptcha-box>

Envelope schema

Content-Type: application/json. One envelope per transmission; batching wraps multiple events in events[].

{
  "v": 1,
  "sessionId": "6f1a…",
  "reference": "order-4471",
  "subject": "participant-17",
  "seed": 918273645,
  "difficulty": "cruel",
  "cruelty": 70,
  "loopMode": "soft",
  "sentAt": "2026-08-04T21:14:52.113Z",
  "client": {
    "ua": "…",
    "viewport": [412, 915],
    "dpr": 2.625,
    "pointer": "coarse",
    "reducedMotion": false,
    "lang": "en-GB",
    "tz": "Europe/Amsterdam"
  },
  "session": {
    "level": 7,
    "total": 12,
    "fraction": 0.5833,
    "displayedFraction": 0.91,
    "elapsedMs": 412903,
    "sufferingMs": 288140,
    "attemptsTotal": 19,
    "failuresTotal": 12,
    "regressions": 2
  },
  "events": [
    {
      "type": "fail",
      "ts": "2026-08-04T21:14:51.998Z",
      "challenge": {
        "id": "slider-notch",
        "family": "C",
        "tier": 3,
        "cruelty": 11,
        "modifiers": ["drift", "delay-4200", "tolerance-half"]
      },
      "attempt": 3,
      "durationMs": 41022,
      "reason": "tolerance",
      "detail": { "offsetPx": 4.7, "tolerancePx": 2 }
    }
  ]
}

Nothing outside this schema is ever transmitted. No keystroke content, no pointer traces, and no form values — in particular, nothing typed into the hostile-form challenge is transmitted, stored, or logged anywhere.

Transport rules

  • Normal events use fetch with keepalive: true.
  • pagehide and visibilitychange to hidden flush the queue through navigator.sendBeacon.
  • Transmission never blocks the interface. All sends are fire-and-forget.
  • Failures retry with exponential backoff at 1 s, 2 s, 4 s, 8 s, 16 s, then drop.
  • Queue depth is capped at 64 envelopes; the oldest are dropped first.
  • credentials: 'omit' unless progress-headers carries an Authorization header, in which case same-origin.
  • Transport failures are never surfaced to the user. A broken endpoint degrades silently.

A minimal receiver:

app.post('/api/kaptcha/progress', express.json({ limit: '256kb' }), (req, res) => {
  const { sessionId, reference, session, events } = req.body
  for (const event of events) {
    log.info({ sessionId, reference, type: event.type, level: session.level, id: event.challenge?.id })
  }
  res.status(204).end()
})

Completion token

kap_<base64url(sessionId)>.<base64url(elapsedMs:attemptsTotal:seed)>.<crc32 hex>
!

This is a receipt, not a credential. It is unsigned, generated client-side, and trivially forged. Do not use it to authorise anything. Its only legitimate use is correlating a completion event with a session record you already hold.

Difficulty and tiers

Every challenge is scored on four axes — Duration, Precision, Ambiguity, Indignity — from 0 to 5 each. Their sum is the cruelty index, from 0 to 20, and it determines the challenge's tier.

TierNameIndexCharacter
1Perfunctory0–5Recognisable as a normal CAPTCHA. Passable in under 10 seconds.
2Tedious6–9Slow but fair. The user begins to notice the delay.
3Adversarial10–13The interface is working against the user and it is now obvious.
4Punitive14–16Failure is expected. Multiple attempts are the norm.
5Absurd17–20The challenge is a joke at the user's expense and does not pretend otherwise.

Difficulty presets

PresetStart tierRampCrueltyTime limitFail policy
mild1+1 every 6 levels, cap 215noneretry
standard1+1 every 4 levels, cap 355noneretry
cruel2+1 every 3 levels, cap 47060 sreplace
inhumane2+1 every 2 levels, cap 58540 sregress
catastrophic3+1 every level, cap 510025 srestart
tier(n) = clamp(startTier + floor((n - 1) / rampInterval), 1, tierCap)

The scheduler draws from challenges at tier(n) or tier(n) − 1, weighted 3:1 in favour of the exact tier. This produces variance without cliff edges.

Intensity scaling

cruelty is the master dial. Every derived quantity is a linear function of k = cruelty / 100.

QuantityFormulaAt 55Effect
P_extra0.05 + 0.35k0.243Probability that a pass triggers "One more round to be sure."
P_regress0.22k0.121Probability that displayed progress visibly drops on failure.
P_falsefail0.06k0.033Probability a correct answer is graded wrong. Capped at one per session.
delayScale0.5 + 2.5k1.875Multiplier on every rung of the delay ladder.
tolerance1.0 − 0.65k0.643Multiplier on every precision tolerance.
modifierCountround(0.5 + 3.5k)2Modifiers applied per challenge.
displayedLead0.08 + 0.22k0.201How far ahead of reality the progress bar runs.

So cruelty="0" yields a component that still presents 84 challenge types but applies no modifiers, no false failures, no progress inflation, and only a token delay. That configuration is what you want if you are using Kaptcha as a puzzle catalog rather than as an instrument of frustration.

The delay ladder

Delays are calibrated against Nielsen's response-time thresholds — 0.1 s for direct manipulation, 1 s for uninterrupted flow, 10 s for holding attention — and against the Doherty threshold at 400 ms. Kaptcha deliberately sits in the worst band for each purpose.

RungBasePurpose
instant0 msBaseline. Used in mild only.
perceptible320 msPast the Doherty threshold. Felt, not resented. Always used for the first challenge of a session.
flow-break1,200 msPast the 1-second limit. Breaks the thought while retaining attention.
doubt4,200 msThe reCAPTCHA fade band. Long enough to wonder whether the page is broken. The default for withholding a verdict.
attention-loss11,000 msPast the 10-second limit. The user mentally leaves and must return.
insult23,000 mscatastrophic only. Accompanied by a progress bar that reaches 97 %.
i

The verdict is computed before the delay and is not touched during it. The component always knows the answer immediately; the delay is theatre. This is why sufferingMs — wall-clock minus imposed delay — is tracked separately from elapsedMs.

Under motion="reduced" animation is suppressed but delays are not reduced. Waiting is not motion, and a user who asked for less animation did not ask for less tedium.

Friction modifiers

31 composable modifiers, each a pure function taking the mounted challenge root and returning a disposer. modifierCount of them are drawn per challenge, filtered by compatibility, and reported in the kaptcha:challenge event and the progress envelope.

IdEffectApplies to
slow-revealThe challenge fades in over 4.2 s. Interaction is blocked until complete.all
fade-outSelected items fade out over 4.2 s before being replaced.B
stagger-refreshReplacements arrive one at a time, 1.2 s apart.B
driftThe primary control drifts 40–120 px along a slow sine path.C, G
fleeThe verify control moves away when the pointer comes within 80 px. Three evasions, then it submits.C, G
shrinkThe target shrinks 1.5 % per second, floor 8 px.C
jitterAll interactive elements jitter ±2 px at 30 Hz.all
inertiaDrag controls carry momentum and overshoot by 12 %.C
invertedDrag axis inverted without notice.C
deadzoneThe first 18 px of any drag are ignored.C
tolerance-halfAll precision tolerances halved.C, F
no-pastePaste, drop, and autofill blocked. "Manual entry required."A, D
no-selectText selection disabled across the stage.all
case-trapThe answer becomes case-sensitive, disclosed only after the second failure.A, D
homoglyphLatin characters in the prompt replaced with Cyrillic and Greek lookalikes. Never in the answer.A
low-contrastContrast reduced to 2.1:1. Never under reduced motion or prefers-contrast: more, and never on more than one challenge in five.A, B
shrinking-textPrompt font decays from 18 px to 9 px over 20 s.A, D
blur-pulseThe stage blurs to 3 px for 900 ms every 6 s.A, B
rotate-frameThe whole stage is rotated 4–11°.B, D
mirrorThe stage is horizontally mirrored. Text is exempt; controls are not.C, D
shuffleGrid contents reshuffle every 3 s, preserving correctness.B, D
decoy-verifyThree to five identical verify buttons; one is live, re-chosen per attempt.all
confirm-chainSubmission requires confirming 2–6 near-identical dialogs, one of which is a trick question.all
are-you-sureA confirmation that appears only when the answer is correct.all
progress-regressOn failure the displayed progress drops by one level, with an audible click.all
cooldownOn failure, an 8–45 s countdown that pauses when the tab is hidden and resets on any keypress.all
queue"You are number N in the verification queue." N descends, then rises once.all
false-progressA determinate bar advances to 97 %, pauses 6 s, then completes.all
extra-roundForces the "one more round" branch on the next pass.all
silent-rulesThe instructions omit one operative constraint, revealed only in the hint after failure.all
keyboard-thiefFocus is returned to a decoy input every 4 s.A, D

Compatibility rules

  • At most one of drift, flee, shrink.
  • At most one of inverted, mirror.
  • cooldown and queue never co-occur.
  • decoy-verify and flee never co-occur.
  • Total applied cruelty delta never exceeds 12 points on a single challenge.
  • jitter, blur-pulse, rotate-frame, and mirror are suppressed under motion="reduced".

Selection algorithm

1. candidates = registry.all()
2. filter by allowlist (types), then denylist (exclude)
3. filter by pointer compatibility with the current input modality
4. filter by requires[] against runtime capability detection
5. filter by tier ∈ { tier(n), tier(n) − 1 }
6. drop any id used in the last 5 levels          (anti-repeat window)
7. drop any family used in the last 2 levels      (anti-clustering)
8. if empty, relax rule 7, then 6, then 5, in that order
9. weighted draw using definition.weight × familyBalance × rng
10. if this is the honest-challenge level, override with 'checkbox-plain'

The anti-repeat window is deliberately short. Seeing the same challenge type again after six levels — when the user has forgotten the specific trap but not the general dread — is more effective than never repeating. familyBalance starts at 1.0 per family, is multiplied by 0.6 each time that family is drawn, and recovers 15 % toward 1.0 each level, so a session visits every family without feeling systematic.

Determinism

All randomness flows through a seeded mulberry32 generator. Math.random does not appear anywhere in the source, and this is enforced by make lint. A session is therefore fully reproducible from (seed, levels, difficulty, cruelty) — which is what makes the acceptance suite possible and what lets a researcher replay exactly what a participant experienced.

The honest challenge

Exactly once per session, at a level drawn uniformly from the middle third, the scheduler presents checkbox-plain: a single checkbox, a 44 px target, no modifiers, no delay, and an instant pass.

i

This is not mercy. It is calibration. The contrast makes every subsequent challenge worse, it re-establishes the sunk-cost gradient at the point where abandonment risk peaks, and it is the moment at which the user realises the cruelty was a choice rather than an accident.

checkbox-plain carries weight: 0 and is never selected by the ordinary weighted draw.

Loop mode

off

The session ends after levels passes. total is finite and progress is honest apart from displayedLead.

soft

The session still ends after levels passes, but P_extra means some passes do not count. The bar sits still after a success while the user watches. Expected challenge count is levels / (1 − P_extra); at the default intensity that is about 15.9 challenges for levels="12".

hard — the never-ending captcha

The session has no terminal condition. Displayed progress follows an asymptote:

displayed(n) = 1 − 1 / (1 + n / 6)
Level6301001,000
Displayed50.0 %83.3 %94.3 %99.4 %never 100 %
  • total is Infinity; the step counter shows Step 7 with no denominator.
  • Every 5th level replaces the challenge with the almost-done interstitial.
  • The tier rises to the cap and stays there.
  • success-url is never navigated to.
  • The escape link is always present, and allow-escape="false" is ignored.
  • On abandonment the terminal panel states plainly that the session could not have been completed.

Hard loop mode is discoverable. It is exposed in the kaptcha:start detail, in every progress envelope, and as data-loop-mode="hard" on the rendered root. Anyone inspecting the page can determine that the session is unwinnable. The joke is not announced, but it is never hidden.

Lifecycle

idle ──start()──▶ preparing ──▶ presenting ──▶ grading ──▶ delaying ──┐
  ▲                                 ▲                                 │
  │                                 └──────── retry ◀─────────────────┤
  │                                                                   │
  └── reset() ◀── complete ◀── advancing ◀────────────────────────────┘
                     │
                  abandoned ◀── abandon() ── (any state)

Per-challenge sequence

  1. Select — the scheduler picks an id. kaptcha:challenge fires and may be cancelled to re-roll, up to 8 times.
  2. Compose — modifiers are drawn and recorded.
  3. Mount — the definition builds DOM into a fresh stage element.
  4. Reveal — a reveal animation runs for 240 ms, or up to 4.2 s with slow-reveal.
  5. Arm — the timer starts and input becomes live.
  6. Attempt — the user submits; validate() returns a verdict.
  7. Withhold — the verdict is delayed by the ladder value. It is already known.
  8. Verdict — rendered; kaptcha:attempt and possibly kaptcha:pass fire.
  9. Extra round — with probability P_extra, a pass becomes "One more round to be sure." The level does not advance.
  10. Teardown — every listener, animation frame, timer, and audio node registered through the context is released.
  11. Advance — the level increments and progress is recomputed.

Cleanup is a contract, not a convention: the challenge context supplies on(), raf(), timer(), and audio() wrappers, and anything registered through them is released automatically. Running 200 challenges must leak no listeners and grow the heap by under 8 MB.

Challenge catalog

87 registered definitions covering 84 challenge concepts across 8 families. The index column is the cruelty index, the sum of the four suffering axes. The pointer column indicates input modality: any challenges work with mouse, touch, and keyboard; fine and coarse definitions are excluded automatically when the modality is unavailable, and every such concept ships in both forms.

87 definitions

IdFamilyTierIndexPointerBehaviour
text-wobbleA14anyDistorted six-character string on canvas. Case-sensitive from tier 2, undisclosed.
text-ambiguousA311anySeven characters drawn only from the confusable set. Rendering is clean; the difficulty is intrinsic.
text-mirrorA310anyType the characters in reverse order. Combines catastrophically with the mirror modifier.
text-fadeA312anyString is shown for 1.4 s then fades away. Two replays, each costing a 6 s countdown.
text-scrollA412anyEight characters scroll through a narrow window. The full string is never simultaneously visible.
text-sequentialA310anyCharacters presented one at a time, 700 ms each. No replay. The reduced-motion substitute for text-scroll.
text-font-lotteryA312anyEach character in a different generic font family, including script faces where case becomes ambiguous.
text-homoglyphA414anyType only the Latin characters. Four of nine are Cyrillic or Greek lookalikes, visually identical.
text-no-pasteA29any24 perfectly legible characters. Paste, drop, and autofill blocked. Trivially easy; takes 40 seconds.
text-audioA313anySix synthesised digits under pink noise, a competing second voice, and slap-back delay. Requires audio.
text-shrinkingA415anyText shrinks 6 % per second from 32 px toward a 5 px floor. Page zoom is never blocked.
text-count-charsA211anyCount occurrences of one letter in a 200-character paragraph. Hints are computed against your last answer.
grid-selectB16anySelect all squares containing the target. Distractors always include a silhouette-sharing category.
grid-refreshB312anyEach correct tile fades out over 4.2 s and is replaced, 45 % of the time with another target. Bounded at 6 replacements.
grid-edgeB415anyOne image sliced into tiles; objects intrude across boundaries by 4, 9, and 18 px. The grader threshold is 6 px.
grid-countB311anyCount the objects in a scene containing occlusions, a reflection, and a depiction on a billboard.
grid-shuffleB414anyTile positions permute every 3 s. Selection correctly follows the tile rather than the position.
grid-rotateB311anyRotate the object upright at 11.25° per press — 32 presses per revolution. Touch adds a low-gain rotate gesture.
grid-jigsawB412anyReassemble a shuffled scene. At tier 4 one pair is rotationally ambiguous until you submit.
grid-soulsB513anySelect all items with a soul. Nine items; four accepted. "The correct answer is not a matter of opinion."
grid-odd-one-outB311anySix items with three overlapping category memberships, engineered so at least two answers are defensible.
grid-spot-differenceB413anyFive differences: a ΔE 12 colour shift, a 3 px displacement, a missing 6 px detail, a mirrored element, an addition.
grid-connectB38anyRotate pipe segments to connect the edges. The only genuinely satisfying challenge; scheduled before tier-5 entries.
grid-find-in-sceneB415anyFind one element among 400 in a scene three viewports wide, with twelve near-misses. Momentum scrolling disabled.
slider-notchC311anySeat a jigsaw piece within 2 px, scaled to 1.28 px at default intensity. Inertial overshoot and a 4 px deadzone.
hold-stillC416anyHold for 12 s without moving 3 px. Requirement silently relaxes after 5 and 8 resets; you are never told.
flee-buttonC313fineThe verify button evades the cursor exactly 7 times, then stops permanently.
flee-button-touchC313coarseThe button teleports away from a landing finger up to 4 times. Fewer evasions, because contact is more startling than hover.
shrink-targetC413anyHit a target three times as it shrinks 4 % per second and relocates. A miss counter serves no functional purpose.
trace-pathC413anyDrag along a 28 px corridor without deviating. Traversal faster than 0.8 s is rejected as "movement too uniform".
drag-sortC313anyDrag six cards into ascending order. Drop snapping is 10 px; the return animation cannot be skipped.
bin-sortC314anySort eight items into three bins. Two items belong plausibly to two bins each; hints disclose one per attempt.
moving-targetC412anyIntercept a bouncing dot five times. It accelerates 8 % per hit; a miss decrements the count.
slot-stopC314anyStop three reels on matching symbols with a constant 90 ms inserted latency. Learnable in four attempts, after which it speeds up.
pinch-rotateC311coarseMatch orientation and scale with a two-finger gesture, held for 600 ms.
key-rotateC311fineThe keyboard equivalent: 5° per arrow press, 2 % per scale press. Typically over 40 keypresses.
long-scrollC213any42,000 px of content with the button at the end. No scrollbar, no End key, no momentum. Collapses at 80 %.
tiny-checkboxC314anyThe familiar panel, with a 9 px checkbox offset from its label. It relocates once on a near miss.
sign-hereC416anyDraw a signature. Rejected for being too smooth: "signature appears machine-generated, please sign naturally".
two-handsC515anyHold two opposite-corner buttons simultaneously for 3 s. Targets are placed outside any one-handed thumb zone.
math-escalateD16anyArithmetic whose complexity scales with level, ending in mixed precedence, modulo, and a term written in words.
math-orderD312anyOrder a fraction, decimal, percentage, root, and exponent whose values differ by less than 0.12.
sequence-continueD311anyContinue a six-term sequence. One rule in six is the English letter-count of the previous term, and is unguessable.
word-searchD311anyFind STOP once in a 10 × 10 grid seeded with six near-misses.
memory-pairsD311anyTwelve cards flipping back after 700 ms. Unmatched cards reshuffle silently after the eighth mismatch.
simonD312anyReproduce a growing sequence to length 8. A single error restarts from length 1, not from the current length.
hanoiD413anyFour discs, fifteen optimal moves, and a purely judgemental move counter.
mazeD415any15 × 15 maze under a 5 × 5 fog window. At tier 5 two walls relocate every 10 s, never breaking solvability.
chess-mateD514anyMate in one from a verified position. Offers "I do not play chess" after the second attempt and substitutes another challenge.
anagramD28anyUnscramble a word with no competing anagram. The third hint gives a definition that is also a small insult.
date-arithmeticD412anyWeekday arithmetic across a 60–400 day offset, one instance in four crossing an irrelevant leap day.
binaryD39anyBase conversion, escalating to hexadecimal and then to base 7.
read-the-termsD516anyScroll 2,800 words of legalese, then answer on clause 14.3 — which is numbered out of order, between 14.7 and 14.8.
reverse-turingD514anyAnswer three questions as a computer would. "A computer would not estimate. A computer would not be amused."
please-waitE212anyA bar reaching 99 %, holding, resetting, three times. The Cancel button is enabled, focusable, and does nothing.
queue-positionE312anyQueue position counts down from 7, rises once at position 2 — "queue reordered" — then completes.
cooldownE311anyA 40 s wait that pauses when the tab is hidden and extends by 3 s on any keypress. The mouse is not penalised.
precision-timerE413anyStop the counter at exactly 10.00 s within 80 ms, scaled to 51 ms at default intensity. Display updates at 10 Hz.
reactionE313anyReact to green under 400 ms, three times consecutively. Two amber decoys during the 2–20 s wait.
type-the-countdownE414anyEnter the value shown at the moment you submit, while it changes as you type. A real skill, wasted here.
wait-for-serverE213anyLog-normal simulated latency, median 18 s, 12 % chance of exceeding 45 s. No request is actually made.
re-verifyE413anyShows a success panel, then expires it, three times.
progress-decayE513anyA bar decaying 1 % per second; each press restores 4 %. Presses are silently capped at 3 per second.
nothing-happensE515anyAn empty panel. Do nothing for 25 s. Any interaction resets the timer, and the rule is never stated up front.
count-the-beepsF312anyCount 7–13 beeps, two of which fall below the individuation threshold. Requires audio.
count-the-flashesF312anyCount 7–13 flashes, hard-capped at 2.5 Hz and 40 % luminance delta. Excluded under photosensitive-safe.
pitch-orderF413anyOrder four tones by pitch. The closest pair is two semitones apart — easy in isolation, less so after four auditions.
rhythm-tapF412anyReproduce a syncopated 8-beat rhythm within ±120 ms, scaled to ±77 ms at default intensity.
colour-matchF414anyMatch a colour to ΔE 9 using H, S, L sliders — while the target was generated in Lab, so the axes do not align.
colour-orderF312anySort eight swatches by hue, two pairs of which differ by 7°. Capped at tier 3 because displays vary.
orientation-tiltF413coarseLevel the bubble and hold 3 s. The target is offset 6°, so resting the phone on a table does not pass.
zoom-checkF514anyDemands 100 % zoom — never selected if you are already at 100 %, and always offers an unconditional bypass.
cookie-consentG417any47 toggles in 6 collapsed accordions, three of which re-enable themselves once. "Reject all" is hidden and takes 4 s.
confirmshameG210anyTwo nested guilt dialogs. Choosing "abandon everything" passes the challenge: "determination confirmed".
double-negativeG413anyFour statements of nested negation. The answer key is the parity of negations, computed per instance.
decoy-buttonsG314anyFive pixel-identical verify buttons; the live one is re-chosen after each wrong press. There is no strategy.
hostile-formG517anyFour fields with contradictory rules revealed one at a time, on violation. Nothing entered is stored or transmitted.
instruction-trapG413anySix instructions; the last cancels the middle four and requires 8 s of inaction. You were, in fact, told.
proof-of-workG514anyFind a suffix satisfying a digit-sum constraint. A parody of proof-of-work CAPTCHA in which the human mines.
resize-windowG515fineResize the viewport to 700 × 500 ± 25 px. A bypass link appears after 20 s for kiosks and fixed windows.
rotate-deviceG515coarseRotate to landscape, then four seconds later back to portrait.
captcha-inceptionG516anyA nested <kaptcha-box> inside the challenge. Depth is capped at 2; depth 3 passes with "recursion limit reached".
checkbox-plainG10anyA checkbox that simply works. Weight 0; selected exactly once per session by the honest-challenge rule.
one-more-roundH311anyInjected, never scheduled. Repeats the challenge just passed without advancing the level.
almost-doneH413anyHard-loop interstitial every 5th level. "Verification is proceeding normally." Continue enables after 6 s.
completionHanyTerminal success. Reports elapsed time, attempts, and the session as a multiple of the 32-second global average.
abandonmentHanyTerminal exit. No guilt, no retry prompt, no second-chance dialog. The exit is clean by design.

Family A — Text and glyph

The oldest family, and the one users recognise as "a real CAPTCHA". Its role in a session is to establish legitimacy in the first two levels and then to betray it.

All rendering is procedural: characters are drawn to a canvas with per-character transform, warp, and noise, using the platform's generic font stacks. No fonts are loaded and no images exist. The character set excludes nothing — l, I, 1, 0, O, rn, and vv are all permitted, and at tier 3 and above they are actively favoured. That is the single most consequential decision in the family.

Hints are true, late, and useless

Family A demonstrates the hint contract precisely. text-ambiguous tells you at the second attempt that the string "may contain both the digit one and the lowercase letter L", and at the third attempt that it contains neither — a statement the instance generator guarantees to be true, by excluding both characters whenever that hint would be reached. The hint is honest, worthless, and retrospectively infuriating, which is the intended shape of every hint in the catalog.

Mobile behaviour

Canvases clamp to min(100%, 320px). Text inputs use autocapitalize="off", autocomplete="off", and spellcheck="false", and are scrolled into view 300 ms after focus so that the virtual keyboard's layout shift does not leave them hidden behind it.

Family B — Grid and image

The family everyone means when they say "CAPTCHA". Every image is drawn procedurally, which means every object boundary is under the component's control, which means every ambiguity in this family is deliberate rather than incidental.

Tiles are never smaller than 64 px on touch and 44 px with a mouse, the grid clamps to 340 px, and selection is rendered as a 4 px inset border plus a badge. Every tier-3 and above member applies the fade-out and stagger-refresh modifiers by default, reproducing the reCAPTCHA v2 behaviour that Google has confirmed is intentional.

Bounded non-termination

grid-refresh replaces each correctly selected tile, 45 % of the time with another target — but the replacement probability decays by 0.08 each time and reaches zero after six replacements. The challenge always terminates. A genuinely endless grid would be indistinguishable from a bug, and a user who believes the page is broken is not being entertained; they are being defrauded of their time.

Engineered ambiguity

grid-edge renders one image sliced into tiles, with objects intruding across boundaries by 4, 9, and 18 px against a 6 px grading threshold, so exactly one boundary case per instance is graded in a way the user could not have predicted. Its two hints — "include squares containing any visible part" and "squares containing only a negligible part should not be included" — contradict each other, are both shown, and are never shown together.

Family C — Precision and motor

This family taxes the body rather than the mind. Tolerances are given in CSS pixels, multiplied by the intensity-derived tolerance factor and by 1.6 on touch. Even after the touch adjustment they remain below the platform-recommended minimum target size, which is the point.

Every concept ships in both fine and coarse forms. A mouse challenge that is merely difficult on a phone becomes impossible, and impossible is off-brand: the component must remain passable, or the satire reads as a defect.

Silent relief valves

Several family C challenges quietly become easier rather than letting a user grind indefinitely. hold-still drops its 12-second requirement to 6 seconds after five resets and to 3 seconds after eight. long-scroll collapses its remaining content at 80 % depth. flee-button stops evading after exactly seven attempts. None of this is disclosed, and users invariably attribute their eventual success to skill.

Touch handling

touch-action is manipulation on the root and escalates to none only on an element actively being dragged, and only after drag confirmation. A blanket touch-action: none would break page scrolling and prevent the user from leaving, which the component does not permit under any configuration.

Family D — Cognitive and logic

Asking the user to think is considerably more insulting than asking them to click, because it implies their time was worth interrupting for something substantive. It is not substantive.

The domain-knowledge exception

chess-mate is the only challenge in the catalog with a built-in substitution: after the second attempt it offers "I do not play chess" and replaces itself with a tier-3 challenge from another family. Gating access on specialist knowledge crosses from tedium into exclusion, and exclusion is not the product.

Instance verification

Generated instances are verified at compose time rather than trusted. math-order regenerates whenever any two values differ by more than 0.12. chess-mate positions are checked exhaustively for a unique mate in one. maze runs a connectivity check before every wall mutation, so the shifting walls can never seal the token in or disconnect the exit.

Family E — Temporal and patience

The purest family. Its members require almost no skill and almost no thought; they require only that the user remain present while nothing happens. They are calibrated directly against Nielsen's thresholds, and they are the reason this component exists.

?

Ethical floor. No single family E challenge may consume more than 120 seconds of wall clock without promoting the escape link to an elevated position. This is a hard limit and an acceptance criterion, not a guideline.

The Cancel button

please-wait renders a Cancel button that is present, enabled, focusable, correctly labelled, and does nothing whatsoever. Pressing it acknowledges the request — "cancellation request received" — and continues. In testing it is the single most effective element in the entire product, and it is four lines of code.

Technically true

queue-position displays an estimated wait computed from the current rate, which is accurate right up until the queue reorders. It becomes a lie in retrospect rather than in the moment. The component never makes a claim that can be proven false at the time it is made — "almost done" is not a falsifiable statement, and that is precisely why it is used.

Family F — Sensory

Perceptual discrimination tasks. Every member has a non-sensory fallback, because a challenge that is impossible for a category of person is not a joke, it is a wall. The fallbacks are equally tedious: count-the-beeps becomes count-the-flashes when audio is off, and count-the-flashes becomes plain arithmetic under photosensitive-safe.

Where the satire stops

zoom-check demands 100 % browser zoom, and is the clearest illustration of the boundary this component observes. It is never selected when the user is already at 100 %, and it always offers a link reading "my display settings cannot be changed" that passes it immediately. Demanding that a user with a vision impairment disable their magnification to access a page is not satire; it is the actual harm the satire is about, and reproducing it faithfully would make Kaptcha the thing it is mocking.

Family G — Meta and anti-UX

The dark-pattern family, drawn from Brignull's original twelve and the five higher-order strategies of Gray et al. — nagging, obstruction, sneaking, interface interference, and forced action. These challenges are not about verification at all; they are about compliance.

StrategyDefinitionWhere it appears
NaggingRepeated, persistent interruption the user did not ask for.Loop mode; "one more round"; the growing queue.
ObstructionMaking the user's actual objective unnecessarily hard to reach.Every challenge. The roach motel is the product.
SneakingHiding or delaying information material to the decision.Undisclosed case sensitivity; rules revealed only on failure; clause 14.3.
Interface interferenceManipulating visual hierarchy to privilege the wrong action.Decoy verify buttons; drifting targets; the 9 px checkbox.
Forced actionRequiring an unrelated action to obtain the desired one.Cookie consent; the signature; window resizing.

Data handling in hostile-form

Nothing entered into hostile-form is transmitted, stored, or retained beyond the challenge's lifetime. Field values never appear in a progress envelope or in exportSession(). The email field uses autocomplete="off" and the password field is type="password" with autocomplete="new-password", so that no password manager records anything. This is enforced, not promised.

Rewarding defiance

confirmshame is the only challenge where refusal is the intended solution. Choosing "abandon everything" through two guilt dialogs passes it, with the message "determination confirmed" — and the session then continues anyway, which is the joke.

Family H — Terminal and loop

Not really challenges. These are session-level states with challenge-shaped interfaces, and they are not scheduled by the ordinary draw.

The completion panel

On success the component reports elapsed time, total attempts, total failures, and the line "Time spent proving you are human: Xm Ys" — followed by the comparison that lands the whole argument: "The global average for a single CAPTCHA is 32 seconds. This session took N times that."

With certificate="true" a canvas-rendered certificate is offered as a PNG download, bearing the session id, elapsed time, reference, challenge count, and the line "The bearer has demonstrated human patience beyond reasonable requirement."

The exit is clean

The abandonment panel shows the elapsed time and the level reached, and — under hard loop mode — the sentence "You spent Xm Ys on this. It was never going to end." There is no guilt trip, no retry prompt, and no second-chance dialog. The entire ethical standing of the project rests on the exit being real.

Theming

Shadow DOM is not used. All internal classes are prefixed k- and all custom properties --k-, and the stylesheet is injected once into document.head as #kaptcha-styles. Every visual value is a custom property, so a host can retheme the component entirely from its own stylesheet without touching the source.

kaptcha-box .kaptcha {
  --k-accent: #7c3aed;
  --k-radius: 4px;
  --k-tile: 84px;
  --k-font: "Inter", system-ui, sans-serif;
}

Custom properties

PropertyDefault (light)Purpose
--k-fontsystem stackInterface typeface.
--k-font-monosystem mono stackCodes, counters, timers.
--k-size15pxBase font size.
--k-radius / --k-radius-sm10px / 6pxCorner radii.
--k-gap / --k-pad12px / 16pxInternal rhythm.
--k-bg / --k-bg-sunken#ffffff / #f4f5f7Surfaces.
--k-fg / --k-fg-muted#16181d / #5b616eText.
--k-line#d7dae0Borders and dividers.
--k-accent / --k-accent-fg#1f6feb / #ffffffPrimary control.
--k-ok / --k-warn / --k-err#2f9e44 / #c8a000 / #d32029Verdict states.
--k-veilrgba(255,255,255,.72)The delay overlay.
--k-dur / --k-dur-slow180ms / 4200msTransition and fade durations.
--k-tile68pxGrid tile size.
--k-target-min44pxMinimum interactive target, except where a challenge deliberately violates it.

All default colour pairings meet WCAG AA contrast. The low-contrast modifier deliberately breaks this at runtime — a documented, suppressible behaviour rather than a baseline defect. The distinction matters.

Responsive behaviour

Layout is driven by container queries rather than media queries, so the component behaves correctly inside a narrow sidebar on a wide screen.

BandWidthBehaviour
Compact< 360 pxHeader collapses to one line, timer becomes numeric, grids clamp to 3 columns, font scale 0.9.
Phone360–599 pxThe default target. Grids up to 4 columns at 68 px cells.
Comfortable≥ 600 pxStage caps at 420 px and centres. Nothing becomes easier on a large screen.
  • Minimum supported viewport is 320 × 480, with no horizontal overflow in any challenge.
  • The stage has a fixed 260 px minimum height so challenge transitions never reflow the host page.
  • Overflowing content scrolls inside the stage, never the document.
  • env(safe-area-inset-*) is respected; dvh is used with a vh fallback.
  • Pointer Events throughout — mouse and touch handlers never coexist for the same interaction.

Keyboard

  • Every challenge is completable by keyboard alone, or declares a pointer requirement and is excluded when that modality is unavailable.
  • Focus is trapped inside the panel only while a modal-style challenge is active, and released otherwise.
  • Tab order follows DOM order; the escape link is always last and always reachable.
  • Escape never closes anything. It reports "Escape is not available during verification." This costs nothing and is the most annoying keyboard behaviour available.

Required viewport meta

<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">

user-scalable=no and maximum-scale=1 must not be used. Disabling pinch zoom is an accessibility violation, and it is not funny.

Motion and contrast

prefers-reduced-motion: reduce forces motion="reduced", which sets --k-dur to zero, disables jitter, blur-pulse, rotate-frame, mirror, and shuffle animation, and substitutes static equivalents for motion-dependent challenges — text-scroll becomes text-sequential, moving-target becomes shrink-target.

prefers-contrast: more suppresses the low-contrast modifier and excludes the two colour-discrimination challenges.

?

Delays are not reduced under reduced motion. Waiting is not motion, and a user who asked for less animation did not ask for less tedium.

Interface copy

All user-facing text is flat, corporate, faintly bureaucratic English. It never winks. The humour derives entirely from the contrast between the banality of the language and the absurdity of the demand.

SituationString
PassVerification step complete.
FailVerification failed. Please try again.
TimeoutTime expired. This attempt has been discarded.
Extra roundOne more round to be sure.
ProgressAlmost done.
Loop-mode interstitialVerification is proceeding normally.
Escape linkI am unable to continue.

Failure is always attributed to the user: the copy says "verification failed", never "we could not verify you". Passive constructions that diffuse responsibility are excluded on purpose. Emoji, exclamatory enthusiasm, self-aware jokes, and the words "fun", "quick", "easy", "just", and "simply" do not appear anywhere in the interface.

Recipes

Gate a form without navigating

<form id="signup" hidden>…</form>
<kaptcha-box id="gate" levels="8" difficulty="mild"></kaptcha-box>

<script type="module">
import './kaptcha.js'

const gate = document.getElementById('gate')
const form = document.getElementById('signup')

gate.addEventListener('kaptcha:complete', event => {
  event.preventDefault()
  gate.hidden = true
  form.hidden = false
  form.querySelector('input[name=kaptcha_token]').value = event.detail.token
})
</script>

Run a frustration study

<kaptcha-box
  reference="study-2026-08"
  subject="participant-17"
  seed="918273645"
  levels="25"
  difficulty="cruel"
  cruelty="70"
  progress-url="/api/study/progress"
  progress-events="challenge,fail,timeout,despair,abandon,complete"
  progress-interval="2000">
</kaptcha-box>

Fixing seed means every participant receives an identical challenge sequence, so differences in outcome are attributable to the participant rather than to the draw. Recording displayedFraction at abandonment measures how well the progress illusion was holding at the moment it failed.

Restrict to a subset

<!-- Only text and grid challenges -->
<kaptcha-box types="A,B"></kaptcha-box>

<!-- Everything except audio, motion, and the terms document -->
<kaptcha-box exclude="text-audio,count-the-beeps,rhythm-tap,pitch-order,read-the-terms"></kaptcha-box>

<!-- A single challenge, repeated, for demonstration -->
<kaptcha-box types="grid-refresh" levels="3" cruelty="90"></kaptcha-box>

Demonstrate one mechanic in a lecture

const box = document.querySelector('kaptcha-box')

box.addEventListener('kaptcha:challenge', event => {
  console.info(event.detail.id, event.detail.modifiers)
  if (!event.detail.modifiers.includes('decoy-verify')) event.preventDefault()
})

Cancelling kaptcha:challenge re-rolls the selection, up to eight times per level, which makes it a practical way to steer a live demonstration toward a specific modifier without editing the source.

Read the metrics afterwards

box.addEventListener('kaptcha:complete', () => {
  const session = box.exportSession()
  const multiple = (session.elapsedMs / 32000).toFixed(1)

  console.table(session.history.map(entry => ({
    id: entry.id,
    tier: entry.tier,
    attempts: entry.attempts,
    seconds: (entry.durationMs / 1000).toFixed(1),
    falseNegative: entry.falseNegative ?? false
  })))

  console.info(`That was ${multiple} average CAPTCHAs.`)
})

Custom challenges

KaptchaBox.register(definition) adds a challenge to the registry. Definitions are validated on registration and rejected with a thrown TypeError if incomplete.

{
  id: 'slider-notch',
  family: 'C',
  tier: 3,
  weight: 1.0,
  cruelty: { d: 3, p: 5, a: 1, i: 2 },
  pointer: 'any',                  // 'any' | 'fine' | 'coarse'
  requires: [],                    // 'audio' | 'motion' | 'orientation' | 'clipboard' | 'storage' | 'vibrate'
  label: 'Fit the piece into the gap.',

  compose:  (ctx) => instance,
  mount:    (ctx, instance) => void,
  validate: (ctx, instance) => ({ ok, reason, detail }),
  hint:     (ctx, instance, attempt) => string | null,
  teardown: (ctx, instance) => void
}

The challenge context

ctx.root
The stage element. A challenge owns it exclusively and must not write outside it.
ctx.rng
A seeded generator scoped to this challenge id. Use it for all randomness.
ctx.ui
Scoped DOM helpers: canvas(), grid(), slider(), input(), button(), prompt().
ctx.on(target, type, fn, opts)
Registers a listener that is released automatically at teardown.
ctx.raf(fn) · ctx.timer(fn, ms)
Registered animation frames and timers, cancelled automatically.
ctx.message(text, kind)
Writes to the live region below the stage.
ctx.submit()
Triggers grading, for challenges that complete without a verify press.
ctx.pointer
'fine' or 'coarse' — the detected modality.
ctx.cruelty
The effective intensity, 0–100, for scaling your own tolerances.

A complete example

import KaptchaBox from './kaptcha.js'

KaptchaBox.register({
  id: 'count-the-vowels',
  family: 'D',
  tier: 2,
  weight: 1,
  cruelty: { d: 3, p: 1, a: 3, i: 3 },
  pointer: 'any',
  requires: [],
  label: 'How many vowels are in the phrase below?',

  compose(ctx) {
    const phrases = ['administrative overhead', 'quarterly reconciliation', 'onboarding questionnaire']
    const phrase = ctx.rng.pick(phrases)
    return { phrase, answer: [...phrase].filter(c => 'aeiou'.includes(c)).length }
  },

  mount(ctx, it) {
    ctx.ui.prompt(it.phrase)
    it.field = ctx.ui.input({ inputmode: 'numeric', maxlength: 3 })
    ctx.on(it.field, 'keydown', e => { if (e.key === 'Enter') ctx.submit() })
  },

  validate(ctx, it) {
    const given = Number.parseInt(it.field.value, 10)
    return given === it.answer
      ? { ok: true }
      : { ok: false, reason: 'count', detail: { given, expected: it.answer } }
  },

  hint(ctx, it, attempt) {
    if (attempt === 2) return 'The letter y is not a vowel for these purposes.'
    if (attempt === 3) return `The answer is between ${it.answer - 2} and ${it.answer + 2}.`
    return null
  },

  teardown(ctx) { ctx.dispose() }
})
?

A definition that registers a listener directly on window or document without going through ctx.on() is defective and will leak. Running 200 challenges must leave no residual listeners and grow the heap by under 8 MB.

Persistence

With resume="true", an interrupted session is restored on connect from localStorage under ${storage-key}:${session-id}.

{
  "v": 1,
  "seed": 918273645,
  "level": 7,
  "startedAt": 1754251200000,
  "elapsedMs": 412903,
  "sufferingMs": 288140,
  "history": [ { "id": "…", "attempts": 2, "durationMs": 18442, "passed": true } ],
  "honestLevel": 5,
  "falseFailUsed": true,
  "familyBalance": { "A": 0.72, "B": 1.0 }
}
  • Written after every level advance and on pagehide.
  • Cleared on completion, on reset(), and on abandonment.
  • Capped at 32 KB; history truncates to the last 50 entries.
  • Storage failures — private mode, quota, disabled — degrade to memory only, are never reported to the user, and never throw.
  • No text entered into any challenge is ever persisted.

On resume the component shows "Resuming verification from step 7." for three seconds. This is the only genuinely user-friendly feature in the product, and it exists because losing a twelve-minute session to an accidental refresh produces support tickets rather than laughter.

Research output

A completed or abandoned session yields a genuinely useful dataset: time-to-first-interaction per challenge, attempts by type, the exact point of abandonment, seven categories of frustration signal with timestamps, and the gap between true and displayed progress at the moment the user gave up.

MetricDefinitionInterpretation
Patience quotientsufferingMs at abandonment or completion.Working time, excluding imposed delay.
Breaking pointCruelty index of the challenge that ended it.Which axis of suffering the participant could not absorb.
Gradient sensitivitydisplayedFraction at abandonment.How well the progress illusion was holding when it failed.
Rage densityFrustration signals per minute.Composure decay rate.
Cloudflare multipleelapsedMs / 32000.How many average CAPTCHAs the session was worth.

Accessibility

!

Kaptcha is not accessible, and does not claim to be.

CAPTCHA already ranks as the single most problematic element on the web in successive WebAIM screen-reader surveys, and audio alternatives fail approximately 46 % of blind users. A component whose stated purpose is to maximise friction cannot simultaneously claim WCAG conformance, and pretending otherwise would be worse than the admission.

What the component nonetheless guarantees:

  • Every challenge carries an aria-label; the message region is aria-live="polite".
  • The escape link is the first focusable element in the footer and is announced as "Leave verification".
  • No challenge relies on colour alone, except the two colour-discrimination tasks — which are excluded under prefers-contrast: more and after two failures.
  • prefers-reduced-motion and prefers-contrast are honoured.
  • zoom-check always offers an unconditional bypass, and page zoom is never blocked.
  • chess-mate offers a substitution rather than gating on specialist knowledge.

The audio challenges are not offered as an accessible alternative to the visual ones, and must not be described as such.

Safety limits

These are enforced in code and cannot be overridden by any configuration, at any intensity.

LimitEnforcement
No seizure riskFlash frequency capped at 2.5 Hz and luminance delta at 40 %, in both the canvas library and the CSS animations. photosensitive-safe removes flashing entirely.
No sudden loud audioAll audio gain-limited to −12 dBFS peak, ramped in over 40 ms, never played without a user gesture.
No motion sicknessNo parallax, no full-viewport motion, no camera-style transforms. All motion suppressed under reduced-motion.
No exit removalThe escape link cannot be disabled under hard loop mode, nor at all after five minutes of session time.
No credential harvestingForm values are never transmitted, stored, or logged; password fields use autocomplete="new-password".
No zoom defeatBrowser zoom, pinch zoom, and OS magnification are never blocked.
No real gateThe component gates nothing of value, requests consent to nothing real, and issues no token any system should trust.
Bounded deceptionAt most one false negative per session, recorded in history and visible in exportSession().
Bounded waitingNo single temporal challenge exceeds 120 s without promoting the escape link.

Security position

Kaptcha offers no bot resistance. Challenges are generated and graded client-side; the answer key is in memory in the page; the completion token is unsigned. Treat every outcome as user-supplied input.

If you actually need bot resistance, the current viable approaches are:

Server-verified proof of work
The browser solves a cryptographic puzzle and the server validates it. Privacy-preserving and invisible, at the cost of client CPU.
Behavioural signals
Pointer trajectory, touch geometry, keystroke dynamics, and focus sequence, evaluated server-side. Effective, with real privacy implications.
Honeypot fields
Trivial to add, defeats unsophisticated automation, and costs the user nothing.
Rate limiting and reputation
Unglamorous, and still the highest-yield measure for most applications.

Current practice combines two or three of these, keeps them invisible, and asks the user for nothing.

Browser support

Requires custom elements, ES modules, Pointer Events, container queries, AudioContext, and canvas.toBlob. In practice: current Chrome, Edge, Firefox, and Safari, on desktop and mobile. Minimum supported viewport is 320 × 480.

Optional capabilities degrade rather than fail. Challenges declaring audio, orientation, clipboard, or storage requirements are silently excluded from selection when the capability is unavailable or permission is denied. Because 87 definitions are registered, exclusion never exhausts the pool.

Troubleshooting

The element renders nothing.
The module did not load, or autostart="false" is set without a call to start(). Check the console for a module resolution error; kaptcha.js must be served with a JavaScript MIME type.
The session never completes.
Check for loop-mode="hard", which has no terminal condition by design. Inspect data-loop-mode on the rendered root, or the kaptcha:start event detail.
Progress events are not arriving.
progress-url must be non-empty and same-origin or CORS-permitted. Transport failures are deliberately silent; enable debug to see the retry queue.
A challenge never appears.
It may declare a capability you lack, a pointer modality you are not using, or a tier outside the current band. KaptchaBox.list() shows every registered definition with its constraints.
The same challenge keeps returning.
The anti-repeat window is 5 levels. With a narrow types allowlist the scheduler relaxes anti-clustering, then anti-repeat, then the tier filter, in that order, in order to have anything to draw.
A correct answer was rejected.
Possibly the once-per-session false negative. It is recorded as falseNegative: true in the history and is disabled entirely at cruelty="0".
Layout breaks inside a narrow container.
The component uses container queries and needs the host to permit at least 320 px of inline size. Check for a parent with overflow: hidden and a fixed narrower width.
Two sessions produced different challenges from the same seed.
Pointer modality, capability detection, and the reduced-motion preference all filter the candidate pool. Reproduction requires matching those conditions as well as the seed.

Glossary

Cruelty index
The sum of a challenge's four suffering scores — duration, precision, ambiguity, indignity — from 0 to 20. Determines tier.
Delay ladder
Six calibrated delay rungs from 0 ms to 23 s, applied to verdict acknowledgement rather than computation.
Displayed fraction
What the progress bar shows, as distinct from fraction, which is the truth.
Extra round
A pass converted into "one more round to be sure", which does not advance the level.
Family balance
The per-family weight multiplier that spreads a session across all eight families without an obvious pattern.
False negative
A correct answer graded wrong. At most one per session, recorded and disclosed.
Honest challenge
The single unmodified checkbox placed in the middle third of every session as calibration.
Imposed delay
Time the component withholds a verdict it already has. Excluded from sufferingMs.
Instance
The seeded parameters produced by a definition's compose() for one presentation.
Suffering time
Wall-clock minus imposed delay: the time the user was actually working.
Tier
A difficulty band from 1 (perfunctory) to 5 (absurd), derived from the cruelty index.