How Live Keepsakes reveal

A Live Keepsake is the one thing in this studio that is not a downloadable file: a hosted message, a code on the sender's screen, and a recipient screen that plays the message when the code is scanned while the sender's screen reacts. Underneath sit a pair of small state machines, a particle system and a strict contract for what the recipient's browser may read. This guide walks through them with the engines themselves: every stage name, count and status line below was produced at build time by the functions the two screens call, on fictional inputs. The other guides in the guides index cover the printed side of the studio.

What the sender's code encodes

The reveal URL is a contract between the sender's code and the recipient page: the site origin, the recipient path /r/, and the reveal id carried as the k query parameter. The id travels as a query string rather than a path segment because the site is static — it cannot prerender a path for an id created at runtime — and, in the engine's words, a query string is something "every static host serves unchanged". For a fictional id the engine produces:

https://keepsakeqrcodes.com/r/?k=0f3c2a1e-7b4d-4c9a-9e2f-6a1b3c5d7e9f

That string is 69 characters long and, encoded at level H by the same encoder the studio uses, becomes a version 8 symbol of 49 × 49 modules — a standard QR symbol with nothing proprietary in it. Any camera app that reads QR codes reads these.

The recipient page accepts only a canonical uuid. Fed ?k=0f3c2a1e-7b4d-4c9a-9e2f-6a1b3c5d7e9f the parser returns 0f3c2a1e-7b4d-4c9a-9e2f-6a1b3c5d7e9f; fed the same id in upper case it returns 0f3c2a1e-7b4d-4c9a-9e2f-6a1b3c5d7e9f, normalised to lower case; fed ?k=table-7 it returns null, and a query with no k at all returns null. A null id means the recipient page shows its not-found state instead of querying the database with garbage.

The sender's machine

The sender card has the stages photo, dissolving and qr, and the events tap and dissolveDone. The machine's rule, quoted from its header: it can never move backwards, and unknown events never change state. Each row below is one call to the engine; the last column repeats it with reduced motion on.

Stage Event Next stage Next stage, reduced motion
photo tap dissolving qr
dissolving dissolveDone qr qr
dissolving tap dissolving dissolving
qr tap qr qr
photo dissolveDone photo photo

The rows that do nothing matter as much as the rows that advance: a tap mid-dissolve, a tap on the finished code, and a dissolve callback arriving while the photo still shows all leave the stage where it was. The happy path — a tap, then the dissolve's end — reads photo → dissolving → qr. Under reduced motion the same events read photo → qr → qr: the tap lands directly on the code, and the dissolve callback, if one still fires, is a no-op.

The recipient's machine

The recipient screen starts in loading. Its events are loaded; missing, which the engine defines as "does not exist or has expired" — the store returned no row; failed, a network or parse failure and therefore, unlike missing, retryable; and revealDone. The stages open, gone and error are terminal: a finished reveal can never regress to loading, and a missing one can never start playing.

Stage Event Next stage Next stage, reduced motion
loading loaded revealing open
revealing revealDone open open
loading missing gone gone
loading failed error error
loading revealDone loading loading
gone loaded gone gone
open loaded open open

The happy path is loading → revealing → open; under reduced motion it is loading → open → open, the content appearing at once. A missing reveal traces loading → gone → gone → gone even when a load and a reveal-done event arrive afterwards. One misreading is worth naming: gone is not an error. It is the stage for a reveal the store no longer returns — ended or never existed, the machine does not tell those cases apart.

Reduced motion is an input, not a patch

Both machines take the reduced-motion preference as a parameter on every call, which is why it is a column in the tables rather than a footnote. The engine's header states the effect: the dissolve and reveal stages are skipped entirely, and every burst size collapses to zero particles. The painter never starts its loop for a zero count, so a reduced-motion device draws nothing — and is not showing a broken page.

How bursts are sized

