Skip to content
← Projects

I fired the doorman

A bill-splitting app for iPhone, the web and the Mac, where every share adds up to the receipt to the cent.

splitlobby splits a restaurant bill while everyone is still at the table. the host scans the receipt, friends open one link and tap what they ate, and an $8.75 guac shared three ways becomes $2.92, $2.92 and $2.91, live, inside a $63.30 receipt that always adds up. so how do you make a number at a dinner table that nobody can argue with?

App Store screenshot: the iPhone app opens on a camera viewfinder under the inked doorman, with a 'scan the check' button
the app opens straight into the camera
App Store screenshot: the parsed El Farolito receipt with every line and total, and an 'open the lobby' button
check what it read before anyone sees it
App Store screenshot: the host's live lobby, each line tagged with who claimed it and a live per-person tally
the live lobby, from the host's phone

three screens on the phone, and a link for everyone else. guests don't download anything, and they don't make an account. they see the receipt first. the only question comes with their first tap, and it's their name.

every recording on this page is the real stack running locally: the Worker, the Durable Object, the database and the guest app. it's the same $63.30 dinner as the App Store screens, renamed. Maya is the phone you're watching, Jo and Theo are two more phones at the table, and Sam is the host.

Ten specs, then one day of code

the repo starts on july 12, 2026. the first commit is mostly docs, ten of them, numbered: the overview, the design system, a PRD for each surface, the split math, the edge cases, the landing page, the milestones and the wire protocol. the overview says each doc is "written so that an implementing agent can build the product without asking questions." i wrote the specs, agents built against them, and i reviewed what came back.

the build order was fixed up front. the split math came first, as a package with zero I/O and property tests. then the backend, testable with curl before any UI existed. then the guest web app, because it's the face of the product. the iPhone app went last, because it's the only part that needs hardware.

the math package, the backend and the guest app all landed on july 12. splitlobby.com was live the next afternoon.

the stack is small on purpose. the host app is SwiftUI, with Apple's Vision framework for reading receipts. the guest app is a React page. the backend is one Cloudflare Worker running Hono, with one Durable Object per lobby, D1 as the durable record and KV for remembering who you are.

the guest page has a hard budget of 60 KB gzipped, and a size check in the build enforces it. it's the only thing a friend loads, from a group chat link, on restaurant wifi. React 19 got tried and reverted in a commit titled "react 19 does not fit the 60KB budget". the build today reports 59.9 KB.

0.1 KB of headroom. plenty

The receipt never leaves the phone

the iPhone app has no home screen. it opens into the camera, because the camera is the app.

i didn't use Apple's stock document scanner. its chrome can't be styled, and it's the first thing a host sees. so it's a custom AVFoundation viewfinder with one line of text, a shutter, a gallery button and a torch toggle. receipts photograph better under a steady torch than a flash. the stock scanner also fixed perspective for free, so that got rebuilt in one step: find the document's corners with Vision, then flatten it with a Core Image perspective filter.

reading happens entirely on the phone, in a Swift package called OCRKit. here's what it does with the receipt from the recordings.

Diagram: the receipt as Vision reads it beside what OCRKit keeps, with misread SUBT0TAL, T1P and T0TAL highlighted and both sum checks passing

Vision runs in accurate mode with language correction off, because language correction "fixes" prices and receipts aren't prose. it returns fragments, not lines, so fragments that overlap vertically by at least half become one row. a price is anchored to the right end of a line. a keyword table checks SUBTOTAL before TOTAL and carries the misreads real paper produces, like SUBT0TAL, T0TAL, T1P and GRATU1TY. a line with no price joins the item above, and everything after TOTAL is ignored.

on iOS 26 an on-device model also gets the raw lines and the rule engine's draft, and can fix mis-split items. it has two seconds. if it's late, or its answer fails a sanity check, the rule engine's draft wins.

then there's the gate, the brass box at the bottom. the host can't open the lobby until the items add up to the subtotal and subtotal plus tax plus tip adds up to the printed total, both within a cent. when they don't, the app offers one-tap fixes: add the missing amount as an item, or shift a decimal on the one line that would close the gap.

the server gets numbers, not a picture.

Every cent goes through one function

a shared dish has no price per person until everyone who shared it has claimed it. here's Maya tapping the guac after Jo, and then Theo tapping it from his own phone.

