Liubov Tsebekova

04 · Cine.pick

The feature the whole product depended on was Math.random()

Cine.pick is anti-streaming. Not a shelf that offers you more, but a tool for understanding your own taste and finding films through it. The value is the self-portrait, not the catalogue. Which is a lovely thesis, and for a long time the part that produced the self-portrait was returning random numbers.

Product
Movie DNA — you swipe films, the system builds a map of your taste across five axes and recommends niche cinema from it
Origin
My first project. PHP + MySQL on shared hosting, built before I knew what I was doing, kept because the bones were right
Library
83 films, hand-curated and hand-tagged. 45 DNA tags across 5 axes, 36 moods — expanded from 16 and 24 during the rebuild
Stack now
Cloudflare Workers + D1 (SQLite), Hono routing. Migrated off PHP/MySQL with the design preserved one to one
Accounts
None. An anonymous identifier per browser, and a “Forget me” button that actually erases
Built with
AI agents for scaffolding, debugging and the migration. Product framing, taxonomy, curation, voice and QA mine

What this case doesn’t claim. The five interviews behind the original framing were the first I ever ran, and I have written elsewhere about how badly I ran them — leading questions, hypothetical futures, “would you use this?” asked five times in a row. No evaluative study has been run on the rebuild. Everything claimed here is about what the system does, which is checkable in the source. Nothing here is a claim about what users do.

The most important feature was fake

The premise of Cine.pick is Movie DNA: a visible taste profile that shows you why a film was matched to you. It is the trust layer, and it is the entire reason the product exists rather than being another randomiser.

When I audited the first version, this is what I found. The profile page carried a banner reading “Demo mode — data is simulated.” The JavaScript picked your “favourite genre” with Math.random() on every page reload, ignoring the real likes already sitting in the database. Film tags were not read from the database at all — a function called generateFakeTags() guessed them by string-matching against genre names. The swipe deck served random films without excluding the ones you had already rated. “Similar films” and “sort by relevance” were both ORDER BY RAND().

And the schema for the real thing was already there, designed correctly. Tags, moods, genres, swipe history — all normalised, all sitting in the database, all unused.

The part I would want read: in my own original case study I had already written the gap down. “Tune recommendations.” “From similar titles to mood continuity.” I knew. I had filed it as a roadmap item instead of calling it what it was, which is a product that promises an explanation and returns a dice roll.

That finding set the whole direction. The product did not need a new concept. It needed the thing it already promised to actually work.

Build on, do not rewrite

The first instinct with an early project you have outgrown is to burn it and start again. That would have been the wrong call here, because under the mess there was a normalised relational database, an article engine with its own shortcode system, a working TMDB integration, dozens of considered pages and a genuinely distinctive library. The taste was already right. The wiring was not.

So: keep what works, replace only what is broken or fake. Four rules came out of that and governed everything after.

No drawn elements. If a button exists, it works. If there is no data, an honest empty state — not a plausible-looking fake. The v1 had several buttons that animated beautifully and saved nothing, which is a specific kind of lie a designer is unusually well equipped to tell.

Value before sign-up. A guest gets a full DNA profile with no account at all. If the product has not been useful once, it has not earned a registration form.

Curation over automation. Where TMDB offers a default — posters, synopses — the hand-picked wide still wins. An authored taxonomy beats machine tags, and the whole product is the argument for that.

Difference through the niche. The library had a real seam running through it: Tarkovsky, Bergman, Bong Joon-ho, Zvyagintsev, Balabanov. As it grew I sharpened that rather than diluting it toward general appeal.

Making the DNA real

Every film carries three kinds of tag: genres, moods, and DNA tags. The link between a film and a DNA tag carries a weight from 1 to 5, because a signature tag means more than a peripheral one — Fever Dream on Suspiria is a 5, the same tag on something merely atmospheric is a 2. Binary tagging would have flattened exactly the distinction the product sells.

Every tag and mood belongs to one of five axes: cerebral, dark, tense, aesthetic, absurd.

The profile takes every film you liked, aggregates its tags with their weights along those axes, and draws the result as a radar. The peaks name an archetype. All of it is real SQL with joins, computed from your actual swipe history — which is a boring sentence that took the entire rebuild to earn.

“Made for your DNA” is then the unwatched films with the highest weighted overlap against what you liked. Similar films on a movie page are computed as SUM(MIN(weight_a, weight_b)) — the shared intensity of two taste fingerprints — rather than by rating, and certainly not at random.

None of this works without the taxonomy underneath it, so the vocabulary went from 16 DNA tags to 45 and from 24 moods to 36. That is roughly the resolution needed to tell the Korean wave apart from East European minimalism instead of filing both under “slow”. All 83 films were re-tagged by hand. There was no shortcut available and I would not have taken one.

