index / work / bubba
Building Bubba
A cast stone object with an NFC chip in it, and an iOS app that locks your distracting apps until you get up and tap it, narrated throughout by a bored devil. Moulded puck, cast base, brass inserts set into the pour so it comes apart cleanly. The object is the only key.
Live, early access. In testing with 20 people.1 No traction numbers on this page: testing has only just started, and what comes out of it goes in the log rather than here. The physical object is not photographed yet because it is still changing.
| role | sole engineer, designer and manufacturer |
|---|---|
| stack | Expo, React Native, TypeScript, zustand, MMKV, expo-sqlite, react-native-nfc-manager, Supabase |
| native | Three Swift app extensions (activity monitor, shield, shield action) and an Expo native module, on FamilyControls, ManagedSettings and DeviceActivity |
| screens | 11 |
| voice lines | 81 across 14 banks2 |
| hardware | v6 prototype, in-house moulding route |
| testers | 20 |
The mechanism
three parts, and the middle one is the product
The object
A circular recycled polypropylene puck sitting in a cast base, with an NFC tag behind the puck face. It has no battery, no pairing dance and nothing to charge. It sits where you work.
The gate
The app selects apps to block using Apple's Family Controls and ManagedSettings, at the system level. Not a timer that nags you, not a screen you can swipe away. The blocked apps do not open.
The walk
The only way to unlock mid-session is to physically return to the object and tap it again. Tapping opens thirty seconds of a flat grey screen with one line on it, then five minutes of unlocked apps, then the session resumes where it paused.
Every focus app sells discipline. Bubba sells the walk back to the desk, which is the part that actually works.
The distinction matters commercially as well as behaviourally. Software friction can be defeated from the sofa, so it gets defeated. Friction with a floor plan in it does not. And the object that enforces it is not a render or a sourcing plan: I mould the puck and cast the base myself, a few at a time, which is at the foot of this page in photographs.

The app
four of the 11 screens, and one that is iOS's





Red is rationed across every one of those screens: one accent colour, seven permitted uses written down as a list rather than left to judgement, and two typefaces with no exceptions.
Two runtimes, one voice
where the character meets the architecture
The character is a bored devil who records what you do and passes no comment on it, and he is a voice bank rather than strings in a view, which is the only reason two runtimes can both speak as him. The app is React Native. The screen above it, the one iOS draws over a blocked app, is a Swift extension running in its own process, and it cannot see the JavaScript bundle. So the character has to cross a process boundary to say anything at the moment it matters most.
It crosses through a shared App Group. When a session starts, the JavaScript side picks a line and hands it to a native module, which writes it into UserDefaults under the suite group.io.bubba.app. When iOS asks the extension to build a shield, the extension reads that same suite, and if the key is missing it falls back to a string compiled into it, which is in character too: You know where the totem is. The app and all three extensions declare the group in their entitlements, and that is the only reason any of it resolves.
The same channel carries traffic the other way. The button on the block screen cannot ask the system to open the app, because the call that does it does not exist inside an extension, so it writes a timestamp into the group instead and the app reads it the moment it comes back to life and sends you where the button meant to.
One string in a shared container is the smallest thing that could have worked, and it is the difference between the character being present at the hardest moment and iOS showing its own default.
The shield shows one line per session, not per tap. The key is written once when the vow starts, so every blocked-app launch for the next hour draws the same subtitle. The extension cannot run JavaScript, so rotating per tap means writing the whole candidate list into the group and letting the extension pick an index itself when iOS asks it for a configuration. That is the fix. I have not made it.
How a session is stored
the clock has to be right when nothing of mine is running
A session outlives the app. The phone locks, iOS backgrounds the app and then kills it outright, and forty minutes later someone opens Bubba expecting the timer to be where they left it. Nothing of mine is running in that gap to keep it there.
The constraint is not that the timer has to be accurate. It is that the timer has to be reconstructible from whatever survived.
A session is therefore two numbers: the instant it started and how long it was vowed for. Nothing counts down. The remaining time is subtraction, recomputed from those two numbers every tick and every cold launch, and the record is read back out of storage before the first screen renders. There is no counter to drift, nothing to keep alive in the background, and no state to reconcile on return, because nothing was ever advancing.
That is also what makes a pause honest rather than merely well behaved. Coming back from a break does not credit you the time you served, because there is no served-time field to credit: the start instant is moved forward so the same subtraction yields the time that was left. The cost is real and worth stating. After a break the stored start is no longer when you began, it is a value chosen to make the arithmetic come out right, and the true wall-clock span of a session is not recoverable from it afterwards. I kept the count of breaks and the time remaining at the first one, and let the rest go.
The same discipline runs into the schema. A finished session is one row with one outcome, and the outcome is a closed set of two values. There is no third value for a break, so a break has nowhere to be written as a failure, by me or by anyone adding a statistic to this in a year. It is a count on the row instead, added later by a migration that runs itself. The two decisions below say what that is for; this is where they are enforced rather than merely intended.
Sending is the one part that reaches a network, and it is treated as the unreliable step it is. The session is written locally first and posted afterwards, and a post that fails is queued in the same database as the ledger and tried again on the next cold start, a few times, then left alone. A record that never sends is kept rather than dropped. It is a row I can come back to, and losing someone's session because a server was down for an evening would be a poor trade for a tidier table.
Decisions
drawn from the log, the three still worth arguing with first
No app without the object.
Install the app without a paired object and you reach a dead end that does not offer a workaround, a trial or a soft version. The product is a £50 object with the software attached, not a subscription with a novelty accessory, and a soft gate would have made it the second thing within a week.
No tooling while the design is still moving.
A 500-unit injection moulding quote was on file and viable. Taking it would have frozen the surface texture, the puck dimensions and the fixing at v2, months before any of the three were settled. A desktop moulder and a printer together cost less than that one run, and the price of keeping the design unfrozen is a manufacturing route that is still a plan rather than a proven line.
Surrender is an event, not an outcome.
Walking over and unlocking mid-session does not fail the session. Only deliberately abandoning it does. The record shows both, so a completed session that took three interruptions reads as a completed session that took three interruptions, which is the truth and is more useful than a failure.
Also decided, and settled
- Five durations, no custom field. 30 minutes, 60, 90, two hours, four, and no free-text box, because negotiating with yourself before the session starts is the behaviour the product exists to interrupt.
- The clock pauses, it never rewinds. Interrupting a 60-minute session costs you the walk and the wait, not the 40 minutes you already served, because punishing served time would teach people to abandon sessions instead of returning to them.
- One notification. A single nudge after three days of inactivity: no streak reminder, no daily prompt and no re-engagement sequence, because an app about not being interrupted does not get to interrupt you.
The object
two components, and why they come apart
It has to come apart, so the fixing is cast in rather than added afterwards.
Brass inserts go into the pour, and the two halves bolt together and unbolt again: for repair, for replacing the tag, and for getting the electronics out at end of life. What bolts together is a black recycled PP puck, partially recessed into a cast Jesmonite base in charcoal, raw finish and no sealer, with BUBBA cut into the mould in recessed blackletter rather than applied to the object once it exists.
The chip is specified on read lifetime rather than memory. This is an object you tap several times a day for years, so the number of reads it is rated for is the specification that matters, and that is what chose an NTAG215 over the cheaper 213. It also cannot be inside the puck while the puck is being made, because the tag does not survive the heat, so the inlay goes in behind the face afterwards. That one constraint set the assembly order and the underside geometry. Writing that tag and recording which unit it belongs to is a bench tool of its own, and it has a page of its own.
The route is deliberately small enough to change. A desktop injection moulder produces the puck from shredded recycled PP, a printed polycarbonate insert gives it its surface, and the base mould is a printed master, cast in silicone, poured in Jesmonite. 6 prototype iterations so far, which is what not tooling bought.