the line goes from ÷2 to ÷3 without Maya touching anything, and her share counts down from $16.56 to $14.68. whatever the answer is, every share has to add up to the receipt. to the cent. if the table owes $63.30, the shares sum to $63.30.

all of it runs through one function. its header says "every cent that gets split anywhere in this product goes through this function." money is integer cents, and no floats touch it. to split an amount, everyone gets their exact share rounded down. the few cents left over, always fewer than the number of people, go out one at a time, biggest fractional remainder first. ties go to whoever joined the lobby first, an order that's stable and the same on every device.

Table: 875 cents split three ways, each exact share 291.67, rounded down to 291, with the two leftover cents going to Maya and Jo

so $8.75 between Maya, Jo and Theo is $2.92, $2.92 and $2.91. the shared line is honest about who got the extra cent. if you're one of the claimants it shows your own share, and if you're not it shows the range.

The chips and guac line rendered for four viewers: Maya and Jo see $2.92, Theo sees $2.91, a non-claimant sees $2.91–$2.92
the same line as Maya, Jo, Theo, and someone who skipped the guac

tax and tip follow food. they go through the same function, weighted by what each person ate. here's the whole dinner, in cents.

Table: food, exact and rounded tax and tip for Maya, Jo and Theo, summing to 4925, 420, 985 and 6330 cents

amount × weight can pass 2^53, so the multiply happens in BigInt. discount lines are negative, and splitting one mirrors the positive case: −5¢ by weights 1 and 2 is −2 and −3, not −1 and −4, a counterexample the code keeps in a comment.

the function doesn't trust itself either. after every split it checks that the shares plus anything still unclaimed equal the receipt total, and throws if they don't. property tests run it against thousands of random receipts: negative lines, zero totals, up to 30 people and 60 items.

A preview has to predict the lock

the lobby is live, so every number on it is a preview. the question is what it's a preview of.

the first version spread the whole tax, service charge and any fixed tip over whatever had been claimed so far, so the first people in carried all of it and watched their number fall as friends arrived. in august i changed what the preview means: an early claimant's number should be what they'll owe at lock.

it's one line of math. while the lobby is open, each fixed pool shrinks to the share of the receipt that's been claimed: the live tax is round_half_even(tax × claimed ÷ subtotal). the tip was already a percentage of claimed food, so it isn't scaled twice. here's Maya's tax line under both rules, on the same dinner, as the table claims.

Chart: Maya's live tax as the table claims; the July rule starts at 420 cents and falls, the August rule stays at 72–73 cents

Maya taps her tacos first. $8.50 of a $49.25 receipt is claimed. under the july rule she carries the whole $4.20 of tax and sees $14.40. under the august rule the live tax is 420 × 850 ÷ 4925 = 72.49, so 72 cents, and she sees $10.92. that's the number at the start of the claim recording, and 72 cents is also the tacos' slice of the tax when the table locks.

what makes it safe is the end state. when everything is claimed, claimed equals subtotal and the scaling is exactly 1. so the moment before lock and the lock itself produce byte-identical results, and a test asserts that. the spec says it in one line: "provisional and final use the same function. lock changes nothing about the math; it only freezes the input."

the server is the one that counts. the guest page runs the same package only to paint your tap before the server answers. the iPhone app never computes a share at all.

One room per dinner, so there's no race to lose

every lobby is one Durable Object, addressed by the lobby's slug, and a Durable Object handles one request at a time. two people tapping the same taco in the same millisecond arrive as two requests, one after the other, and the second sees the first one's claim. there are no locks and no retries. the race can't happen.

Sequence diagram: Maya's tap goes as a POST through the Worker to the lobby's Durable Object, which broadcasts one claim event to every phone

the rest of the protocol follows from that. a claim goes up as a POST, and the WebSocket only fans out. each event carries the full answer, the item's whole claimant list and everyone's shares, so phones set state and never add or subtract. each event also carries a version number, and a phone that sees a gap fetches a fresh snapshot instead of guessing.1

two smaller choices. the slug is a hash of the idempotency key the phone sends when it creates the lobby, so a host whose request times out on bad wifi and retries lands on the same lobby instead of a second one. and the socket authenticates through its subprotocol header, not the URL, because URLs end up in request logs.

a host can also pre-add the table. a guest taps their own name in the list instead of typing it, and that tap binds by member id, so typing "maya" can't take over Maya's seat. a pre-added person stays out of every split until they join or the host assigns them something.

the lobby has three states. a Durable Object gets one alarm slot, so the object keeps its own list of deadlines, from the last-call timer to the 30-day archive, and always arms the earliest one.

