Skip to content
← Projects

Fair isn't the middle

It started as a fair midpoint and a suggestion at 3:33 every week. It launched as a map of the places your friends love, with the same math still picking where you go.

333 is an iPhone app for getting your friends out to the same place. you save the spots you want to try, see the ones your friends love, and make a plan for the night in one sentence. under every plan sits a harder question. Sam is 1.6 km from the middle of the group and Jo is 3.6 km away, but Sam walks and Jo takes the train, so Sam's trip is 23.8 minutes and Jo's is 18. the closer friend has the longer night. so where is fair, if it isn't the middle?

App Store card: the Places home, a map with shelves of spots friends saved, over a dusk sky
App Store card: the plan sentence with Sam and Jo, a place, a day and a time filled in
App Store card: one night drafted as three stops with a time each, on a cobblestone street at dusk
App Store card: a city list of five places with a route map between them

those are four of the six cards on the App Store listing, real captures framed by Shotation over one dusk sky.

Saves are the signal

Places opens on a map of spots, and nobody is tracked. you save the cafe you keep meaning to try. saves stay private until you choose to share them, and the first save asks you once. every suggestion shows the friends behind it, and when two of you want the same place, both of you hear about it.

The Places map: a search bar, friend filter chips and photo pins, with a sheet of places saved by Sam and Jo
A place page for Marigold with the faces of the friends who saved it and a Make a plan here button
The save sheet: Want to go, Been, a Keep private switch and the user's lists
A match card on a sky background: You and Sam both want to go to Saltwater, with a Plan it with Sam button

A plan is one sentence

the sentence has four blanks, and each one is a tap.

pick Sam and Jo, and the row under the sentence turns into Three places all of you want. tap one and it fills where: Me and Sam and Jo at Marigold on Today at 7 PM. from that, the app drafts the night around the main stop, with somewhere before and somewhere after, each titled with the reason it's there, like "Lucia, Ash has been here." any stop can be swapped for three others.

The drafted night: Tonight at Marigold, 7 PM, with Lucia before, Marigold as the main stop with each person's minutes, and Lantern Books after
App Store card: the plan as a timeline on a dusk sky with everyone's RSVP

you send it to the group chat. friends answer from the link with one tap, even without the app. if the plan changes after it's sent, everyone gets asked again, and one RSVP counts for the whole night.

leave where blank, though, and the app has to pick the place itself.

Fair is measured in minutes

the geographic middle of three people is only fair if they all travel the same way. a friend on the train, one on a bike and one on foot can be the same distance from a spot and nowhere near the same number of minutes. so the engine that picks the main stop turns everything into minutes before it compares anything.

here it is on a made-up night, run through the shipped code. Sam walks from Clinton Hill, Jo takes the train from Park Slope and Ash bikes from SoHo. the minutes come from the engine's own fallback estimator instead of a live routing call, and everything after that is the shipped ranking, untouched.

Diagram: three starting points around a plain middle, a weighted center shifted toward Sam, and a ring of one search circle with six around it; Sam pulls 23.8 minutes, Ash 19.6, Jo 18.0

first, a center. each person pulls on it by how long their own trip to the plain middle would take, distance at their speed plus a few minutes to get out the door. Sam's 1.6 km on foot is 23.8 minutes. Ash's 3.6 km on a bike is 19.6, and Jo's 3.6 km on the train is 18. the walker pulls hardest, so the center slides 160 m toward Sam.

then it searches a ring around that center. the center circle goes first. the six around it, 60° apart, only get searched if the center doesn't turn up enough, so a good center saves six paid calls. if the ring comes up short, it grows from 1.2 km to 3 and then 10. eight spots at a time get real travel times from a routing matrix, up to 24 per plan, and those are the minutes you see.

Bar chart: six spots with each person's trip as a share of their budget; Lucia, Marigold and Velvet Room stay under the budget line, while Lantern Books, Copper Kettle and Fern go over it on Sam's walk

then each trip is divided by a budget for its mode: 25 minutes walking, 30 by bike or train, 35 driving. that ratio is the load. it puts a 20-minute walk and a 24-minute train ride on one scale, at 0.80 each. a spot where anyone's load goes over 1 is out before anything else gets a vote. on this night that throws out three of the six, all because of Sam's walk. Velvet Room sits right on the plain middle. it survives, in third.

