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.

rolesole engineer, designer and manufacturer
stackExpo, React Native, TypeScript, zustand, MMKV, expo-sqlite, react-native-nfc-manager, Supabase
nativeThree Swift app extensions (activity monitor, shield, shield action) and an Expo native module, on FamilyControls, ManagedSettings and DeviceActivity
screens11
voice lines81 across 14 banks2
hardwarev6 prototype, in-house moulding route
testers20

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 Bubba holding page: a blackletter wordmark in red over a dark, heavily dithered engraving of a lamplit hall.
The holding page at tapbubba.com. Art direction rather than product: the object is not photographed yet, so the page carries the engraving and the wordmark and nothing it cannot stand behind.

The app

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

The Bubba home screen: a dithered engraving of stone steps, the numeral zero, and a button reading MAKE A VOW.
The home screen. The engravings are public-domain plates put through a dither at two colours, and which image appears depends on the time of day and what the record says.
The Vow screen: five duration options from thirty minutes to four hours, and a button reading SIGN THE CONTRACT.An active session: a dithered engraving of a stone staircase, a timer reading twenty-nine minutes fifty-eight, and a SURRENDER button.A screen reading "You know where the totem is", with a button reading TAP THE TOTEM.
Signing, serving, and the only way out. Five fixed durations and no free-text field, because a minutes box invites you to negotiate before the session starts. The third screen is the whole product: the app will not unlock until you have walked back to the object.
The iOS block screen over a locked app: a horn illustration, the heading THE INCARCERATED, and the line "You know where the totem is."
The screen iOS renders over a blocked app. The icon, the title, the subtitle and one button label are mine. The shape, the typeface, the background and the second button are the system's and cannot be changed, which is why this screen looks like a different product to the rest.

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

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.

Silicone mould held above three cast and printed bases.
Iterating means owning the mould. The blue silicone is cast from a printed master and the bases under it are three versions of what comes out of it, each one a change that would have been a tooling revision on a supplier route.
Printed mould box clamped shut on a tile, ready to pour.
A route small enough to change looks like this. The mould box clamped up before a pour: bulldog clips and a spare tile, which is what a sixth iteration actually costs to produce.

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

Sources

  1. 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.
  2. 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.