Guide

RSVP Reading Algorithm — Optimal Recognition Point + Timing (Open Source)

Last updated 11 September 2026 · 4 min read

RSVP (rapid serial visual presentation) shows one word at a time at a fixed point on screen, so the eye never has to saccade to find the next word. Two decisions determine whether that reads as prose or as a drum machine, and both are easy to get subtly, silently wrong: where the eye should land on each word, and how long each word should hold before the next one appears.

rsvp-timing is a small, dependency-free engine that answers both, as parity-locked JavaScript and Swift implementations. It's a derivative work: the engine originates in thomaskolmans/rsvp-reading (MIT), which established the entire public API this project still exposes — getORPIndex, getWordDelay, splitWordForDisplay, and the rest. Full, by-commit attribution to the upstream authors is in NOTICE; if you want the original rather than this fork's additions, go there first.

Usage

import { getWordDelay, splitWordForDisplay } from 'rsvp-timing';
        const { before, orp, after } = splitWordForDisplay('reading');
        // { before: 're', orp: 'a', after: 'ding' }
        getWordDelay('home.', 300);   // 400ms — 200ms baseline, doubled at sentence end
        getWordDelay('of,', 300);     // 240ms — comma pause, attenuated (2 letters)
let parts = RSVPTiming.splitWordForDisplay("reading")
        let ms = RSVPTiming.getWordDelay(for: "home.", wpm: 300)

What's actually being computed

The Optimal Recognition Point is the character the eye fixates on inside a word — not the centre; it sits left of centre and drifts right as the word gets longer. Hold that letter still on screen while the word around it changes, and the text stops jittering horizontally between words.

Per-word delay is not a flat 60000 / wpm metronome. This engine's additions on top of the upstream base: sentence-ending punctuation extends the beat, commas extend it less, em/en-dashes count as sentence-end equivalents while a hyphen inside well-known doesn't, the first word after a paragraph break gets a reorientation beat, very long words get proportionally more time, and — this one actually matters for how it feels — short words get attenuated pauses, on a graduated scale from 0.3× (1 letter) to 1.0× (7+ letters), because a 2-letter word taking a full-length pause reads as a hitch. When a word is both first-after-break and punctuated, it takes the larger of the two pauses rather than letting them multiply — the original stacking produced a roughly 3× stutter. There's also trailing-noise stripping before the punctuation test runs, because EPUBs glue footnote markers onto the preceding word (theology.2, hierarchies.¹) and a naive suffix check on "theology.2" never finds the period at all.

What I actually verified

I regenerated and ran the repo's own parity gates just now, not just read the README's claim:

npm run check:vectors   → vectors current
        swiftc -O RSVPTiming.swift main.swift -o /tmp/check && /tmp/check
        → PARITY OK — all vectors reproduced

The committed fixture (parity-vectors.json) holds 519 generated cases — 51 ORP-index cases, 459 word-delay cases across every length bucket and punctuation shape, and 9 countdown-formatting cases — and the Swift twin reproduces every one exactly. That fixture exists because a hand-maintained "byte-for-byte parity" comment in the Swift file was, in fact, wrong twice: a missing length-bucket tier (<= 17 → 5, else 6) meant every word 18+ characters highlighted a different letter on each platform, and CharacterSet.decimalDigits being Nd-only (vs. JS's broader \p{N}) meant a footnote-glued word like hierarchies.¹ pivoted differently too. Both bugs shipped in a real app; a golden fixture catches both on the first run instead of relying on code review to notice a one-character threshold typo.

Where it's actually used

This is the same per-word pacing and ORP engine running in the RSVP mode of Velo, the reading app it was extracted from — not a reimplementation built to resemble it. The Swift twin here is parity-tested against the exact JS vectors, so the timing you see in this repo's fixture is the timing Velo actually renders.

Known limitation

Parity is guaranteed within the Basic Multilingual Plane. Above it, the two implementations diverge structurally rather than by bug: the JS getActualORPIndex iterates UTF-16 code units, so an astral character shows up as an unmatched lone surrogate, while Swift iterates grapheme clusters. Prose in the languages this engine targets doesn't hit this, and emoji aren't ORP-countable in either implementation — so it's a documented boundary, not a silently papered-over gap.

MIT licensed. Full API, the fixture, and the regeneration script are on GitHub.

Try it on a real book

Velo includes a library of free public-domain classics, so you can test both modes before importing anything of your own.

Get Velo Free

← All guides