Every hydration app I tried had the same problem: it was just me and a number. Log a glass, watch a bar fill, forget about it by Wednesday.
Hydration apps are built for one person. When I looked for one designed for couples, where you each hit your own goal but play as a team, I couldn't find one. So I built it.
DuoDrop is a mobile-first Progressive Web App (PWA) for couples. Every glass of water either of you logs waters one shared plant. It starts as a seedling in a pot and grows into a blossoming tree. You each keep your own daily goal, but the garden only thrives when you both show up.

An uncut recording: Alex logs on the left, and Sam's plant reacts on the right in real time.
This post covers the engineering behind it: realtime sync with Supabase, why all the game rules live in PostgreSQL, how the procedural plant is generated, and what it takes to get web push notifications working on iOS.
đź‘‹ I'm a full-stack engineer who enjoys turning small ideas into polished products. If you like this kind of build, let's connect on LinkedIn.
What DuoDrop does (the 30-second tour)
-
Pair up. One person plants a seed and gets an invite code like
DUO-WA9K2. The other opens the link and sees who invited them before they even sign in. - Log water. Tap a quick amount (250 / 350 / 500 ml) or open the log sheet. Your partner sees it instantly, with droplets flying into the plant.
- Grow together. Water counts toward "together %", and the plant's health follows that number. XP from logging levels the garden up, and the plant visibly grows.
- Play the daily game. There are rotating challenges ("Photo finish": hit your goals within 30 minutes of each other), streaks, and badges like First Sprout and Full Bloom.
- Get gentle nudges. A push notification tells you when your partner drinks, and an hourly job reminds whoever is falling behind (at most twice a day).
The tech stack
| Layer | Choice |
|---|---|
| Frontend | React 19, TypeScript, Vite, Tailwind CSS v4 |
| Animation | Motion (formerly Framer Motion) for springs, layout and gestures |
| Backend | Supabase: Postgres, Row Level Security, Realtime, Auth, Edge Functions |
| Jobs & webhooks |
pg_cron + pg_net, straight from the database |
| PWA |
vite-plugin-pwa (injectManifest) with a custom service worker for Web Push
|
| Testing | Vitest, pgTAP (database tests), Playwright (two-browser E2E) |
There's no custom API server. That was a deliberate choice, and it shaped everything else.
Deep dive 1: game rules live in Postgres, not in React
A game for two people has a nasty property: both players must see the same truth. If XP, streaks or "goal complete" are computed on the client, two phones will disagree eventually (time zones, offline queues, race conditions). And anyone with DevTools can give themselves 10,000 XP.
So DuoDrop treats the database as the game server:
-
Clients can't write to game tables at all. Every write goes through a small set of RPCs (
log_water,claim_challenge,join_couple…). - Row Level Security means each person can only read their own couple's rows.
-
One function,
settle_couple(), recomputes the shared day, the streak and the badges after anything changes.
-- All writes to game tables go through the RPCs.
revoke insert, update, delete on public.water_logs, public.couples, public.xp_events,
public.challenge_claims, public.badge_unlocks, public.activity
from anon, authenticated;
create policy "members read logs" on public.water_logs
for select to authenticated using (public.is_member(couple_id));
Here's the core of log_water. Note the anti-farming rule: only water up to your own goal earns XP, so chugging 6 litres doesn't level you up.
create function public.log_water(p_ml int) returns uuid
language plpgsql security definer set search_path = public, pg_temp as $$
begin
if p_ml < 50 or p_ml > 2000 then raise exception 'Pick between 50 and 2000 ml'; end if;
perform 1 from couples where id = v_couple for update; -- serialize per couple
insert into water_logs (couple_id, user_id, amount_ml, local_date) ...;
insert into activity (couple_id, actor_id, kind, amount_ml, ref) ...;
-- Only water up to your goal earns XP (no farming).
v_eligible := least(p_ml, greatest(0, v_goal - v_before));
if v_eligible > 0 then
perform public.add_xp(v_couple, auth.uid(), 'log', greatest(5, round(v_eligible / 100.0)::int * 2), v_id);
end if;
perform public.settle_couple(v_couple); -- shared goal, streak, badges
return v_id;
end $$;
A few details I'm glad I got right early:
-
for updateon the couple row serializes concurrent logs from both partners, so the "shared goal complete +100 XP" bonus can't fire twice. - "Together %" caps each partner at their own goal. If one person drinks 4 litres, it doesn't cover for the other. The plant needs both of you.
-
Days follow the couple's time zone (
now() at time zone timezone), so midnight means midnight for them, not for a server in Frankfurt.
Same random challenge on server and client
The daily rotating challenge has to be identical on both phones and verifiable by the server when someone claims it. I used FNV-1a, a tiny hash, implemented twice:
// src/lib/random.ts
export function hashString(input: string): number {
let h = 0x811c9dc5
for (let i = 0; i < input.length; i++) {
h ^= input.charCodeAt(i)
h = Math.imul(h, 0x01000193)
}
return h >>> 0
}
-- the same hash in PL/pgSQL, used by claim_challenge() to verify claims
h := h # ascii(substr(p_text, i, 1));
h := (h * 16777619) % 4294967296;
The hash input is couple_id + ':' + date. Both the Vitest and pgTAP suites assert that the two implementations pick the same challenge, so they can never drift apart silently.
Tested like a backend
Because the rules are SQL, they're tested in SQL with pgTAP: 41 assertions covering RLS isolation, the two-member cap, XP caps, together %, streaks, badges and one-time claims.
select throws_ok($$ insert into water_logs (...) $$, '42501', null, 'no direct inserts into water_logs');
select is((select xp_total from couples), 50, 'water past the goal earns no XP');
select throws_ok($$ select join_couple(...) $$, 'P0001',
'This garden already has two gardeners', 'gardens are capped at two');
Deep dive 2: a procedural plant that's unique to every couple
The plant is the emotional core of the app, so it couldn't be a fixed set of PNGs. It's a procedurally generated SVG:
- Seeded by the couple's ID, so every couple grows their own tree, and it looks the same on both phones and every reload.
- Growth is a continuous number derived from couple XP (one unit per level), so it never jumps between "stages".
- Health comes from today's together %: a thirsty plant turns olive and droopy, and a thriving one is lush and blossoming. It never dies, because guilt is a bad retention strategy.
Determinism comes from a seeded PRNG (mulberry32) fed by the same FNV-1a hash:
export function generatePlant(seed: string): PlantSpec {
const rand = mulberry32(hashString(seed))
const r = (min: number, max: number) => between(rand, min, max)
// ...branches, leaves, canopy blobs, blossoms and fruit,
// each with an `appear` value: the growth level where it emerges
return { lean: r(-0.12, 0.12), bend: r(-0.22, 0.22), branches, leaves, blobs, blossoms, fruits }
}
Each dimension is a smooth curve over growth rather than a lookup table:
export const plantHeight = (g: number) =>
curve([[0, 30], [0.3, 52], [1.5, 104], [3, 140], [5, 178], [8, 206], [12, 222]], g)
/** 1 while the plant lives in its pot, 0 once it's a tree in the ground. */
export const potAmount = (g: number) => 1 - smoothstep(2.6, 3.3, g)
Then springs make every change feel physical. When your partner logs water, growth and health don't snap; they ease into place:
const gSpring = useSpring(growth, { stiffness: 26, damping: 14, mass: 1.1 })
const hSpring = useSpring(healthFrom(pct, yesterdayMissed), { stiffness: 50, damping: 18 })
For performance, the plant writes SVG attributes straight to refs on each animation frame instead of re-rendering React. It also pauses when it scrolls out of view and respects prefers-reduced-motion.