What it actually took
the unglamorous parts
The Family Controls entitlement is granted by Apple case by case, and the APIs behave differently under it than they do in the documentation examples. A string in a shared container is what that costs: nearly every route between the app and the shield turns out to be closed, and the one that is open moves bytes rather than calls. Two processes, no shared memory, and a dead drop between them.
Copy is not written into the interface. Every line the product says comes out of a JSON voice bank, banked by moment, so the tone can be revised without touching a view, and the same line never has to be found in three places. This is the part that will look like over-engineering until the first round of tester feedback says a screen reads wrong.
A field existing in the data does not entitle it to a screen.
The stats screen counts seven things and refuses several more that were easy to compute. Anything that turned the app into a dashboard came out, including the entry count on the home screen, which made a threshold feel like an inbox.
What is not done
known gaps, stated plainly
- The object is not finished. The surface texture is undecided between six candidates, the base material is still to be confirmed against a cement control, and the fixing spec is open until the puck dimensions settle. The photographs of the object are of the base and the mould that makes it, and the moulded puck does not appear in any of them. No photograph on this page is a photograph of a final unit.
- There has been no production run. The moulder is in, the printing route works and the chip is specified. The insert tooling is not ordered, and until a batch of fifty comes off consistently, the manufacturing route is a plan that has worked 6 times.
- No usage data worth quoting. 20 people have access and prototype tags. Access granted is not use, the test period has not closed, and anything I say about behaviour before it does would be a story about 20 people who know me.
- iOS only, and structurally so. Family Controls has no Android equivalent. Android is not a port of this app, it is a different mechanism with the same object, and I have not designed it.
- Accessibility is partial. One screen is deliberately hard to read, which is a design decision with a real accessibility cost that I have not resolved. The dithered imagery needs alternative text that does not sound absurd, and the two-colour palette needs checking properly rather than by eye.

Thirty seconds of this, at low contrast, on purpose. The design decision and the accessibility cost are the same decision. - One reward feature is a placeholder. It has a screen, a definition and no mechanism behind it. It says so on the screen, which is the only reason it is allowed to ship.
Sources
- 20 people in the test group, counted from the tester list. It counts access granted, and says nothing about how many of them use it.
- Screen and voice line counts taken from the build and the voice bank on 4 September 2026. The bank is version 1.1.0: 81 lines across 14 banks. Counts what exists, not what is finished.