Twelve swipes

Cold start is the hard problem in any taste product, and CineMatch — a swipe deck — is the answer. The interesting question is how many swipes before you show someone their profile.

The answer is twelve, and it is not a round number chosen for looking nice. Each film carries five or six tags, so twelve ratings produce roughly 60 to 70 weighted signals: the point at which the radar stabilises and the archetype stops changing between reloads. In time that is 60 to 90 seconds. On the twelfth swipe a full-screen moment says the initial map is built and points at the profile.

Every session after the first, the onboarding hints disappear — the person already knows the mechanic, and repeating instruction to someone who has learned it is a small insult. The progress bar becomes a library counter instead. The deck is infinite, loading in batches until the library runs out, and progress persists forever. The last swipe can always be undone, because a mis-swipe on a taste profile is a corrupted taste profile.

The same logic drives the home page. A newcomer sees “Find My Taste”; a returning visitor sees “Keep Swiping”. One screen should not say the same thing to everybody.

That button taught me the copy rule I now use everywhere. It used to say “Pick For Me”, which reads as passive — you choose, I have given up — and pointed at a randomiser rather than the swipe deck. A button should say what will happen, not sound good.

One more format decision worth naming: vertical posters in the swipe deck, wide stills everywhere else. Posters carry recognition and belong to the tinder shape; wide stills are the editorial look that keeps the grids from resembling a streaming wall. Format follows context rather than consistency.

The layer that makes it mine

I have a shelf: Wallace, Pynchon, Baudrillard, Debord, Camus, Jung, Girard, Bachelard, Frankl. It turned out to be the intellectual DNA of the product — the voice grew out of it before I noticed that was happening — so during the rebuild I made the connection explicit rather than ambient.

Each of the 45 DNA tags got a one-line authored definition and, where the lineage is honest, a thinker behind it. Doppelganger comes from Jung’s Shadow. Folk Horror from Girard’s scapegoat. Fever Dream from Bachelard. Alienation from Debord. Absurdist from Camus. The tags stopped being an algorithmic vocabulary and became an authored one.

Book companions work as thematic rhymes rather than adaptations: Baudrillard beside Blade Runner 2049, Girard beside Midsommar, Jung beside Persona. Not “the book of the film” — “if this film landed on you, here is a text in the same key.”

The voice follows from the same place. A tag definition reads: “Guilt — the crime may be over. The sentence is internal.” Compressed, aphoristic, somewhere between irony and dread, never cynical.

This is the part no recommendation engine can copy, and it is why this project is still the one I keep coming back to. The engine makes it work. The shelf makes it worth doing.

Anonymity, the move, and the clean-up

There are no accounts. Every browser gets a random identifier in a cookie, and every identifier gets its own profile, watchlist and swipe history. No email, no name, no password.

That started as a UX position — value before sign-up — and became a legal one. The moment you collect an email address you become a personal-data operator under Russian 152-FZ: notification, policy, consents, and a data-localisation requirement that a database hosted on Cloudflare physically cannot satisfy. For a personal project, the correct move is not to create the risk category in the first place. So anonymity is a stated position rather than a limitation, and /privacy carries a short honest text and a “Forget me” button that genuinely erases everything.

The security clean-up was less dignified. The audit found live database credentials committed in the source, no CSRF protection on the auth posts, user enumeration in the error messages — “no account with that email” versus “invalid password” tells an attacker which half they got right — an unauthenticated write endpoint hardcoded to user 1, and display_errors on in production, leaking paths and SQL to anyone who could provoke a failure. All fixed, credentials rotated. I include this because it is the honest half of “a designer who ships their own code”: you also ship your own mistakes, and some of them are not aesthetic.

The accessibility pass was a similar list. A div hamburger unreachable by keyboard, no skip link, no :focus-visible anywhere in the codebase, user-scalable=no blocking pinch-zoom on the swipe pages, no prefers-reduced-motion handling at all, unlabelled search inputs, missing alt text, colour as the only signal for active state, and touch targets at 36px.

Then the whole thing moved. Beget introduced mandatory Gosuslugi authentication, which was not acceptable for this project, so it migrated to Cloudflare Workers with a D1 database. The schema converted from MySQL to SQLite intact — 83 films, weighted tags, books, articles, swipe history — and every page was rewritten from PHP to JavaScript on Hono routing with the design preserved one to one. Only the logic changed. A local rig runs the entire site in Node and walks every page in about a second, so nothing reaches production unchecked.

Still open, and it is the same thing it has always been: content. The journal engine is finished and short of essays. Some book covers are still placeholders. The curator’s note under each film — one or two sentences on why this film is here, which no streaming service will ever write — is specified and unwritten. The engine was the hard part and it is done. The writing is the long part and it is mine to do.