QRsocial
A social network scoped to one room. Scan the code at the door, prove you're actually inside, and join a pool that dissolves the moment you leave it.
11pm. Low light, loud room, and everyone facing the same view.
- Client
- Kalyan
- Role
- Product Design · Design System · Full-Stack
- Scope
- Web · iOS · API · Realtime
- Status
- In build · 2025
Every tap up there wrote a record down here.
The prototype is not a video. Each action names the model it touched — Presence while you're inside, FollowRequest when you ask, Connection when they agree, ChatMessage until you leave. Scroll back up, do something, and watch this fill.
- presence:joined ×4Sessional — Presence rows, alive only while inside the fence.
No users yet. Here's what's actually built.
Countable facts, each verifiable by opening the repo. When this ships with real usage these numbers should be replaced by outcomes, not joined by them.
Web, native iOS, HTTP API, realtime socket server, and two shared packages.
One ThemeColors contract, filled five ways — light, dark, and three venue skins that take precedence over mode.
Durable, sessional and volatile — encoded in the schema, not in a cleanup script.
Everyone in the room, and no way to say anything.
Kalyan came with a straightforward observation about the venues he works with: a bar on a Friday night holds a hundred people who will never speak to each other, and the apps that claim to fix this are built on the opposite assumption — that you are at home, on the couch, swiping through people who are nowhere near you.
The brief asked for a temporary digital community for people physically present at the same venue. That word — temporary — is where the interesting engineering is. Every social product is built to accumulate. This one had to be built to forget, and forgetting is much harder to retrofit than to design in.
A social app whose whole value is being in a specific room can never be more convenient than being in that room. Every entry step I added made the product safer and the door slower. That tension is the actual design problem — not the grid of faces.
Attendees are the users. The venue is the one who has to say yes.
GET /venues/:id/stats returns live occupancy broken into active, grace and pending. Not tickets sold and not check-ins — people whose phones are currently inside the fence, each one having passed a rotating code and a live selfie.
Minting a new code retires every previous one in the same transaction. A host who wants the room closed rotates it; whoever screenshotted the old one is out. Access control the bar staff already understand.
The room deletes itself. There's no forum accumulating under the venue's name, no history to police, no archive anyone can be sued over — the operational cost of hosting one ends when the night does.
That is the pitch, not a finding. Whether a live room actually moves dwell time or bar spend is exactly the kind of claim this page can't make yet — it needs a venue, a season, and a comparison against nights without it.
Three proofs at the door. Two of them cost nothing.
Presence is asserted three ways, and each closes a hole the others leave open — a QR alone is copyable, GPS alone is spoofable, a selfie alone proves a face and not a place. But three proofs also sounds like three obstacles, so here is what each one actually costs someone half-distracted in a loud room.
Point at the code
Hold the phone up to the code on the bar. A continuous decode loop fires the moment it reads, so there is no shutter to find and nothing to confirm.
Codes carry an expiry, and minting a new one atomically retires every previously ACTIVE code in the same transaction. A photographed code dies the moment the host rotates it.
Stand where you already are
Nothing. Validation returns the venue's centre and radius, and position reports on its own for the rest of the night.
75m fence, re-checked all session. Leaving opens a grace window instead of evicting you — phones lose GPS indoors, and being thrown out for walking past a wall is not acceptable.
One photo of your face
Press the shutter, then Use photo. The camera never restarts between the code and your face — one video element is shared across both, so the switch is a cross-fade rather than a reload.
Camera only, no library upload. It anchors the entry token and becomes your visible identity — the thing proving you're real is the only thing anyone can see.
zero keystrokes
Three proofs sounds like three obstacles. Two of them cost nothing — the code reads itself and the fence checks itself — so the only thing asked of someone half-distracted in a loud room is to hold up a phone and press a shutter. Registration happens once, long before the door; nobody creates an account standing in a queue.
That count is the returning path. Your first time also costs one system camera prompt — unavoidable, and deliberately spent at the scanner rather than saved for the selfie, so permission is granted before anyone is pointing a lens at their own face.
The name isn't hidden. It was never in the payload.
You unlocked a profile in the hero. This is what the server actually sent to make that happen — and what it refused to send before you had permission. Step through and watch displayName simply not exist until a Connection does.
{
"venue": {
"name": "Skyline Rooftop",
"venueType": "BAR"
},
"geofence": {
"latitude": 17.4326,
"longitude": 78.4071,
"radiusMeters": 75
}
}Validation only — no presence row is created yet. A scraped QR gets you a venue name and nothing else.
Accepting runs as one transaction — the request moves to ACCEPTED and the Connection is materialised together, or neither happens. A half-accepted request that unlocks one direction is the exact bug you cannot ship in a product about consent.
Ephemeral is a schema decision, not a marketing word.
When you left the venue in the hero, three different things happened to three kinds of record — because lifetime is a tier every model is assigned to, and a background sweeper in the realtime service is what makes the promise true.
Only what survives the night. Connection is here — a connection you consented to is yours to keep, and it is the single exception to the whole ephemeral model.
Scoped to a visit. A pending follow request deliberately outlives the venue — if you both left before answering, it is still waiting, because the alternative punishes whoever walked out first.
No history for late joiners, gone when you leave. Someone arriving at 11pm cannot read what was said at 9pm — the conversation belongs to the people who were there.
Not a prototype. Two screens from the iOS app.
Everything above this line is a demonstration I built for this page. These two are the actual product — the pool that withholds names, and the one screen in the app where a name appears.