The worst trip decides

the score for what's left is a blend: fairness 0.5, taste 0.3, context 0.2. fairness is 1 − (worst load + a quarter of the spread) ÷ 2. on this night taste and context come out equal for every spot, so the score is 0.35 plus half the fairness.

worst loadspreadfairnessscore
Lucia0.800.0980.5880.644
Marigold0.840.0980.5680.634

Lucia wins even though Ash's ride there is three minutes longer. Ash has room to spare on a bike and Sam doesn't on foot. one minute of Sam's walk moves the worst load by 0.04, while three minutes of Ash's ride barely touch the spread.

everything else is kept small on purpose. the vibe you typed adds at most 0.15. a provider rating moves a spot by ±0.05, enough to break a near-tie. weather only ever subtracts, at most 0.12, against a fairness term that swings about 0.25 across the legal range. the comment next to it: "A park in a downpour slides; it never hands the night to a place nobody can reach."

the engine also remembers who trekked. when a plan locks around one person's long trip, that person pulls 1.5× harder on the next center for 30 days, so the same friend doesn't make the long trip every time.

the original research plan was fancier: draw everyone's reachable area as a polygon and intersect them. that's in the code as a stub with one job. it only goes in if it clearly beats the ring on the same ten New York test scenarios.

The model reads the vibe, the math picks the place

there's a language model in here, with a narrow job. the seam is one line at the top of the file: the LLM does LANGUAGE, the geometry does FAIRNESS.

the model reads what you typed, like "somewhere quiet enough to talk", and scores the spots that already passed the budget gate. it gets names, categories and minutes, and never coordinates, people or the center. it can reorder fair spots. it can't add a spot or change anyone's minutes.

it also writes one short line per spot, and that line gets treated like copy from a stranger. it's cut to one sentence, eight words and 60 characters at most. an exclamation mark drops it, and so does any word from a list that includes discover, unlock, seamless and journey. a dropped line falls back to something we can prove, "Within the travel limit for everyone included", or to nothing. if the model is slow or down, a keyword matcher takes over, because the finder must never fail just because language failed.

the stops before and after the main one come from code, not the model. each has to be a verified walk of 15 minutes or less from the main stop, within 1.2 km, and open at that hour. a restaurant gets a bar or a cafe before it, and a bookstore gets coffee. when not enough places fit, nothing widens quietly. it stops and shows the organizer named options.

Fair goes without saying

the engine already throws out any spot where someone's trip is over budget, so on most plans there's nothing to say about fairness. the comment above the code that decides: otherwise fairness goes without saying.

so the plan shows each person's minutes and nothing else. a line appears only when one trip is clearly longer: at least 15 minutes more than the next longest, and at least half again as long. both have to hold.

Chart of longest trip against next longest trip; the shaded area where the plan names the longest trip starts 15 minutes above the diagonal and bends at 30 and 45 into a 1.5 times line; point A is inside, B, C and D are outside

the line bends at 30 and 45, where the two rules swap. below a 30-minute next trip, the 15 minutes is the stricter one. above it, half again as long is. B is only 12 minutes longer, so it stays quiet. C is 16 minutes longer and still stays quiet, because 56 isn't half again as long as 40. A gets its line: "Jo's trip is the longest at 41 min."

The shipped plan on a dusk sky: two stops on a timeline and each person's travel minutes, with no fairness headline

the travel minutes come from Apple Maps first, with Google's matrix as the fallback. Apple's terms keep its data off non-Apple maps, and the app's map is MapLibre, so Apple's answer only ever shows up as text and never as a pin or a line. the app went through five maps to get here.1

The same minutes run the night

on the day, the plan becomes a Go card: when to leave, how to get there, the next stop and getting home. a leave-by push arrives, and a Live Activity counts down on the lock screen.

A Live Activity on a sunset lock screen: 12:00, leave by 2:40 PM for Oak & Ash, 16 min by subway
the leave-by time comes from the same minutes
Two home screen widgets showing the next outing: The Overlook, Sat, Oct 24, 7:30 PM

a missing starting point never blocks a plan. if Jo hasn't shared where she's coming from, her travel time says pending and the plan still goes out. between stops it tells you where to head next. the next morning one tap asks how it was, and a visit only counts once that person says they went.

