Inspiration
An evening ends, the bill lands on the table, and then it starts: who had the second bottle, who only ate a starter, and who is going to work it all out. Somebody opens an app, and the app wants an account, a phone number, and a friend list, so that four people who are sitting at the same table can tell each other a number.
The devices are already lying next to each other. That is the whole observation. They can talk directly, they can do it without a server, and for the length of one evening that is enough. The closest thing the platform offers is SharePlay, and it needs FaceTime or Messages, Apple IDs and a working internet connection. Narby needs none of those, and that is the point of it.
The other half of the idea is that a bill is not the only thing a table does. It also plays, votes, writes a list, and works out who is paying. Those are the same problem wearing different clothes: several people, one shared thing, for as long as they are together, and then it is over.
What it does
One person shows a QR code, everyone who can see the screen scans it, and from there the devices hold the table open over Multipeer, device to device. Apple's Foundation Models framework reads the photographed receipt on the device that took it, the split comes out exact to the cent, and the same table can write a list or play a game afterwards. Nothing at the table goes through the internet: put every device in airplane mode and the evening still works.
The part nobody else brought to this hackathon: Snake across the phones. Lay two, three or four iPhones edge to edge, tap the picture that shows where yours lies, and the screens become one field. Each person steers a snake on their own phone, and the snakes run across the seams onto the other screens as if the glass were continuous. The field is built from the real screen sizes, in 18-point cells, so a cell is a cell on every device; the seam is fixed when the round starts rather than nudged while a snake is on it. It needs no special chip: since 1.1 the arrangement is chosen from a picture, and the ultra-wideband distance check is an extra on phones that have it, not a requirement. Pong runs across the same seam with two paddles and one ball.
Getting to the table. One person opens a table and shows a code. Whoever can see the screen scans it and is in. There is no account, no invitation link, no phone number and no device list. The code is deliberately the access control and not just the transport: within five metres sits the next table, and a session you join because you happen to be close enough would be socially wrong as well as annoying. Up to eight devices sit at one table (that is the ceiling of Apple's Multipeer session, so it is named rather than discovered), and at the ninth Narby says the table is full and points at the solo path.
Everyone gets a colour when they join, and it holds everywhere: on the band across the top of their own screen, on the ring that shows who is there, on the lines they take on the bill, on their side of the board, on their snake. A first name stands next to it, so the colour is never the only signal.
The bill, to the cent. Photograph the bill or type the lines. Then everybody takes what they had, on their own device. Two people taking the same line means shared, not overwritten, because a claim is one bit per person and item rather than a field per item. The split adds up to the cent: a remainder is distributed by the largest remainder method with a rotation seeded from the item number, so the same person does not pay the extra cent on every shared plate of every bill. If the lines and the printed total do not agree, Narby says so and never quietly corrects it. And when nobody else has the app, you split it alone and send the amounts out; that path is the whole app, not a fallback.
The model reads the bill, a person confirms it. On a device with Apple Intelligence, the on device model reads the photo and fills lines and amounts into a form. Every value it read is marked as an AI suggestion until you confirm or change it, per field, and the mark disappears in the same step as the edit. Nothing goes to the table before somebody releases it. Without Apple Intelligence the camera button is simply absent and typing the lines in is the complete app, not a lesser one. The interface itself runs in German, Spanish and English, and the receipt is met in the same three: text recognition is configured for all three languages at once, and the currency is decided by a symbol printed on the paper or by the device region, never by the model's guess.
Six more things a table can do, all on the same screen, chosen by the person showing the code:
List. One collection, three views (list, cards, mind map), everybody writing at once; the view is a local choice, the entries are shared. Tic tac toe. Two people take a side by tapping, the rest watch, a side is held like a seat rather than lost by walking away. Who pays? A draw over everybody at the table, stopping on the same name on every screen at the same moment, from a shared seed rather than a host's secret. Impostor. Everyone gets the same word, one gets its partner word, and nobody knows who has which. Here the secret is deliberately the host's, which is the exact opposite of the draw, and for the same reason. Snake across the seam. Two to four devices in a row, a snake each, and they run across all of the screens. Pong across that same seam. Two paddles, one ball, a set to seven; facing each other it is the table game, side by side it is the 1972 arcade. And it does not go through the internet. No account, no server, no cloud, no sign in, no analytics, no ads, no sync. Turn on airplane mode and all of it still works. The photo of the bill stays on the device that took it and does not outlive the table; the games save nothing at all. The only things that ever talk to a network are purchases and the "more apps" list, and neither is on the path of anything the app does at a table.
Free, with no unlock. Three optional tips (a coffee, a tapa, a dinner) buy a supporter badge and a second app icon. No module is ever put behind money, which is a line in the app's own rules: a round at the table must not fail because one person did not pay.
How we built it
Swift 6 with strict concurrency, SwiftUI, deployment target iOS 26, project defined in XcodeGen, iPhone and iPad. No third party dependency except the purchase layer described below. Roughly 770 automated tests, and the parts worth testing are deterministic on purpose.
Four kinds of synchronisation instead of N modules. The mistake a platform makes is letting every module invent its own networking, and then the tenth module costs as much as the first. Here a module implements no networking at all; it declares its kind, and the session layer provides exactly one tested strategy per kind. Turn (small ordered messages with one authoritative counter), claim (small commutative messages, last writer wins per person and item), collection (conflict free merge without a server, deliberately small), flow (60 Hz, latency critical, nothing is stored). Each kind has a frozen byte layout: a claim is three bytes, a flow frame is five bytes plus the module's payload.
Pong was built to test that sentence rather than because anybody needed Pong, and the result is measured rather than felt: 510 lines of seam logic inherited for free, 66 lines of axis rotation written, and its own half of the module (rules, wire, surface) 895 lines against Snake's 931. The networking really was free; the surface and the rules really were the whole price.
The QR code is the access control at two places, not one. The ticket is binary, frozen field by field, wrapped in Base32 (RFC 4648, no padding) at error correction level M because every usable scanner hands back a string and binary data dies silently on a string round trip. The advertiser publishes only the session id, which is no secret; the access token never travels over the air. It sits on the screen, comes back as the invitation context, and is checked twice, once by the transport and once by the session, because the transport is replaceable and the rule is not. Multipeer carries the answers back, and the encryption is the system's own.
Version skew is negotiated, not assumed. Without a server, devices with different app versions meet. Each device announces which modules it has and at which schema version, the table runs on the intersection, and a module somebody is missing is named with the person who is missing it rather than quietly absent. Every payload carries its schema byte, so an older build fails on a newer one by name instead of by misreading it.
A seat travels, never an identity. Claims, turns, entries and frames carry a three bit seat index rather than sixteen bytes of identity: everybody at the table agrees on it because the host assigns it, it survives a reconnect, and it already is the colour. The sender's seat is resolved in exactly one place and compared against the seat in the payload, so "a phone cannot set somebody else's bit" is one check and a named error.
The bill's reading is guided generation, and the arithmetic is ours. The model gets Vision's text in reading order (German, Spanish and English configured simultaneously) and answers a @Generable struct whose amount fields are decimal strings rather than numbers: a receipt prints 8,55 and 1.299,00 and EUR 8.55, and handing that decision to a model, once per line, invisibly, is how a price becomes a Double. Every field is optional, because a required field is a field the model will fill whether the answer is there or not. The prompt templates are versioned files in Shared/Prompts//, not string literals.
After the model, four deterministic stages run in an order that is itself the argument, and each one exists because a real receipt broke us:
Footer lines (IVA, TOTAL, Base, PENDIENTE, MESA) are dropped or redirected into the field they duplicate, never guessed away by percentage tolerance. A quantity above one needs printed evidence in the recognised text, in two token shapes; without evidence it stands only while it is plausibly small. Unit price or line total is decided by whichever reading lands closer to the printed total, not by asking the model. Included or added tax is decided by arithmetic where there is a printed total, and by the device region where there is none, with the short list of exceptions named rather than a long list of the world. The currency comes from the device region and is only taken from the model when the code is on a small allow list and one of its symbols appears literally in the recognised text.
The purchase layer. RevenueCat sits in front of StoreKit 2 for the three tips, pinned to an exact version, linked into the app target only, because a widget extension has no business knowing a payment system exists. There is no RevenueCat paywall, no offerings dependency and no product change: products are fetched by identifier, and the supporter section is our own. A build with no key falls back to StoreKit 2 completely, and that fallback matters more here than in our other apps, because the promise this app makes is that it works with no network at all; a build setting must not be able to change what the app is. The keyless tree is checked rather than asserted: it builds green, the key entry is empty, and the suite passes. Privacy is kept literally: no app user id and therefore no account of any kind, automatic device identifier collection off, diagnostics off, the SDK cache in its own defaults suite. The privacy label stays "Data Not Collected".
RevenueCat shipped with 1.1, live on the App Store since 04.09.2026 (build 30, approved on the second review after the first was rejected under 5.1.1(iv) for a "Not now" button on the local-network explainer). Version 1.0, live from 01.09.2026, was a plain StoreKit 2 build.
Challenges we ran into
One receipt, four independent errors, and none of them was loud. The first real two device evening happened in a restaurant in Guadalajara province, Spain. The app read the line 4 X MENU DEG.MATANZA *160,00 as quantity 4 at 160,00 and computed 640,00; it decided the currency was Mexican pesos, because the model had seen the place name Guadalajara and the paper printed no symbol at all; and it added a tax that the printed total already contained. The reconciliation then named a gap of 485,45 that the app had created itself.
Every fix has the same shape, and it is not a longer prompt: the model reads, the engine computes. Currency comes from the region unless the paper shows the symbol. Both readings of an amount are tried and the closer one to the printed total wins. Included tax is arithmetic (lines plus tax equals total, or lines equal total), never a country table. A later evening added the fourth: the model read the amount into the quantity field (160 units of 1,00 EUR fanned out onto the table), and that error is arithmetically invisible, because the line total reading ignores the quantity and the unit price reading divides by it, so both add up with any quantity at all. No amount of checking sums would have found it; only printed evidence would, so that is what it now requires.
The connection was unreliable and nothing in the app knew why. iOS suspends an app on lock and in the background, and the suspension kills the Multipeer session, advertiser and browser without reliable delegate events: the one failure the event stream cannot report, because it would have to be running to report it. Everything the app had built for self healing was waiting for exactly that event. The answer was to stop waiting and start asking: on becoming active, the transport is asked what the radio actually has, the difference against the app's own bookkeeping is precisely the set of swallowed disconnects, and each one is pushed through the same disconnect path as a live one. One event, two origins, no second reconnect machine.
A colour that had to stay under a navigation title could not be the loudest thing on the screen. Wanting the table to see whose phone is whose, we built a gradient behind the status bar, measured its blend against 48 chrome pairings, and shipped something correct that failed its only job. It went through three revisions in three days, ending as a plain band under the navigation bar that takes space instead of overlapping. Two findings from that: text on the eight participant colours came out 16 out of 16 in the dark ink and zero in the cream, which is not luck but the consequence of the luminance band the palette was built in; and the band painted through the status bar because two SwiftUI defaults compose that way (background(_:ignoresSafeAreaEdges:) defaults to .all, and a safeAreaInset hands its inset view the container's safe area on purpose). Neither was visible in the code. Both were found by photographing the running app and scanning a column of pixels, which is now how layout gets reviewed here.
A child found two defects in one evening that eighty adult sessions had not. He played Snake with big gestures, and big gestures on a full bleed canvas are iOS system gestures: he triggered the home screen and lost every game. No surface in the app had ever asked to defer them. He also noticed that the snake "raced" after every loss. The obvious suspect, the speed ramp surviving the rematch, was wrong, and measuring it said so: a rematch builds a fresh game. What survived was the drawing clock, an interval estimated from arrival times that still held the fast round's value, so the first frame of the new round was drawn 1.66 times too fast, and (on the host only) the last picture of the dead round was still there to interpolate from, which threw every head across the screen. The rule that came out of it: the drawing clock is round state, not device state.
A QR code that a long name could break. Every capability the device offers travels in the invitation ticket. With the seventh and eighth module the full ticket measured 226 characters against a cap of 220, so a host whose name was 21 to 24 bytes got no code at all, and shorter names worked fine. The cap turned out to be wrong as well: the comment said 220 was the limit of a version 9 QR at error correction M, and measuring with the app's own generator said 221 is the last character of version 8 and 262 the last of version 9. Raised to 262, which costs a longer name a slightly denser code and costs everybody else nothing, because the generator picks the version per ticket.
Accomplishments that we're proud of
Seven modules on four kinds, and the fourth one costing almost nothing. That was the bet the architecture made in the plan, and Pong is the receipt for it. It also means the storage promises are per kind rather than one promise with exceptions: flow and turn write nothing, claim and collection store exactly what the user can see. Each is verified with a filesystem fingerprint taken before and after a full round through the real module, not asserted in prose. Every module states its promise in one sentence on its tile before it starts.
Impostor keeps its secret by construction, not by intention. The round broadcast and the catch up snapshot have no field a word could sit in; the word travels as a directed frame to exactly one device. A test listens on the raw bus and proves that no broadcast contains the word's bytes. The host could cheat, exactly like the person dealing cards at a table, and everybody can see whether they do.
The colours are derived and measured, and the order they are handed out in is the part nobody expects to matter. Eight hues at 40 degree spacing with an 80 degree hole around the app accent, one shared luminance band, contrast against all four grounds at least 3:1. Seats used to take them in order, so a table of two got the two most similar colours in the palette. The order is now a bit reversed permutation, which is provably max min for every prefix; the second person now lands 160 degrees away instead of 40. And the honest half is in the test too: under simulated colour blindness the palette collapses, which is exactly why colour is never the only signal.
The bill adds up, and when it does not, it says so. A thousand generated bills with random party sizes, line counts, quantities, discounts and both surcharge modes hold one invariant: the sum of everybody's share plus the unclaimed lines equals lines plus tax plus tip, to the cent, with a seeded generator so a red run can be repeated.
A privacy label that reads "Data Not Collected" and matches the code. The table never goes to the network. The purchase path and a cross promotion list are the only things that can, and both are off the path of everything the app does while people are sitting together.
Accessibility that carries the same statements. The ring speaks all four of its meanings as one sentence and updates on every change, because an animated ring is nothing to VoiceOver. The identity band speaks the same sentence in both of its visual states, and it is deliberately not a button. The word card in Impostor speaks what it would show, including the rule sentence that is identical on every card so that the card does not know who is holding it.
What we learned
A restaurant finds a class of defect a test suite cannot reach. Four independent errors on one printed receipt, and every one of them looked plausible on screen. We now keep the receipts in the test suite verbatim, including the one that does not add up, because a fixture that has been tidied up stops being evidence.
Some errors are invisible to arithmetic, and it is worth knowing which. A wrong quantity is the clean example: both readings of the amount agree with any quantity, so nothing downstream can object. When the check cannot exist, the answer is evidence (did the paper print it?) rather than a cleverer check.
Look at the layout instead of reading it. Three layout errors on iPad and the band painting through the status bar were all invisible in review and all obvious in a screenshot. The suite was green for every one of them.
A small on device model is anchored by whatever you show it. Positive guidance naming where on the page a value lives works; counterexamples make it worse; every field optional, or it invents. This was learned in another app and held here without an exception.
A wire format is permanent from the first upload. Before the store, a schema change costs nothing; after it, it is a migration, and the honest form of it is that the module itself tells the table it needs an update, because the capability negotiation will happily settle on the older version and then fail frame by frame in silence.
And a child is the best tester of a gesture surface there is, because an adult already knows where the edges of the operating system are and unknowingly avoids them.
What's next for Narby: Split the Bill
Version 1.1 is in the store since 04.09.2026: iPad, Pong as the seventh tool, Snake as a row of two to four devices, the identity band, and RevenueCat.
Next is 1.2 with two small things every app in the family now has: a quiet card on the main screen when a newer version is in the store, and an appearance setting (system, light, dark) for this app alone. After that, the list is mostly honest debt that the 1.1 screenshots exposed. On a 13 inch iPad a short screen hangs at the top edge and leaves the lower half empty, which is a layout question the phone never asked. The bill confirmation sheet becomes a form sheet on iPad, so the empty state behind it is visible at the same time as the draft in front of it. Thirty string keys still say "phone" where they should say "device", and they will be decided per language rather than translated. Spectators for the flow modules, and the two by two square for Snake, which is not a longer row but a different geometry: a snake would have to leave a vertical seam and enter a horizontal one.
Built With
- activitykit
- apple-foundation-models
- multipeer-connectivity
- nearby-interaction
- revenuecat
- storekit
- swift
- swiftui
- vision
- xcodegen

Log in or sign up for Devpost to join the conversation.