One session, one build, in the app's dark default. The venue switch demonstrated above is live on the web client, which sets data-venue-theme from the venue record; on iOS the five token fills exist and the provider supports them, but nothing selects a skin yet. The room screen renders from fixtures — chat, inbox and profile are wired to the live socket.
Why presence got its own service.
Request/response traffic and long-lived socket connections have almost nothing in common operationally. One scales on CPU per request; the other scales on how many connections a box holds open, and needs sticky sessions to do it. Put both in one process and the cheap thing scales with the expensive one — so you pay for the expensive one.
So the realtime service is separate and stateless. Rooms are venue:<id> and every socket also joins user:<id> on connect — both adapter-safe, so running N replicas behind a Redis adapter is a deployment change rather than a rewrite.
That split creates one problem: the API knows when a follow is accepted, and only the socket server can tell the other person now. Rather than pull Redis into an MVP for one message type, the two talk over an internal HTTP webhook with a constant-time bearer check, fired best-effort so a slow notification can never delay a response. It is explicitly a seam — the publisher is one function, and swapping it for pub/sub touches no callers.
// One package both ends import, so the
// client and server cannot drift apart.
interface ServerToClient {
"presence:joined": (a: AttendeePreview) => void;
"presence:left": (id: string) => void;
"presence:updated": (p: PresencePatch) => void;
"chat:message": (m: RoomMessage) => void;
"follow:request": (r: RequestPreview) => void;
"follow:accepted": (c: ConnectionRef) => void;
}What's done, what isn't, and what I got wrong.
The entry handshake, the privacy gate, the follow loop, the ephemeral chat and the presence sweeper are built and wired. The web client runs against the live socket. The native iOS app is further behind — its room screen still renders from fixtures while chat, inbox and profile are connected, and the host-side tooling exists as three API endpoints with no interface on top of them yet.
I am also treating the compatibility score with suspicion. It is computed from shared interests and profile attributes, and a number on someone's face is a strong claim to make about a stranger. A 66% is a judgement rendered by a system that has met neither person.
The grace period is the decision I keep returning to. Two to three minutes was specified as an anti-flicker measure for bad indoor GPS, and for that it is right. But it also means someone can leave and stay visible in the pool for three minutes after — which is a safety property, not a UX one, and deserves to be argued as such rather than inherited from a brief.
QRsocial decides who is in a room. Farm OS decides whether the room is safe to be in.
The same instinct, pointed at hardware — a system that reports its own state, and says so before it fails. Custom ESP32 nodes, printed enclosures, and a dashboard 22,000 birds depend on.
Farm OS