The plan between stops: Next, The Overlook at 7:30 PM, 8 minute walk, with an Open in Apple Maps button
The morning after: How was Saturday at The Overlook? with I'd go again, It was fine and Not for me

One Worker keeps the minutes honest

333 is a SwiftUI app and one Cloudflare Worker, and every minute on a plan comes from the Worker.

Architecture diagram: the iPhone app, one Cloudflare Worker with the finder engine, D1, KV and R2 and the web pages, and outside services called only through the Worker, joined by a generated protocol package

the phone holds no keys. places, routes and photos all go through the Worker, and a zod contract in packages/protocol generates the Swift types, so CI fails if the two ever disagree about a field. only the Presence package may import CoreLocation, and precise coordinates never land in the database. a starting point belongs to the person it describes, so nobody can put a friend somewhere else to make the math come out their way.

place search runs on 333's own catalog, US third places from Overture Maps with OpenStreetMap hours. a place with no hours on file is never shown as open.

the same Worker serves 84 city guides, two lists of five places locals recommend for every US metro over a million people and 25 college towns. the same files feed the app's shelves.

A slow Saturday in Greenpoint: a route map of five numbered stops, then Cafe Grumpy and Peter Pan Donut with a local tip and the walk to the next stop

that's the app that launched in october. in july it was a different app.

It started as a calculator and a ritual

on july 23, 2026 the research went through what already existed. there are a lot of midpoint sites, and the research doc put them in one line: they're "calculators, not networks". one person types in everyone's address, gets an answer, and nobody comes back. Partiful starts after the venue is picked. Corner and Beli are about your own taste. Bump shows where your friends are and stops there.

so the first plan was the math plus a ritual. put the group on a map, work out each person's trip, suggest three spots where nobody's trip is much worse than anyone else's. then once a week, on the group's day, at 3:33 in the afternoon, a suggestion would drop. the Worker's cron still ticks at :03, :18, :33 and :48 past every hour, so one tick lands on 3:33 pm in every time zone that's offset by whole, half or quarter hours.

the name had to carry all of it. 333 is a third place, three or more friends, an angel number, and one more than 222, an app for meeting strangers that texts at 4:22.

"one-up of 222" is in the research doc, word for word

the research, the scaffold, the first TestFlight build and the backend all went out that same day. five days later the drop card looked like this.

Detail of the July drop card: THE 3:33, Thursday. You three. Here:, and a FAIREST badge next to 14/16/19
the july drop card: one number per friend

Thursday. You three. Here: and three spots with three numbers each, like 14/16/19 MIN, one per friend. the one the math liked best got a FAIREST badge. in july, everything in the app came back to that card.

The reference won every piece

before there was a real app, there was one screen drawn four ways: Mystic Hour, Toy City, Downtown Editorial and Mystic Toy. each board had the same map and the same 3:33 drop. only the world around it changed, down to the line on the card.

The four brand worlds as type: a golden serif 333 with The universe says Thursday; a blue candy 333 with your crew, one spot, 15 min each; a black grotesk 333 with MEET IN THE MIDDLE; a glowing gold 333 at night with three of you. one spot. it's a sign

the first icon went geometric, "333." in a heavy sans with an orange period. five days later it was ink on paper with a yellow dot.

Three app icons: a white 333. with an orange period on blue, a black 333 with a yellow dot on cream, and three hand-drawn black 3s falling into each other
jul 23, jul 28, and jul 29, still the icon

the day after that, every surface of the app got judged blind. eight screens, each next to a real screen from the app it borrowed from: Corner, Airbnb, Partiful, Beli, Find My and Wrapped. the commit message: the reference won every piece.

the yellow and the accent colors went, the chrome turned black and white, and photos carry the color now. every primary button lost its gradient. the logo became three hand-drawn 3s falling into each other, shipped as the icon on july 29. it's still the icon.

some of my test feedback went straight into commit titles: "why cant you set anchors" — because the card was a poster.

Friends, not crews

the first version was built around a crew. you set up your group, the app found the middle of it, and every screen started by asking which crew you were looking at.

i used it, and that switcher was in the way. on july 28 i wrote down what i actually wanted, and the pivot doc quotes it word for word:

"core thing is the structure is not group you need to switch groups. Its more like zenly / bump where its your profile and you add friends like snapchat map."