The recipient screen fires a reveal burst as the message appears; the sender screen fires an open burst when an open event arrives and a loveback burst for a love-back event. The engine's header calls the hierarchy part of the product design: the recipient's reveal moment is the biggest, and on the sender's screen a love back bursts bigger than an open.

Burst Particles Particles, reduced motion
reveal 120 0
open 45 0
loveback 90 0

The counts are constants, and the sizing function returns them unchanged unless reduced motion is on, when it returns zero for every kind; spawning a reveal burst with the reduced-motion count yields 0 particles.

What a burst is made of

The occasion chosen in the composer sets the particle shape, the headline shown above the message, and the default accent colour:

Occasion Composer label Recipient headline Particle Default accent
love A love note Someone loves you hearts #be123c
birthday A birthday surprise Happy birthday! balloons #2563eb
congrats Congratulations Congratulations! confetti #f59e0b
proposal A proposal A question for you… sparkles #a16207
friendship For a friend A friend is thinking of you stars #15803d

Each shape has its own stored physics: gravity, which the engine defines as "downward acceleration in px/s² (negative = buoyant)"; the "initial vertical velocity range [min, max] in px/s"; a horizontal speed limit; and lifetime and size ranges. The engine describes the shapes' motion in its own header — "balloons and hearts are buoyant and drift upward, confetti tumbles down under gravity, sparkles and stars radiate and fade in place" — and the table below prints the figures it stores behind that description, unchanged. The description is the engine's, not something to infer from the signs: stars are stored with a gravity of 40 px/s² and a launch range of -200 to -60 px/s, and the engine describes their motion as "radiate and fade in place".

Shape Gravity (px/s²) Launch vy (px/s) Horizontal spread (±px/s) Lifetime (ms) Size (px)
hearts -35 -130 to -50 60 1800 to 3200 10 to 22
balloons -60 -170 to -80 40 2200 to 3800 16 to 30
confetti 260 -320 to -140 220 1600 to 2800 6 to 12
sparkles 0 -90 to 90 90 900 to 1800 4 to 10
stars 40 -200 to -60 160 1400 to 2600 8 to 16

Spawning is pure given an injected random source, so it can be walked through: this page spawns with a scripted source that always returns its midpoint, and every particle takes the middle of every range. A hearts reveal burst at (400, 300) with the love accent yields 120 particles, each launched at -90 px/s (negative is upward), 16 px across, with 2500 ms to live. Stepped forward 1000 ms on a canvas 600 px tall, 120 remain; the first has risen to y = 210, its speed has grown to -125 px/s under buoyancy, and 1500 ms remain. Stepped 2500 ms instead — the particle's own midpoint lifetime, read back from the spawned particle — 0 remain: the engine counts a particle expired once its remaining life reaches zero. Confetti shows the other sign of gravity: a confetti particle launched at -230 px/s is at 30 px/s after 1000 ms, already falling. Opacity follows lifetime — alpha 1 with 2500 ms left, alpha 0.5 with 300 ms left — and the accent joins the shape's palette at spawn, so every burst carries the reveal's own colour.

What the recipient's browser can and cannot read

The reveal-model header states the security posture: the anonymous read path returns only the occasion, the message and the style, and the parser on the recipient side is an allowlist — it copies exactly those fields and, in the header's words, structurally cannot leak user_id, title, or anything else a future column might add. That is a property of the code, and it can be shown: handed a fictional row carrying occasion, message, style, user_id, title, expires_at, the parser returns exactly occasion, message, style.