Three couples, three trees. Same code, different seeds.
Deep dive 3: realtime sync and web push (including iOS)
Realtime: one channel per couple
All the "moments" in DuoDrop (a partner's log, a goal hit, a badge, a level-up) are rows in a single activity table. The client subscribes to inserts for its couple, and RLS still applies to Realtime, so you can't eavesdrop on another couple by changing a filter.
client
.channel(`couple:${coupleId}`)
.on('postgres_changes',
{ event: 'INSERT', schema: 'public', table: 'activity', filter: `couple_id=eq.${coupleId}` },
(p) => onActivity(p.new as RawActivity))
.subscribe()
Your own taps animate optimistically with temporary IDs, and the server's answer reconciles them. Everything else replays as a moment: droplet flight, a toast, confetti, or a badge reveal. In my local recordings, a log shows up on the partner's phone in under a second.
Push: the database calls the Edge Function
Realtime only helps while the app is open. For everything else there's Web Push, and again the database drives it. An after insert trigger on activity posts to a Supabase Edge Function using pg_net, which is asynchronous so it never slows down the user's write:
create trigger activity_push after insert on public.activity
for each row execute function public.notify_activity(); -- net.http_post(...) to /send-push
select cron.schedule('duodrop-nudge', '5 * * * *', $$ select net.http_post(url := .../nudge ...) $$);
send-push decides who should hear about it ("Alex just drank 500ml 💦" goes to the other partner; "New badge 🏆" goes to both). It signs the message with VAPID keys and prunes dead subscriptions when a push service returns 404/410. The hourly nudge job only nudges between 09:00 and 21:00 in the couple's local time, at most twice per person per day. It fires when someone is well behind an even pace for the day, or late in the afternoon when the two of you are within 600 ml of finishing.
The service worker side is small:
self.addEventListener('push', (event) => {
const data = event.data?.json() ?? {}
event.waitUntil(self.registration.showNotification(data.title ?? 'DuoDrop', {
body: data.body, icon: '/icons/icon-192.png', badge: '/icons/badge-72.png',
tag: data.tag, // collapses repeats, e.g. several partner logs
data: { url: data.url ?? '/' },
}))
})
The iOS gotchas
Web Push on iPhone is real now, but with conditions that will cost you an afternoon if you don't know them:
- It only works for installed PWAs. Safari tabs can't receive push. The user must Add to Home Screen first (iOS/iPadOS 16.4+).
-
Permission must come from a user gesture. Calling
Notification.requestPermission()on page load fails silently. DuoDrop asks from a button in settings. -
navigator.serviceWorker.readycan hang in some edge cases, so the subscribe flow wraps it in a timeout and falls back gracefully. -
Use the
tagfield. Without it, a chatty partner can stack up a dozen notifications.
Testing a two-person app with one laptop
The hardest bugs in a multiplayer app only show up with two people. The Playwright suite opens two isolated browser contexts on a mobile viewport and plays a whole day:
const alex = await (await browser.newContext()).newPage()
const sam = await (await browser.newContext()).newPage()
await alex.getByRole('button', { name: 'Add 500 ml' }).click()
await expect(sam.getByText('Alex drank 500 ml — your plant just grew!')).toBeVisible()
It pairs them up, syncs live, finishes the shared goal, claims a challenge exactly once, and checks streaks and badges. Every GIF in this post came from a script built on that same approach.
For solo development, there's also a tiny CLI that acts as your partner against the local Supabase stack (it refuses to run against production):
npm run partner -- log 300 # "Sam" drinks; watch your screen react
It's mobile-first, but tablets and desktops get their own layouts (press W to log water).
What I learned
- Put shared truth in one place. Moving the game rules into Postgres removed a whole class of "why does my partner see something different?" bugs, and made cheating pointless.
- Design for the second person. Every feature started from "what does the other phone see?": the invite preview, the partner toast, the shared confetti.
- Make progress visible and forgiving. A plant that droops but never dies motivates far better than a red "0%".
- Test the multiplayer path end to end. Two browser contexts in Playwright caught bugs that unit tests never would.
What's next
- Send a cheer. A one-tap “💧 drink with me” push to your partner. The “Cheerleader” challenge already hints at it.
- Unlockable garden cosmetics. The Rewards screen promises to “unlock garden goodies”, so new pots, plant species and seasonal skins would make that real.
- Smarter nudges. Today’s reminders assume an even pace from 8:00 to 21:00. Next: learn each person’s own rhythm from their history and nudge at the times they usually drink.
Try it with your person: duodrop.online 🌱 (on iPhone, add it to your Home Screen to get notifications).
If you're building with React, Supabase or PWAs, or you're hiring for someone who enjoys owning a product end to end, from database rules to the last spring animation, connect with me on LinkedIn. I'd love to hear what you think, and the comments are open for questions about any part of the stack.







Top comments (2)
Really clean setup, and testing RLS isolation in pgTAP is a nice touch. Since the RPCs are security definer, RLS doesn't apply inside them, so each function is the real gate. Two cheap pgTAP cases: call each RPC signed out (as anon) and as a user who isn't in that couple, and check both fail. Also worth knowing: Postgres grants EXECUTE on new functions to PUBLIC by default, so
select has_function_privilege('anon', 'public.log_water(int)', 'execute')shows whether signed-out visitors can call it. If they never need to, revoke execute from public and anon, then grant it to authenticated.Hi @patchandship
Spot on! That is such a clean security catch, and testing RLS isolation with
pgTAPthat way is brilliant. Thanks for sharing this tip—it's a crucial detail for locking down security definer functions properly!