The video above covers what Veta is. This is what it doesn't have room for: the rules underneath, and the things that went wrong on the way there.
The design problem
Streak counters are a threat that eventually gets carried out. They work by holding something over you, and the day you slip they take it — which means the mechanic that motivated you for two months is the same one that makes you delete the app. The punishment is load-bearing.
I wanted to know whether a habit tracker could work with the threat removed entirely: nothing to lose, nothing reset, no penalty of any kind. Kintsugi gave me the shape of the answer, but the interesting part was never the metaphor. It was whether an app can still feel like it matters when it has no leverage over you.
The rules that make it work
Three, and they're the whole product:
A crack is a consequence, not a decoration. It isn't a "you failed" badge
drawn on top of the piece. Fracture.generate takes the vessel's form, a seed,
and a ceiling — how tall the piece was on the day it broke. A crack
physically cannot wander into ceramic that didn't exist yet, it dies against the
foot instead of bouncing off it, and its width is the number of days you were
gone. Two people who miss the same week get different vessels, forever, and
neither of them can be redrawn.
Repair is free, permanently. Charging to recover from a bad week would monetise the exact moment the product exists to protect. Everything sold is expansion — more concurrent pieces, the premium glazes, the museum — and never restoration.
A window can only fail once it has closed. Not everything is daily; you can ask a piece for three days a week. While that window is still open, nothing can happen to the vessel no matter how many days are blank. The person who runs on Saturdays must not find their piece broken on a Wednesday.
How it's built
Flutter, and not one bitmap of ceramic anywhere in the app. Every vessel is
a surface of revolution drawn at runtime with a CustomPainter: banded shading,
elliptical rim and base arcs, per-clay gloss and speckle across nine glazes.
The gold palette was sampled from photographs of real kintsugi rather than
picked from a colour wheel — its brightest tone is cream, never white, which is
why it reads as metal instead of a glowing UI accent.
Because the geometry is generated from a seed, a piece is reproducible from a few integers: no images are stored, and the same scar redraws identically on any device, forever.
How RevenueCat is used
One entitlement, plus, delivered by a default offering, and the paywall is
assembled entirely from what getOfferings() returns — package order, prices,
plan names and trial state are never hard-coded, so packaging changes from the
dashboard without shipping a build. The API key enters through --dart-define
at build time and is not in the repository.
Entitlement state is pushed, not polled. addCustomerInfoUpdateListener is
attached at startup, so a restore, a purchase made on another device or a grant
from the dashboard unlocks the app without it having to ask. restorePurchases
feeds the same listener, which means there is exactly one code path that can
flip the flag.
Billing never throws. A missing key, a dead network and a store that was
never configured all end the same way: the entitlement simply doesn't activate
and everything else keeps working. Nothing about purchases can stop someone
recording their day. It also means the app runs on an emulator or on web with
no store attached at all, which is how most of the 115-test suite runs.
The rest of the app has never heard of RevenueCat. Screens read one boolean,
VesselStore.isPlus; Billing exposes three members — isPlus, packages,
purchase — and lives behind them. It is 128 lines, in lib/data/billing.dart.
What is sold is expansion: more than one piece at a time, the five premium glazes of the nine, the museum, and high-resolution export. What is not sold, and never will be, is repair. Charging to recover from a bad week would monetise the exact moment the product exists to protect, so the paywall was designed around that constraint rather than having it bolted on afterwards.
Challenges I ran into
The hard ones were all in the boring layer, and all of them could have silently robbed a user.
Daylight saving. The day index came from
DateTime(y,m,d).difference(epoch).inDays, which truncates the 23- and 25-hour
days a DST shift produces. Twice a year that would have deleted a day of
progress. It now uses civil-date arithmetic.
A save that could destroy the collection. load() cleared what was in
memory and then persisted it, so any read failure became permanent loss. Writing
is now a permission that has to be earned: nothing is saved until the app
knows what was on disk, and anything unreadable is quarantined verbatim —
"this build can't parse it" and "it's worthless" are different claims.
A two-phase write that rescued nothing. Saving writes a copy, promotes it, then deletes the copy, so a kill mid-write leaves a whole version behind. Except recovery preferred the main record — which always decodes in exactly that scenario, because it holds the older, complete version. The newer copy was found, ignored, and then overwritten by the very save that was meant to protect it. Only a test that seeds the disk in that precise state exposes it.
The same lesson, three times. Ugly cracks were never a seed problem. A fracture is generated against the height the piece had when it broke, so a break on day 12 of 60 lives inside a disc of clay and comes out as a stub against the foot. No width and no random number rescues that. What actually needed choosing was when the piece broke.
What I learned
Measurements filter; they don't decide. I built instrument after instrument to judge visual and audio work, and they misled me in the same direction every single time. A gold-detection mask counted warm terracotta as gold. An "attack time" metric measured the waveform's frequency instead of its envelope, reporting 0.1 ms for everything. A one-second RMS window declared four sound effects inaudible while they were playing perfectly — a 4 ms transient averaged across 1000 ms moves the number by a tenth of a decibel. Every one of those detected whether something changed, never whether the change was perceptible.
So the rule became: measure only to discard, and only where the descriptor is a physical claim. A crack is a fast attack — if it takes time to peak it's a thud, and that's not an opinion. A kiln is low-frequency. But whether gold looks beautiful is not something a number can answer, and there the instrument has to stay quiet.
Everything else went to blind comparison: candidates rendered, anonymised, and judged with no context about the outcome I was hoping for. When I told the judge what to look for it voted 6–0 in that direction — and 6–0 the opposite way when asked neutrally. That is the most useful thing I learned building this, and it had nothing to do with pottery.
What's next
Accounts and cloud storage. A collection currently lives entirely on the device that made it, which means replacing a phone costs sixty days of work. The next milestone is an account layer with server-side persistence, so that a piece outlives the hardware that grew it and the same museum can be opened from any device. It closes the last reachable state in which the app could still take progress away unfairly — the one thing it promises never to do.
iOS. Veta is written in Flutter and its rendering engine is entirely platform-independent, so reaching iPhone is a matter of adding a build target rather than rewriting the app. The constraint is not engineering but access: publishing to the App Store requires a Mac and an Apple Developer membership, and neither is within reach on a student budget in Argentina. Any prize this project earns is already committed to precisely that — the machine and the membership, in that order — which makes an iOS release the first concrete use of an award rather than an open-ended intention.


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