The parser is also total. A row with an empty message parses to null; a row whose occasion is wedding — not one of the 5 reveal occasions (the studio's own list has Wedding, capitalised) — parses to null. Style falls back to the occasion's own accent, so a corrupt value cannot wedge the reveal: #0ea5e9 is kept as #0ea5e9; an accent of rose becomes #be123c, the love accent.

What flows back is equally narrow. An opening is recorded as an event with a kind — open or loveback — and a time, and the sender's status line is composed from those events alone, in the sender's time zone (pinned here to UTC). With no events it reads "Not opened yet"; with one open, "Opened once · last 9:14 PM"; with 3 opens and a love back, the love back arriving after the last open, "Opened 3 times · last 9:20 PM · 1 love back" — the time after "last" is the latest open, not the latest event: the love back at 9:21 PM is later and does not move it. The Live Keepsakes page states the consequence: opens are anonymous, and the sender sees when, never who.

What the composer checks before a reveal exists

Before a live keepsake exists, the draft is validated against the database contract, and the validator returns every error at once so the form can mark all offending fields in one pass. The message is at most 500 characters and the private label at most 120. Fictional drafts:

  • A proposal draft with a short message and a short label: 0 errors.
  • A message of only spaces: 1 error, "Write the message your keepsake reveals."
  • A message of 501 characters: 1 error, "The message can be at most 500 characters."
  • The occasion wedding with a 121-character label: 2 errors in one pass, "Choose one of the occasions." and "The private label can be at most 120 characters."

Where this stops

A live keepsake is hosted, and it ends. The methodology page states the mechanism without a figure: a live keepsake "carries its own expiry instant on its record; after that instant the reveal link goes dark", and a recipient who arrives afterwards is handled by the recipient machine as a missing reveal. The features page and the Live Keepsakes page state the hosting terms; this guide describes only what the machines do once that instant has passed. The static keepsakes you download are the opposite — the features page calls them "plain files that encode their content directly and never expire", and the methodology says of a downloaded file that it encodes its destination directly and is unaffected by any row disappearing. The recipient machine's missing event is how the ending reaches the recipient, and it carries no reason.

The open tally is an event count on a hosted reveal, not a scan count on a static code: the methodology is explicit that static codes cannot report scans and that the studio adds no tracking to destinations, and a live keepsake can report openings only because its code points at the studio's own recipient page. The record carries a kind and a time — no name, no account, no location — so the sender sees when, never who. The machines, finally, say what a screen does with the events it receives, not how long a dissolve takes or how fast a network answers. The guide to what the studio does not model covers the rest.

Frequently asked questions

What does the code on the sender’s screen encode?

A standard QR symbol of the reveal URL — the site origin, the /r/ path and the reveal id as the k query parameter. The fictional example on this page is 69 characters and encodes to a version 8 symbol of 49 × 49 modules at level H.

Does the recipient need an account?

No. The recipient page reads through an anonymous path that returns only the occasion, message and style; on this page a fictional row carrying occasion, message, style, user_id, title, expires_at parsed to exactly occasion, message, style.

What does the sender learn when the keepsake is opened?

An event with a kind and a time. With no events the status line reads “Not opened yet”; with one open, “Opened once · last 9:14 PM”; with 3 opens and a love back, “Opened 3 times · last 9:20 PM · 1 love back”. Nothing in it says who.

What happens when someone scans after the keepsake has ended?

The store returns no row, the recipient machine moves from loading to gone, and gone is terminal — the trace loading → gone → gone → gone shows later events changing nothing. A reveal that expired and one that never existed look the same.

What does reduced motion change?

The sender’s tap goes straight to the code (photo → qr → qr), the recipient’s load goes straight to open (loading → open → open), and every burst collapses to zero particles: 0 in total against 120, 45 and 90 otherwise.

Is the photo on the sender’s screen uploaded?

No. The Live Keepsakes page states that it is shown only on the sender’s device, as the card the tap dissolves into the code, and is never uploaded. What the recipient’s browser can read from the hosted record is the occasion, message and style.

About these figures: every stage, count and status line on this page was produced at build time by the reveal model, the reveal machines and the particle engine, on fictional inputs, so the guide cannot drift from what the two screens do. As everywhere on this site, composition and verification happen in your browser; a live keepsake adds one hosted record — the occasion, message and style the recipient screen reads — on the terms the Live Keepsakes page states. See the methodology page.