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.
Registered challenge definitions
Composable friction modifiers
HTML attributes
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.
| Tool | Checks |
|---|---|
jscheck.py | Author 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.py | Tag 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.py | Extracts .env.json from the component defaults, or verifies the two have not drifted. |
selftest.py | Reads 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
- An attribute set on the element.
.env.json, when the host page loads it and applies it.DEFAULTSinsidekaptcha.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
| Attribute | Type | Default | Behaviour |
|---|---|---|---|
reference | string ≤ 128 | "" | Opaque caller identifier, echoed in every progress envelope. Control characters are stripped. |
session-id | string ≤ 64 | generated | Session identifier. A UUID v4 is generated and reflected back onto the attribute when absent. |
subject | string ≤ 128 | "" | Secondary identifier — the user or form being gated. Echoed in the envelope. |
progress-url | URL | "" | Optional POST endpoint for progress envelopes. Empty disables telemetry entirely: no request, no queue, no storage. |
progress-headers | JSON object | {} | Additional request headers. A parse failure is logged and treated as {}. |
progress-events | csv | all | Which events to transmit: any of start,challenge,attempt,pass,fail,timeout,progress,complete,abandon,despair, or all, or none. |
progress-interval | int ms | 0 | Minimum interval between transmissions. Events inside the window are coalesced into one batched envelope. |
success-url | URL | "" | Navigated to on completion. Empty renders the completion state and fires kaptcha:complete only. |
success-method | enum | assign | assign, replace, post, or none. post submits a generated form carrying reference, session id, and token. |
token-field | string | kaptcha_token | Field name for the token under success-method="post". |
Session shape
| Attribute | Type | Default | Behaviour |
|---|---|---|---|
levels | int 1–999 | 12 | Challenges to pass. Ignored under loop-mode="hard". |
difficulty | enum | standard | mild, standard, cruel, inhumane, catastrophic. Sets the starting tier and the ramp rate. |
cruelty | int 0–100 | 55 | Master intensity. Scales delays, tolerances, and every probability in Intensity scaling. |
time-limit | int seconds | 0 | Per-challenge limit. 0 is untimed. Applies to the challenge only, never to imposed delays. |
time-limit-jitter | int percent | 0 | Randomises each limit by ±N %. The displayed countdown reflects the jittered value, so two identical challenges have different limits. |
attempts | int 1–99 | 3 | Attempts per challenge before the challenge is failed. |
fail-policy | enum | retry | retry new instance of the same type · replace a different type · regress lose a level · restart lose the session. |
loop-mode | enum | off | off, soft, hard. See Loop mode. |
types | csv | "" | Allowlist of challenge ids or family letters. Empty means all. |
exclude | csv | "" | Denylist, applied after the allowlist. |
seed | int | random | PRNG seed. Reflected to the attribute when generated, so any session can be reproduced exactly. |
autostart | bool | true | Begin on connect. When false, call start(). |
resume | bool | false | Restore an interrupted session from storage on connect. |
storage-key | string | kaptcha | localStorage key prefix. |
Presentation and safety
| Attribute | Type | Default | Behaviour |
|---|---|---|---|
theme | enum | auto | auto, light, dark. auto follows prefers-color-scheme and updates live. |
lang | enum | en | Only en ships. The strings table is structured for expansion. |
heading | string ≤ 80 | Verification required | Panel heading. |
show-progress | enum | bar | bar, steps, count, none. |
show-timer | bool | true | Whether the countdown is visible. When false the limit still applies. |
sound | bool | true | Master audio switch. Audio-dependent challenges are excluded when false. |
photosensitive-safe | bool | false | Excludes flicker challenges and caps all animation at 2.5 Hz. Forced true under prefers-reduced-motion. |
motion | enum | auto | auto, full, reduced. |
allow-escape | bool | true | Renders the escape link. Ignored — always true — under loop-mode="hard" and after five minutes of session time. |
escape-url | URL | "" | Where the escape link goes. Empty fires kaptcha:abandon and renders a terminal state. |
certificate | bool | true | Offer a downloadable certificate of humanity on completion. |
debug | bool | false | Console 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:
| Property | Type | Meaning |
|---|---|---|
state | string | idle, running, paused, delaying, complete, abandoned. |
level | int | 1-based index of the current challenge. |
total | int | levels, or Infinity under hard loop mode. |
elapsedMs | int | Total session wall-clock. |
sufferingMs | int | Wall-clock minus imposed delay — the time the user was actually working. |
history | array | Frozen per-challenge records. |
current | object | { id, family, tier, cruelty, attempt, deadlineAt }, or null. |
Methods
| Method | Returns | Behaviour |
|---|---|---|
start() | Promise<void> | Begins a session. No-op when already running. |
pause() | void | Freezes timers and animation. Imposed delays already in flight continue. |
resume() | void | Unfreezes. |
reset() | void | Destroys session state, clears storage, returns to idle. |
skip() | boolean | Advances one level. Returns false unless debug is set. |
abandon() | void | Terminal abandonment. Fires kaptcha:abandon and follows escape-url. |
exportSession() | object | Full session record: per-challenge timings, attempt logs, modifiers applied, frustration signals. |
KaptchaBox.dumpDefaults() | string | Static. The default .env.json as formatted JSON. |
KaptchaBox.register(def) | void | Static. Registers a custom challenge definition. See Custom challenges. |
KaptchaBox.list() | array | Static. 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.
| Event | Additional detail | Cancelable |
|---|---|---|
kaptcha:ready | { challengeCount } | no |
kaptcha:start | { seed, difficulty, cruelty } | no |
kaptcha:challenge | { id, family, tier, timeLimitMs, modifiers[] } | yes — preventDefault() 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 } | yes — preventDefault() suppresses navigation |
kaptcha:abandon | { level, elapsedMs, reason } | no |
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.
| Signal | Condition |
|---|---|
rage-click | 5 or more pointer-downs within 1,200 ms inside a 40 px radius. |
thrash | Pointer path length above 4,000 px within 3 s with no successful interaction. |
abandonment-hover | Pointer exits the viewport top edge — the classic exit-intent signal. |
keyboard-mash | 12 or more keydowns in 1,500 ms with entropy below 2.0 bits per character. |
stall | No input event for 25 s while a challenge is active. |
tab-flight | Document hidden 3 or more times within one challenge. |
retry-spiral | The 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
fetchwithkeepalive: true. pagehideandvisibilitychangeto hidden flush the queue throughnavigator.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'unlessprogress-headerscarries anAuthorizationheader, in which casesame-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.
| Tier | Name | Index | Character |
|---|---|---|---|
| 1 | Perfunctory | 0–5 | Recognisable as a normal CAPTCHA. Passable in under 10 seconds. |
| 2 | Tedious | 6–9 | Slow but fair. The user begins to notice the delay. |
| 3 | Adversarial | 10–13 | The interface is working against the user and it is now obvious. |
| 4 | Punitive | 14–16 | Failure is expected. Multiple attempts are the norm. |
| 5 | Absurd | 17–20 | The challenge is a joke at the user's expense and does not pretend otherwise. |
Difficulty presets
| Preset | Start tier | Ramp | Cruelty | Time limit | Fail policy |
|---|---|---|---|---|---|
mild | 1 | +1 every 6 levels, cap 2 | 15 | none | retry |
standard | 1 | +1 every 4 levels, cap 3 | 55 | none | retry |
cruel | 2 | +1 every 3 levels, cap 4 | 70 | 60 s | replace |
inhumane | 2 | +1 every 2 levels, cap 5 | 85 | 40 s | regress |
catastrophic | 3 | +1 every level, cap 5 | 100 | 25 s | restart |
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.
| Quantity | Formula | At 55 | Effect |
|---|---|---|---|
P_extra | 0.05 + 0.35k | 0.243 | Probability that a pass triggers "One more round to be sure." |
P_regress | 0.22k | 0.121 | Probability that displayed progress visibly drops on failure. |
P_falsefail | 0.06k | 0.033 | Probability a correct answer is graded wrong. Capped at one per session. |
delayScale | 0.5 + 2.5k | 1.875 | Multiplier on every rung of the delay ladder. |
tolerance | 1.0 − 0.65k | 0.643 | Multiplier on every precision tolerance. |
modifierCount | round(0.5 + 3.5k) | 2 | Modifiers applied per challenge. |
displayedLead | 0.08 + 0.22k | 0.201 | How 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.
| Rung | Base | Purpose |
|---|---|---|
instant | 0 ms | Baseline. Used in mild only. |
perceptible | 320 ms | Past the Doherty threshold. Felt, not resented. Always used for the first challenge of a session. |
flow-break | 1,200 ms | Past the 1-second limit. Breaks the thought while retaining attention. |
doubt | 4,200 ms | The reCAPTCHA fade band. Long enough to wonder whether the page is broken. The default for withholding a verdict. |
attention-loss | 11,000 ms | Past the 10-second limit. The user mentally leaves and must return. |
insult | 23,000 ms | catastrophic only. Accompanied by a progress bar that reaches 97 %. |
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.
| Id | Effect | Applies to |
|---|---|---|
slow-reveal | The challenge fades in over 4.2 s. Interaction is blocked until complete. | all |
fade-out | Selected items fade out over 4.2 s before being replaced. | B |
stagger-refresh | Replacements arrive one at a time, 1.2 s apart. | B |
drift | The primary control drifts 40–120 px along a slow sine path. | C, G |
flee | The verify control moves away when the pointer comes within 80 px. Three evasions, then it submits. | C, G |
shrink | The target shrinks 1.5 % per second, floor 8 px. | C |
jitter | All interactive elements jitter ±2 px at 30 Hz. | all |
inertia | Drag controls carry momentum and overshoot by 12 %. | C |
inverted | Drag axis inverted without notice. | C |
deadzone | The first 18 px of any drag are ignored. | C |
tolerance-half | All precision tolerances halved. | C, F |
no-paste | Paste, drop, and autofill blocked. "Manual entry required." | A, D |
no-select | Text selection disabled across the stage. | all |
case-trap | The answer becomes case-sensitive, disclosed only after the second failure. | A, D |
homoglyph | Latin characters in the prompt replaced with Cyrillic and Greek lookalikes. Never in the answer. | A |
low-contrast | Contrast 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-text | Prompt font decays from 18 px to 9 px over 20 s. | A, D |
blur-pulse | The stage blurs to 3 px for 900 ms every 6 s. | A, B |
rotate-frame | The whole stage is rotated 4–11°. | B, D |
mirror | The stage is horizontally mirrored. Text is exempt; controls are not. | C, D |
shuffle | Grid contents reshuffle every 3 s, preserving correctness. | B, D |
decoy-verify | Three to five identical verify buttons; one is live, re-chosen per attempt. | all |
confirm-chain | Submission requires confirming 2–6 near-identical dialogs, one of which is a trick question. | all |
are-you-sure | A confirmation that appears only when the answer is correct. | all |
progress-regress | On failure the displayed progress drops by one level, with an audible click. | all |
cooldown | On 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-progress | A determinate bar advances to 97 %, pauses 6 s, then completes. | all |
extra-round | Forces the "one more round" branch on the next pass. | all |
silent-rules | The instructions omit one operative constraint, revealed only in the hint after failure. | all |
keyboard-thief | Focus 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. cooldownandqueuenever co-occur.decoy-verifyandfleenever co-occur.- Total applied cruelty delta never exceeds 12 points on a single challenge.
jitter,blur-pulse,rotate-frame, andmirrorare suppressed undermotion="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.
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)
| Level | 6 | 30 | 100 | 1,000 | ∞ |
|---|---|---|---|---|---|
| Displayed | 50.0 % | 83.3 % | 94.3 % | 99.4 % | never 100 % |
totalisInfinity; the step counter showsStep 7with no denominator.- Every 5th level replaces the challenge with the
almost-doneinterstitial. - The tier rises to the cap and stays there.
success-urlis 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
- Select — the scheduler picks an id.
kaptcha:challengefires and may be cancelled to re-roll, up to 8 times. - Compose — modifiers are drawn and recorded.
- Mount — the definition builds DOM into a fresh stage element.
- Reveal — a reveal animation runs for 240 ms, or up to 4.2 s with
slow-reveal. - Arm — the timer starts and input becomes live.
- Attempt — the user submits;
validate()returns a verdict. - Withhold — the verdict is delayed by the ladder value. It is already known.
- Verdict — rendered;
kaptcha:attemptand possiblykaptcha:passfire. - Extra round — with probability
P_extra, a pass becomes "One more round to be sure." The level does not advance. - Teardown — every listener, animation frame, timer, and audio node registered through the context is released.
- 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
| Id | Family | Tier | Index | Pointer | Behaviour |
|---|---|---|---|---|---|
text-wobble | A | 1 | 4 | any | Distorted six-character string on canvas. Case-sensitive from tier 2, undisclosed. |
text-ambiguous | A | 3 | 11 | any | Seven characters drawn only from the confusable set. Rendering is clean; the difficulty is intrinsic. |
text-mirror | A | 3 | 10 | any | Type the characters in reverse order. Combines catastrophically with the mirror modifier. |
text-fade | A | 3 | 12 | any | String is shown for 1.4 s then fades away. Two replays, each costing a 6 s countdown. |
text-scroll | A | 4 | 12 | any | Eight characters scroll through a narrow window. The full string is never simultaneously visible. |
text-sequential | A | 3 | 10 | any | Characters presented one at a time, 700 ms each. No replay. The reduced-motion substitute for text-scroll. |
text-font-lottery | A | 3 | 12 | any | Each character in a different generic font family, including script faces where case becomes ambiguous. |
text-homoglyph | A | 4 | 14 | any | Type only the Latin characters. Four of nine are Cyrillic or Greek lookalikes, visually identical. |
text-no-paste | A | 2 | 9 | any | 24 perfectly legible characters. Paste, drop, and autofill blocked. Trivially easy; takes 40 seconds. |
text-audio | A | 3 | 13 | any | Six synthesised digits under pink noise, a competing second voice, and slap-back delay. Requires audio. |
text-shrinking | A | 4 | 15 | any | Text shrinks 6 % per second from 32 px toward a 5 px floor. Page zoom is never blocked. |
text-count-chars | A | 2 | 11 | any | Count occurrences of one letter in a 200-character paragraph. Hints are computed against your last answer. |
grid-select | B | 1 | 6 | any | Select all squares containing the target. Distractors always include a silhouette-sharing category. |
grid-refresh | B | 3 | 12 | any | Each correct tile fades out over 4.2 s and is replaced, 45 % of the time with another target. Bounded at 6 replacements. |
grid-edge | B | 4 | 15 | any | One image sliced into tiles; objects intrude across boundaries by 4, 9, and 18 px. The grader threshold is 6 px. |
grid-count | B | 3 | 11 | any | Count the objects in a scene containing occlusions, a reflection, and a depiction on a billboard. |
grid-shuffle | B | 4 | 14 | any | Tile positions permute every 3 s. Selection correctly follows the tile rather than the position. |
grid-rotate | B | 3 | 11 | any | Rotate the object upright at 11.25° per press — 32 presses per revolution. Touch adds a low-gain rotate gesture. |
grid-jigsaw | B | 4 | 12 | any | Reassemble a shuffled scene. At tier 4 one pair is rotationally ambiguous until you submit. |
grid-souls | B | 5 | 13 | any | Select all items with a soul. Nine items; four accepted. "The correct answer is not a matter of opinion." |
grid-odd-one-out | B | 3 | 11 | any | Six items with three overlapping category memberships, engineered so at least two answers are defensible. |
grid-spot-difference | B | 4 | 13 | any | Five differences: a ΔE 12 colour shift, a 3 px displacement, a missing 6 px detail, a mirrored element, an addition. |
grid-connect | B | 3 | 8 | any | Rotate pipe segments to connect the edges. The only genuinely satisfying challenge; scheduled before tier-5 entries. |
grid-find-in-scene | B | 4 | 15 | any | Find one element among 400 in a scene three viewports wide, with twelve near-misses. Momentum scrolling disabled. |
slider-notch | C | 3 | 11 | any | Seat a jigsaw piece within 2 px, scaled to 1.28 px at default intensity. Inertial overshoot and a 4 px deadzone. |
hold-still | C | 4 | 16 | any | Hold for 12 s without moving 3 px. Requirement silently relaxes after 5 and 8 resets; you are never told. |
flee-button | C | 3 | 13 | fine | The verify button evades the cursor exactly 7 times, then stops permanently. |
flee-button-touch | C | 3 | 13 | coarse | The button teleports away from a landing finger up to 4 times. Fewer evasions, because contact is more startling than hover. |
shrink-target | C | 4 | 13 | any | Hit a target three times as it shrinks 4 % per second and relocates. A miss counter serves no functional purpose. |
trace-path | C | 4 | 13 | any | Drag along a 28 px corridor without deviating. Traversal faster than 0.8 s is rejected as "movement too uniform". |
drag-sort | C | 3 | 13 | any | Drag six cards into ascending order. Drop snapping is 10 px; the return animation cannot be skipped. |
bin-sort | C | 3 | 14 | any | Sort eight items into three bins. Two items belong plausibly to two bins each; hints disclose one per attempt. |
moving-target | C | 4 | 12 | any | Intercept a bouncing dot five times. It accelerates 8 % per hit; a miss decrements the count. |
slot-stop | C | 3 | 14 | any | Stop three reels on matching symbols with a constant 90 ms inserted latency. Learnable in four attempts, after which it speeds up. |
pinch-rotate | C | 3 | 11 | coarse | Match orientation and scale with a two-finger gesture, held for 600 ms. |
key-rotate | C | 3 | 11 | fine | The keyboard equivalent: 5° per arrow press, 2 % per scale press. Typically over 40 keypresses. |
long-scroll | C | 2 | 13 | any | 42,000 px of content with the button at the end. No scrollbar, no End key, no momentum. Collapses at 80 %. |
tiny-checkbox | C | 3 | 14 | any | The familiar panel, with a 9 px checkbox offset from its label. It relocates once on a near miss. |
sign-here | C | 4 | 16 | any | Draw a signature. Rejected for being too smooth: "signature appears machine-generated, please sign naturally". |
two-hands | C | 5 | 15 | any | Hold two opposite-corner buttons simultaneously for 3 s. Targets are placed outside any one-handed thumb zone. |
math-escalate | D | 1 | 6 | any | Arithmetic whose complexity scales with level, ending in mixed precedence, modulo, and a term written in words. |
math-order | D | 3 | 12 | any | Order a fraction, decimal, percentage, root, and exponent whose values differ by less than 0.12. |
sequence-continue | D | 3 | 11 | any | Continue a six-term sequence. One rule in six is the English letter-count of the previous term, and is unguessable. |
word-search | D | 3 | 11 | any | Find STOP once in a 10 × 10 grid seeded with six near-misses. |
memory-pairs | D | 3 | 11 | any | Twelve cards flipping back after 700 ms. Unmatched cards reshuffle silently after the eighth mismatch. |
simon | D | 3 | 12 | any | Reproduce a growing sequence to length 8. A single error restarts from length 1, not from the current length. |
hanoi | D | 4 | 13 | any | Four discs, fifteen optimal moves, and a purely judgemental move counter. |
maze | D | 4 | 15 | any | 15 × 15 maze under a 5 × 5 fog window. At tier 5 two walls relocate every 10 s, never breaking solvability. |
chess-mate | D | 5 | 14 | any | Mate in one from a verified position. Offers "I do not play chess" after the second attempt and substitutes another challenge. |
anagram | D | 2 | 8 | any | Unscramble a word with no competing anagram. The third hint gives a definition that is also a small insult. |
date-arithmetic | D | 4 | 12 | any | Weekday arithmetic across a 60–400 day offset, one instance in four crossing an irrelevant leap day. |
binary | D | 3 | 9 | any | Base conversion, escalating to hexadecimal and then to base 7. |
read-the-terms | D | 5 | 16 | any | Scroll 2,800 words of legalese, then answer on clause 14.3 — which is numbered out of order, between 14.7 and 14.8. |
reverse-turing | D | 5 | 14 | any | Answer three questions as a computer would. "A computer would not estimate. A computer would not be amused." |
please-wait | E | 2 | 12 | any | A bar reaching 99 %, holding, resetting, three times. The Cancel button is enabled, focusable, and does nothing. |
queue-position | E | 3 | 12 | any | Queue position counts down from 7, rises once at position 2 — "queue reordered" — then completes. |
cooldown | E | 3 | 11 | any | A 40 s wait that pauses when the tab is hidden and extends by 3 s on any keypress. The mouse is not penalised. |
precision-timer | E | 4 | 13 | any | Stop the counter at exactly 10.00 s within 80 ms, scaled to 51 ms at default intensity. Display updates at 10 Hz. |
reaction | E | 3 | 13 | any | React to green under 400 ms, three times consecutively. Two amber decoys during the 2–20 s wait. |
type-the-countdown | E | 4 | 14 | any | Enter the value shown at the moment you submit, while it changes as you type. A real skill, wasted here. |
wait-for-server | E | 2 | 13 | any | Log-normal simulated latency, median 18 s, 12 % chance of exceeding 45 s. No request is actually made. |
re-verify | E | 4 | 13 | any | Shows a success panel, then expires it, three times. |
progress-decay | E | 5 | 13 | any | A bar decaying 1 % per second; each press restores 4 %. Presses are silently capped at 3 per second. |
nothing-happens | E | 5 | 15 | any | An empty panel. Do nothing for 25 s. Any interaction resets the timer, and the rule is never stated up front. |
count-the-beeps | F | 3 | 12 | any | Count 7–13 beeps, two of which fall below the individuation threshold. Requires audio. |
count-the-flashes | F | 3 | 12 | any | Count 7–13 flashes, hard-capped at 2.5 Hz and 40 % luminance delta. Excluded under photosensitive-safe. |
pitch-order | F | 4 | 13 | any | Order four tones by pitch. The closest pair is two semitones apart — easy in isolation, less so after four auditions. |
rhythm-tap | F | 4 | 12 | any | Reproduce a syncopated 8-beat rhythm within ±120 ms, scaled to ±77 ms at default intensity. |
colour-match | F | 4 | 14 | any | Match a colour to ΔE 9 using H, S, L sliders — while the target was generated in Lab, so the axes do not align. |
colour-order | F | 3 | 12 | any | Sort eight swatches by hue, two pairs of which differ by 7°. Capped at tier 3 because displays vary. |
orientation-tilt | F | 4 | 13 | coarse | Level the bubble and hold 3 s. The target is offset 6°, so resting the phone on a table does not pass. |
zoom-check | F | 5 | 14 | any | Demands 100 % zoom — never selected if you are already at 100 %, and always offers an unconditional bypass. |
cookie-consent | G | 4 | 17 | any | 47 toggles in 6 collapsed accordions, three of which re-enable themselves once. "Reject all" is hidden and takes 4 s. |
confirmshame | G | 2 | 10 | any | Two nested guilt dialogs. Choosing "abandon everything" passes the challenge: "determination confirmed". |
double-negative | G | 4 | 13 | any | Four statements of nested negation. The answer key is the parity of negations, computed per instance. |
decoy-buttons | G | 3 | 14 | any | Five pixel-identical verify buttons; the live one is re-chosen after each wrong press. There is no strategy. |
hostile-form | G | 5 | 17 | any | Four fields with contradictory rules revealed one at a time, on violation. Nothing entered is stored or transmitted. |
instruction-trap | G | 4 | 13 | any | Six instructions; the last cancels the middle four and requires 8 s of inaction. You were, in fact, told. |
proof-of-work | G | 5 | 14 | any | Find a suffix satisfying a digit-sum constraint. A parody of proof-of-work CAPTCHA in which the human mines. |
resize-window | G | 5 | 15 | fine | Resize the viewport to 700 × 500 ± 25 px. A bypass link appears after 20 s for kiosks and fixed windows. |
rotate-device | G | 5 | 15 | coarse | Rotate to landscape, then four seconds later back to portrait. |
captcha-inception | G | 5 | 16 | any | A nested <kaptcha-box> inside the challenge. Depth is capped at 2; depth 3 passes with "recursion limit reached". |
checkbox-plain | G | 1 | 0 | any | A checkbox that simply works. Weight 0; selected exactly once per session by the honest-challenge rule. |
one-more-round | H | 3 | 11 | any | Injected, never scheduled. Repeats the challenge just passed without advancing the level. |
almost-done | H | 4 | 13 | any | Hard-loop interstitial every 5th level. "Verification is proceeding normally." Continue enables after 6 s. |
completion | H | — | — | any | Terminal success. Reports elapsed time, attempts, and the session as a multiple of the 32-second global average. |
abandonment | H | — | — | any | Terminal exit. No guilt, no retry prompt, no second-chance dialog. The exit is clean by design. |
No definitions match the current filters.
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.
| Strategy | Definition | Where it appears |
|---|---|---|
| Nagging | Repeated, persistent interruption the user did not ask for. | Loop mode; "one more round"; the growing queue. |
| Obstruction | Making the user's actual objective unnecessarily hard to reach. | Every challenge. The roach motel is the product. |
| Sneaking | Hiding or delaying information material to the decision. | Undisclosed case sensitivity; rules revealed only on failure; clause 14.3. |
| Interface interference | Manipulating visual hierarchy to privilege the wrong action. | Decoy verify buttons; drifting targets; the 9 px checkbox. |
| Forced action | Requiring 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
| Property | Default (light) | Purpose |
|---|---|---|
--k-font | system stack | Interface typeface. |
--k-font-mono | system mono stack | Codes, counters, timers. |
--k-size | 15px | Base font size. |
--k-radius / --k-radius-sm | 10px / 6px | Corner radii. |
--k-gap / --k-pad | 12px / 16px | Internal rhythm. |
--k-bg / --k-bg-sunken | #ffffff / #f4f5f7 | Surfaces. |
--k-fg / --k-fg-muted | #16181d / #5b616e | Text. |
--k-line | #d7dae0 | Borders and dividers. |
--k-accent / --k-accent-fg | #1f6feb / #ffffff | Primary control. |
--k-ok / --k-warn / --k-err | #2f9e44 / #c8a000 / #d32029 | Verdict states. |
--k-veil | rgba(255,255,255,.72) | The delay overlay. |
--k-dur / --k-dur-slow | 180ms / 4200ms | Transition and fade durations. |
--k-tile | 68px | Grid tile size. |
--k-target-min | 44px | Minimum 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.
| Band | Width | Behaviour |
|---|---|---|
| Compact | < 360 px | Header collapses to one line, timer becomes numeric, grids clamp to 3 columns, font scale 0.9. |
| Phone | 360–599 px | The default target. Grids up to 4 columns at 68 px cells. |
| Comfortable | ≥ 600 px | Stage 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;dvhis used with avhfallback.- 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.
| Situation | String |
|---|---|
| Pass | Verification step complete. |
| Fail | Verification failed. Please try again. |
| Timeout | Time expired. This attempt has been discarded. |
| Extra round | One more round to be sure. |
| Progress | Almost done. |
| Loop-mode interstitial | Verification is proceeding normally. |
| Escape link | I 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.
| Metric | Definition | Interpretation |
|---|---|---|
| Patience quotient | sufferingMs at abandonment or completion. | Working time, excluding imposed delay. |
| Breaking point | Cruelty index of the challenge that ended it. | Which axis of suffering the participant could not absorb. |
| Gradient sensitivity | displayedFraction at abandonment. | How well the progress illusion was holding when it failed. |
| Rage density | Frustration signals per minute. | Composure decay rate. |
| Cloudflare multiple | elapsedMs / 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 isaria-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: moreand after two failures. prefers-reduced-motionandprefers-contrastare honoured.zoom-checkalways offers an unconditional bypass, and page zoom is never blocked.chess-mateoffers 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.
| Limit | Enforcement |
|---|---|
| No seizure risk | Flash 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 audio | All audio gain-limited to −12 dBFS peak, ramped in over 40 ms, never played without a user gesture. |
| No motion sickness | No parallax, no full-viewport motion, no camera-style transforms. All motion suppressed under reduced-motion. |
| No exit removal | The escape link cannot be disabled under hard loop mode, nor at all after five minutes of session time. |
| No credential harvesting | Form values are never transmitted, stored, or logged; password fields use autocomplete="new-password". |
| No zoom defeat | Browser zoom, pinch zoom, and OS magnification are never blocked. |
| No real gate | The component gates nothing of value, requests consent to nothing real, and issues no token any system should trust. |
| Bounded deception | At most one false negative per session, recorded in history and visible in exportSession(). |
| Bounded waiting | No 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 tostart(). Check the console for a module resolution error;kaptcha.jsmust be served with a JavaScript MIME type. - The session never completes.
- Check for
loop-mode="hard", which has no terminal condition by design. Inspectdata-loop-modeon the rendered root, or thekaptcha:startevent detail. - Progress events are not arriving.
progress-urlmust be non-empty and same-origin or CORS-permitted. Transport failures are deliberately silent; enabledebugto 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
typesallowlist 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: truein the history and is disabled entirely atcruelty="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: hiddenand 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.