State diagram: open, last_call and locked, with cancel, a 60-second alarm, lock by hand, amend and reopen

last call gives the table 60 seconds, and an ink bar drops in with the countdown.

if anything is still unclaimed when it runs out, the alarm splits it across everyone and locks. a host who locks by hand resolves those lines first: claim it for someone, cover it, or split it.

locked doesn't mean frozen. in august the host got three corrections that work after lock: fix the tip, fix a line, delete a line. each one recomputes and re-checks that the total still adds up, and if it doesn't, nothing changes. before that, fixing a $2 typo meant reopening the lobby and turning everyone's final number grey again. reopen still exists, for a real do-over. someone who arrives after lock can knock, and the host gets a prompt to reopen.

Grey until it's final

with every number live, the next question was how a provisional number should look.

the first answer was a tilde in front of every unsettled amount. it went in my first visual review, six minutes after the guest app landed on day one. the decision log calls it D9: "the tilde read as noise."

The same $14.68 three ways: with a tilde on day one, grey while the lobby is open, black once locked

now provisional money is grey and final money is black. that's the whole system. a screenshot can't show a number that looks final before it is.

that made the lock the most important moment in the app, and color is the only thing that changes in it. repainting everything on one frame read as a glitch, so in august it became a beat. per-line shares go black first, 30 ms apart down the receipt. the chips at the top follow at 170 ms. your own number goes last, at 240 ms, because it's the one that matters. only color animates. the digits never move, so a settled amount is readable on every frame.

then the receipt prints.

the print is written as a storyboard: a shot list in a comment, one TIMING object that holds every delay, and a single integer stage that the components read. the design doc says to use springs where motion is physical and fixed durations where it's mechanical, because printers don't spring. so the share block feeds at a constant 900 ms with ±0.4 px of jitter, and the stamp is the one thing that bounces.

Timeline in milliseconds after lock: the color beat at 0, 170 and 240, the 900 ms feed from 150, the tear at 1050, the stamp at 1250 and pay at 1900

a page that loads already locked doesn't print. nothing happened while you watched, so nothing animates. reduced motion swaps the whole storyboard for one 250 ms crossfade.

the landing page is the same receipt, with the doorman still at the top. it prints on load and ends by circling LEFT TO FIGURE OUT at $0.00.

A hotel that prints receipts

the design doc opens with "a hotel lobby that prints receipts." the lobby is a room. guests arrive. there's a last call. if you're late, you knock.

the visual rule was one sentence: "the interface is a calm cream room; the receipt is the only object with theater; brass is the only loud color and it fires only at lobby moments (join, last call, print)." claims, names, shares and the stamp all live on the receipt. if a feature couldn't be drawn as something happening to a receipt, it got redesigned.

the color rules were just as strict. blue belongs to your own claims. other people's names are grey, so only you are colorful. green only means settled. the tokens file calls breaking them a bug.

a doorman ran the place: a hand-inked bellhop with a receipt on a tray, and a gloved hand holding a tray and a brass bell. the second one is still the app icon, on the phone and the Mac.

Ink drawing of a bellhop in a pillbox cap striding with a receipt on a raised tray
the masthead
The app icon: a gloved arm holding a tray with a receipt and a brass bell
the app icon, on iPhone and Mac

he had sound too. a brass bell, an 880 Hz triangle wave for 180 ms, when someone entered the lobby, and two soft knocks at the door. muted until you touch the page, and capped at five dings a minute. a comment in the code says "a busy lobby must not become a slot machine."

the bell button in the guest app started as a brass gradient. it lasted less than a day. the next afternoon it was flat, "per Family reference (no gradients, pressed-into-paper)."

the first App Store set, ten days after that, still went all in: brass, lowercase headlines, "the math settles itself".

First App Store set: 'the math settles itself' on a brass gradient, with the stamp over the totals
First App Store set: 'last call' on a brass gradient over the last-call screen

A blind taste test against the money apps

on july 29 i set up a design loop that judged splitlobby against the apps people already trust with money.

it put the guest page side by side with Cash App, Venmo, Robinhood, Monzo and Splitwise at 390×844. builders closed one named gap per round. then a fresh critic with no context judged real screenshots blind, and a high-severity finding blocked the round until it was fixed with measured evidence. six rounds, plus one rule on top: splitlobby had to stay unmistakably itself. the iPhone host screens went through the same pass afterwards.