the switcher died. the geometry survived untouched, because the fairness engine always took a list of starting points and never knew what a group was. the core math is still the same functions today.

the studio drew three ways out. A made the app a list of chats with a plan as a card inside one, the way LINE drops a poll.

Studio mockup of option A: a group chat with a plan card, Somewhere with a garden, a two-row poll and a Lock in Juniper button

B had no groups at all, just people you pick like the recipients of a text. C was "the most 333": a room with a link that closes itself at 4am.

Studio mockup of option C: a room sheet with four faces, Closes at 4am and a Find the first stop button

A won in the studio. the launch app went one step further and gave the chat back to iMessage. 333 owns the places, the plan and who's in. the talking stays in the group chat.

The code is the spec

by mid-august there were 4,400 lines across seventeen spec docs, plus eight audit and plan files at the root. they disagreed with each other and with the app. the commit that deleted them says why: "Agents (and I) kept building to whichever page they read first."

it also stripped 922 citations of those docs out of code comments, and the reasoning each one carried stayed next to the line it explained. the CI check that used to make sure doc citations resolved now makes sure none are left. CLAUDE.md went down to the eight rules that actually bind.

the next day every screen was rebuilt on stock iOS 26 parts. the rule since then is to use the native component and only hand-roll a control when the native one can't do the job, with a comment saying why.

the day after that, the app got a studio: every screen drawn in HTML at real device metrics, on the same generated color tokens as the app, so trying a design doesn't need an Xcode build. it started with 21 screens. then six agents took one page each, and every new screen had to name the Mobbin search it was grounded in. that made 247 screens across 14 pages. some borrow one feature hard from one app, like Waymo's trip editor for building a night.

Studio mockup of adding a second stop: Walkable from Mia's, with Lucia, Copper Kettle and The Overlook and their walk times, and Leave it open

Thirty-two questions, seven overrules

on october 11 i built a decision board with thirty-two questions, including "What is the Places home?" and "Which notifications earn a push?" every option shows live phone mockups, and one is marked recommended. you pick, add a note, and Copy decisions puts the whole thing on the clipboard as a table to paste back to an agent. there's also a button that says Pick all recommended.

The decision board's top bar: Decisions, 0 of 32 decided, Pick all recommended and Copy decisions, above question 1, What is the Places home?, with Map with shelves marked Recommended

i didn't press it, but most of the time the recommendation was right. the home screen shipped as recommended, the map whose sheet becomes shelves like "Saved by Sam and Jo."

on seven of the single-answer questions i picked something else. for starting a plan, the board recommended typing or saying a whole night, like "Saturday night, five of us, near the L", and getting a draft back. i picked the sentence you fill in. the AI still drafts the night. it just starts from four tapped words instead of a typed paragraph.

Question 13, How does someone start a plan?: Say it, get a whole plan, marked Recommended, next to Fill in a sentence

saving got a list picker instead of one tap. fresh places show up as a map with three pins instead of three cards. the plan became a timeline on a dusk sky instead of an invitation card, and the Plans tab became a week instead of "next up". for fairness, the board wanted to say it out loud every time the long trip rotates: "Closer to Jo this time." i picked the silent rule above.

Question 22, How visible is fairness?: Said out loud, marked Recommended, with a Closer to Jo this time screen, next to Only when it's unfair, shown with the shipped plan's per-person minutes

the picks went into CLAUDE.md as founder decisions, on top of the hard rules. two days later the whole iOS UI was rebuilt from scratch on them, and the domain moved to 333.place. the 3:33 drop, Wrapped, streaks and in-app chat are parked as deferred, and the live map of where your friends are is gone.

Still called 333

in september the welcome screen and the landing at 333.place moved to a sky, with an espresso cup, a red glass and a tennis ball drifting at the edges. the only 3s on it are the mark in the corner.

The 333.place landing: Find your third spaces over a blue sky, with a chrome espresso cup, a red glass, a tennis ball, gingham and a matcha glass around a phone showing the Places map

the app is named after a time of day it no longer sends you anything at. the math from the first week still picks where you go. it just stopped saying so.

  1. Mapbox in the research, Apple MapKit in week one, MapLibre with Protomaps in august, native Google Maps in september, then MapLibre on OpenFreeMap's free tiles, with Apple Maps answering travel times as text. ↩