every row became a 44 px tap target, and a claim became a 200 ms highlighter sweep you can reverse by tapping again. every live number got one behavior, a 250 ms count-up in whole cents in tabular digits, so nothing shifts. the bell button became a labeled pill that says "join the lobby". and on the iPhone, the brass last-call banner became a plain ink bar at the bottom of the host screen.

iPhone last call before the design loop: a brass banner across the top reading 'last call — lobby closes in 1:11'
before
iPhone last call after the design loop: a black LAST CALL bar above 'cancel last call' and 'lock now'
after

the green stamp moved off the per-person totals into clear space below them.

Locked receipt before the design loop: the MATH CHECKS OUT stamp printed across the per-person totals
before
Locked receipt after the design loop: the totals clear, the stamp below them
after

the second App Store set is what's in the store today: the plain receipt, nothing framed around it.

Current App Store screenshot: the locked El Farolito receipt with THE DAMAGE totals and the stamp
Current App Store screenshot: 'your past checks', one receipt lying slightly tilted on the stack
the stack of past checks

the stack is where past checks go. each receipt keeps a tilt from a hash of its id, between −3° and +3°, so the pile looks tossed but never reshuffles. dragging riffles through it with a tick per receipt. and they fade like thermal paper: full ink for a week, then lighter until day 30, when the server archives the lobby. the fade is the retention policy.

The phone is the night out, the Mac is the night audit

on august 18 splitlobby grew a second half. the Mac plan opens with two lines: "the phone is the night out. the mac is the night audit." the phone is an event, sixty seconds and multiplayer. the Mac is an archive, quiet and years deep. it reads any receipt you drop on it with the same OCRKit, and it holds the phone's host identity, so every lobby you've hosted shows up with who had what.

The Mac app's receipt view for a five-person dinner, each line tagged with the initials of who had it
another night at La Cuchara Torcida, in the Mac library

the most important decision is that receipts are real files. each one is a .receipt package on disk, and the database is only an index, rebuilt by rescanning. if the app disappears, the archive is still a folder you can open, and Quick Look renders it from Finder.

Diagram: photos, hosted lobbies and emails go into a .receipt folder holding original.pdf, parsed.json and provenance.json, which feeds Quick Look, expenses and month close

the second decision is who gets to decide. the model proposes and deterministic code decides. an on-device model suggests, a cloud fallback is off by default and never sees the image, and every proposal still has to pass the same sum check as the phone. the copy says "looks like something you've expensed before". it never says "this is deductible". expense a dinner you split and it files your share, not the bill: the spec's example is a $180 dinner that files as your $52.

Firing the doorman

the doorman talked. that was the point of him. he asked "who should I say is joining?" he called your total "your damage". a closed lobby said "the lobby's closed. knock?" every line was lowercase.

on august 18, the night the Mac app got built, i wrote a set of copy rules for it: sentence case, plain words, no em dashes, no eyebrow labels, no subtitle that restates its title. twelve minutes later i ran them across the iPhone app, the widget, the guest page and the server's error copy, in one commit called "copy rules across the whole product". the doorman stopped talking.

July name sheet: 'who should I say is joining?', 'join the lobby', 'just looking'
july
August name sheet: 'What's your name?', 'Join the lobby', 'Just looking'
august 18

a closed lobby still asks if you want to knock. it just asks like a person.

July closed-lobby sheet: 'the lobby's closed. knock?' with a knock button
july
August closed-lobby sheet: 'The lobby is closed. Knock?' with a Knock button
august 18

his total, "your damage", became "Your share". the hotel stayed in the nouns. there's still a lobby, a last call, a knock, and a "That's everything" button when you're done. the receipt kept its own small voice in thermal capitals. the host's ledger is still headed THE DAMAGE, and the footer still prints one line of wit.

Last call

the doorman went in one commit. the rule for splitting a cent is still the one from the first day's spec. the personality went to the one place it can't touch a number, the bottom of the receipt, where a line is picked when the lobby opens and printed at lock. Maya's table got "THE MATH CHECKS OUT. THE CALAMARI WAS A GROUP DECISION."2

  1. two more rules ride along. device ids only ever travel up, so nothing broadcast lets a lurker collect them, and pings are answered without waking the object, so an idle lobby can hibernate with everyone still connected. ↩
  2. another is "SETTLED WITHOUT INCIDENT". the landing page printed one too, until a commit called "drop wit line". ↩