Advanced

Hot Takes

A gamified survey platform where 40+ interactive game templates capture behavioral signals — not just answers — across mobile, web, and an admin console.

Lead Engineer
2024 – 2025
3 people
2025
  • React Native
  • TypeScript
  • Supabase
  • PostgreSQL
  • Zustand
  • Expo

Problem

Surveys have a completion problem. The moment a user sees a wall of radio buttons and a progress bar reading "1 of 24," most of them bail — and the ones who stay start straight-lining answers to get to the end. The data you collect from a form is thin: what the user picked, and nothing about how they got there. For a brand trying to understand preference and hesitation, a straight-lined form is barely better than no data.

Hot Takes reframes the survey as a game. Instead of a form field, a question becomes a swipe stack, an emoji rating, a ranking board, or a head-to-head knockout — 40+ interactive templates. The user plays; the platform records not just the choice but the behavior around it: how long they hesitated, how fast they swiped, how many times they changed their mind. That behavioral layer is the actual product.

Target user: two distinct audiences with opposed needs. Respondents (often on cheap Android phones, on mobile data) want something fast and fun. Brand admins want a dashboard that turns thousands of plays into targeting segments and behavioral insight.

Constraints:

  • Must feel like a 60fps game, not a web form, on mid-range Android — the device class most respondents actually use.
  • Must run cross-platform — the same game templates on iOS, Android, and web, without forking the interaction logic per platform.
  • Must capture rich behavioral telemetry per interaction without making the UI stutter or the network payload balloon.
  • Multi-tenant from day one: brands must never see each other's responses, and PII has to be strippable on export.

Business goal: dramatically higher completion and richer data than classic survey tools, delivered as a product a non-technical brand admin can run alone.

Architecture

Hot Takes is a three-surface system over a single Supabase backend: a React Native (Expo) respondent app, a React admin console, and a Postgres + Edge Functions backend that is the single source of truth.

  • Respondent app (React Native + Expo). Renders the game templates and the survey runtime. A template is a self-contained component that takes a question config and emits both an answer and a stream of behavioral events. Shared interaction logic runs identically on iOS, Android, and web.
  • Admin console (React). Survey builder, targeting rules, live analytics, and export. Reads the same tables through Row Level Security so admins only ever see their tenant's data.
  • Supabase Postgres. The system of record. surveys, survey_responses (with session_id, device_info, network_info, referral_source, campaign_id metadata), behavioral event tables, users with a role column, plus export_jobs and audit_logs. Row Level Security enforces tenancy and role at the database, not in app code.
  • Edge Functions (Deno). Server-side work that must not run on the client: send-email and process-email-queue for transactional email via Resend, gated by an authenticated admin-role check before anything sends.
  • Client state (Zustand). The survey runtime is a state machine — current question, in-progress answer, buffered behavioral events, sync status — kept in a lightweight Zustand store rather than Context, so a swipe doesn't re-render the whole screen.

Data flow

  1. Admin builds a survey in the console; targeting rules and question configs persist to Postgres.
  2. A respondent opens a survey; the app pulls the config and renders the matching game template per question.
  3. As the user plays, each interaction emits an answer and behavioral events (dwell time, swipe velocity, hesitation, changes of mind) into a client-side buffer.
  4. On completion, the buffered answer + events are written in one batch to survey_responses and the event tables; RLS validates tenancy on write.
  5. The admin console reads aggregated behavioral data in real time via Supabase subscriptions; exports run as async export_jobs with PII removal applied.

Engineering Decisions

One React Native + TypeScript codebase for iOS, Android, and web — not three. The risky, valuable part of Hot Takes is the interaction logic inside each of 40+ game templates (gesture handling, animation, event capture). Writing that once in TypeScript and running it everywhere means a swipe behaves identically on every surface, and a bug is fixed in one place. Tradeoff: hitting a true 60fps game feel in React Native took real work (Reanimated on the UI thread, gesture handlers, aggressive memoization) that a native-per-platform build might have gotten "for free" — at 3x the code.

Supabase over a bespoke backend. A small team shipping multi-tenant auth, Postgres, real-time subscriptions, storage, and Edge Functions cannot also hand-roll all of that. Supabase gave us Row Level Security as the tenancy boundary — the single most important decision for a platform where a leak across brands is catastrophic. Tradeoff: RLS policies are subtle and easy to get wrong, so they are tested as first-class code (see below) rather than trusted by inspection.

Enforce tenancy and role in the database, never in app code. Every multi-tenant table has RLS policies keyed on auth.uid() and the user's role. The client cannot over-fetch even with a crafted query, because the policy runs inside Postgres. The send-email Edge Function independently re-checks role IN ('super_admin', 'admin') before sending — defense in depth, because "the client wouldn't call this" is not a security model. Tradeoff: policies add query cost and cognitive overhead, but the alternative is trusting the client, which is not an option for cross-brand data.

Behavioral events are buffered and batch-written, not streamed per gesture. A single question can emit a dozen events. Writing each one as it happens would hammer the network and drain battery on exactly the cheap devices we target. Instead events buffer in the Zustand store and flush in one batched write at question or survey boundaries. Tradeoff: a hard crash mid-question can lose the in-flight buffer — acceptable for behavioral telemetry (best-effort by nature), and the answer itself is committed at the boundary regardless.

Zustand for the runtime, not Context or Redux. The survey runtime updates on every gesture frame; routing that through React Context would re-render the whole tree, and Redux's boilerplate wasn't worth it for a store this focused. Zustand's selector subscriptions mean only the components reading a given slice re-render. Tradeoff: less structure than Redux, but the store surface is small and the performance win on-device is decisive.

React Compiler on (reactCompiler: true). The web surface runs with the React Compiler enabled so memoization is largely automatic, which matters for a UI that animates continuously. Tradeoff: it's new, so we kept an eye on edge cases — but the reduction in manual useMemo/useCallback noise was worth it.

Source Code Walkthrough

A game template is a pure contract: config in, answer + behavior out

Every one of the 40+ templates implements the same interface. This is what makes the runtime template-agnostic and the platform able to add a game without touching the engine.

// runtime/template.ts
// Why: the survey runtime must drive 40+ wildly different games (swipe, rank,
// rate, allocate) without knowing their internals. Every template is reduced to
// one contract — render from a config, emit an answer, and stream behavioral
// events. New games plug in without changing the runtime.
export interface GameTemplate<TConfig, TAnswer> {
  kind: TemplateKind;
  render(props: {
    config: TConfig;
    onEvent: (e: BehavioralEvent) => void;      // dwell, swipe velocity, undo…
    onAnswer: (answer: TAnswer) => void;         // final committed choice
  }): JSX.Element;
}
 
// A behavioral event is deliberately small and uniform so it batches cheaply.
export type BehavioralEvent =
  | { t: 'dwell'; ms: number }
  | { t: 'swipe'; velocity: number; direction: 'left' | 'right' }
  | { t: 'change'; from: string; to: string }   // user changed their mind
  | { t: 'hesitation'; ms: number };

Why this way: collapsing every game to (config) → (answer, events[]) means the runtime, the buffering layer, and the analytics pipeline are all written once and never grow with the template count. Improvements: codegen the config types from the survey-builder schema so a malformed config is a compile error, not a runtime one.

Buffering behavioral events off the network hot path

Events are frequent and low-value individually; the batch is what matters. This store buffers them and flushes at boundaries.

// store/runtime.ts — Zustand
// Why: a single question can emit a dozen behavioral events. Writing each to
// Supabase as it fires would saturate the network and drain battery on the cheap
// Android devices most respondents use. Buffer in memory, flush in one batch at
// the question boundary.
export const useRuntime = create<RuntimeState>((set, get) => ({
  answers: {},
  eventBuffer: [],
 
  recordEvent: (e) =>
    set((s) => ({ eventBuffer: [...s.eventBuffer, e] })),
 
  commitQuestion: async (questionId, answer) => {
    const { eventBuffer } = get();
    // One batched write: the answer + all buffered behavior for this question.
    await ingestResponse({ questionId, answer, events: eventBuffer });
    set({ eventBuffer: [] }); // reset buffer for the next question
  },
}));

Performance considerations: selector subscriptions mean a gesture updating eventBuffer doesn't re-render components reading answers. Batching turns a dozen writes into one. Edge cases / improvements: a crash mid-question drops the in-flight buffer — acceptable for telemetry; a durable local queue (SQLite/MMKV) would make it crash-safe if we decide the behavioral data is worth that.

Row Level Security is the tenancy boundary — in the database

Tenancy isn't enforced by careful client queries; it's enforced by Postgres refusing to return rows the caller isn't allowed to see.

-- migrations: survey_responses tenancy
-- Why: a brand must NEVER read another brand's responses. Enforcing this in the
-- client is one forgotten .eq() away from a cross-tenant leak. The policy runs
-- inside Postgres, so even a crafted query returns zero unauthorized rows.
ALTER TABLE survey_responses ENABLE ROW LEVEL SECURITY;
 
CREATE POLICY "Admins read only their own surveys' responses"
ON survey_responses FOR SELECT TO authenticated
USING (
  EXISTS (
    SELECT 1 FROM surveys s
    WHERE s.id = survey_responses.survey_id
      AND s.owner_id = auth.uid()
  )
);

Why this way: the security boundary lives where the data lives. Edge cases / improvements: EXISTS subqueries in policies add per-row cost; for high-volume reads we denormalize owner_id onto survey_responses so the policy is a direct column compare instead of a join.

Defense in depth: re-checking role server-side before sending email

The client shouldn't be able to trigger a broadcast just because it holds a session. The Edge Function re-verifies the caller is an admin.

// supabase/functions/send-email/index.ts
// Why: "only admins can reach this UI" is not a security control. The Edge
// Function independently verifies the caller's role before Resend is ever
// called — the client is never trusted with authorization.
const { data: userData } = await supabaseClient
  .from('users').select('role').eq('id', user.id).single();
 
if (!userData || !['super_admin', 'admin'].includes(userData.role)) {
  return json({ success: false, error: 'Admin access required' }, 403);
}
// …only now do we POST to the Resend API.

Why this way: authorization is checked at the boundary that performs the privileged action, not upstream where it can be bypassed. Improvements: move the role check into a shared middleware so every privileged function inherits it instead of copy-pasting.

Keeping a swipe on the UI thread with Reanimated

The naive version drove card animation from React state and stuttered on mid-range Android. Moving the gesture to the UI thread fixed the frame budget.

// templates/SwipeStack.tsx
// Why: driving the card's position from React state re-renders on every frame
// and misses the 16ms budget on cheap devices. A Reanimated shared value keeps
// the drag on the UI thread; React only hears about the *result* (a committed
// swipe), and the hesitation timer feeds the behavioral buffer.
const translateX = useSharedValue(0);
const gesture = Gesture.Pan()
  .onUpdate((e) => { translateX.value = e.translationX; })     // UI thread, no re-render
  .onEnd((e) => {
    if (Math.abs(e.translationX) > SWIPE_THRESHOLD) {
      runOnJS(commitSwipe)(e.translationX > 0 ? 'right' : 'left', e.velocityX);
    } else {
      translateX.value = withSpring(0);                        // snap back
    }
  });

Performance considerations: the drag never touches the JS thread, so a continuous swipe stays at 60fps; runOnJS crosses back only once, on commit. This is what took template frame time from ~28ms to ~11ms. Improvements: share the swipe physics across all gesture-based templates instead of re-implementing per template.

Technical Challenges

  • 60fps games on cheap Android. The whole premise dies if it stutters. Solved with Reanimated (gestures/animation on the UI thread), memoization, and the React Compiler — not by rendering from React state.
  • Multi-tenant isolation. Cross-brand data leakage is existential. Solved by making RLS the boundary and testing policies as code, with a server-side role re-check for privileged actions.
  • Behavioral telemetry without jank or payload bloat. A dozen events per question, on mobile data, on weak devices. Solved with in-memory buffering and batched boundary writes rather than per-gesture network calls.
  • One interaction model, 40+ games, three platforms. Solved with the single GameTemplate contract so the runtime never grows with the catalog.
  • Async export with PII stripping. Large exports can't block a request. Solved with export_jobs processed asynchronously, pii_removal_applied by default, and an expires_at on generated files.

Code Quality & Testing

Type safety. TypeScript in strict mode across all three surfaces. The GameTemplate<TConfig, TAnswer> generic is the backbone: every template is statically bound to its config and answer shape, so the runtime can drive 40+ games with zero any at the seams. Behavioral events are a discriminated union, so a handler that forgets the 'change' variant fails to compile rather than silently dropping data. Supabase row types are generated from the schema (supabase gen types) so a renamed column surfaces as a type error across the app instead of a runtime undefined.

Testing strategy.

  • RLS policy tests are non-negotiable. Because tenancy lives in the database, the tests live there too: a suite spins up seeded users across two tenants and asserts that tenant A's session reads zero of tenant B's survey_responses, that non-admins are refused on privileged tables, and that the email function's role gate rejects a member token. These are the tests that let me sleep.
  • Property-based tests (fast-check) target the behavioral pipeline invariants: buffering then flushing N events must ingest exactly N events (no loss, no duplication) for any event sequence; the equatorial contract of every template — an answer is only emitted after at least one interaction — holds for arbitrary gesture streams; and a commitQuestion always leaves the buffer empty. fast-check shrinks any violation to the minimal event sequence.
  • Template contract tests. Every game template is run against a shared harness asserting it honors the GameTemplate interface — emits a well-formed answer, emits only valid BehavioralEvents, and never calls onAnswer twice. Adding a game means adding a config fixture; the harness does the rest.
  • Migration tests. Each SQL migration is applied to an ephemeral Postgres in CI to catch a broken policy or column before it reaches staging.

Error handling & observability. The Edge Functions return structured { success, error } envelopes with correct HTTP status codes (401/403/404/500) and log the failing branch — the send-email function distinguishes an RLS denial from a missing user from a Resend API failure, so an on-call admin sees why an email didn't send, not just that it didn't. On the client, ingest failures are surfaced as a retryable state in the Zustand store rather than a thrown exception, so a flaky mobile connection degrades to "syncing…" instead of a lost survey.

Code organization. Clear surface boundaries: runtime/ (template contract + survey state machine), templates/ (the 40+ games, each self-contained), store/ (Zustand), and the Supabase layer (migrations + Edge Functions) as the backend contract. Templates depend on runtime/ but never on each other, so the catalog scales without coupling. Shared UI primitives (the neo-brutalist card, shadow, and border components) are factored out so all 40+ games share one visual language.

Enforced in CI. tsc --noEmit (strict) on every surface, ESLint (eslint-config-next + RN rules), the RLS policy suite, the fast-check property suite, the template contract harness, and migration application against a throwaway Postgres — all on every push.

Benchmarks

The game format lifts survey completion from ~32% (classic form) to ~78% — the headline result, and the reason the behavioral layer even has data to work with. Each completed response carries ~12 behavioral events (dwell, swipe velocity, hesitation, changes of mind) beyond the answer itself. Moving gestures onto the UI thread with Reanimated cut mid-range Android template frame time from ~28ms to ~11ms, inside the 60fps budget. Response ingest writes land at ~90ms p95 including the RLS tenancy check. See the charts above.

Survey completion rate — game format vs. classic form (%)
12events
Game template render frame time (mid-range Android) (ms)
90ms

Lessons Learned

  • What succeeded: making RLS the tenancy boundary. Pushing isolation into Postgres — and testing the policies as code — is why multi-tenant data never leaked, even as the client grew.
  • What failed first: the initial swipe animation ran off React state and stuttered badly on cheap Android. Reanimated on the UI thread was the fix, and it reset how every gesture template was built afterward.
  • What I would redesign: durable, crash-safe buffering of behavioral events (local SQLite/MMKV queue) from day one instead of an in-memory buffer, so a mid-question crash loses nothing.
  • Remaining tech debt: the server-side role check is copy-pasted across Edge Functions and should be shared middleware; some RLS policies use EXISTS joins that want a denormalized owner_id for read-heavy paths.
  • Future improvements: codegen template config types from the survey-builder schema, and a shared gesture-physics module so every swipe-like game inherits the same feel.

Media

  • Screen recording of the swipe-stack and ranking templates running at 60fps on a mid-range Android device.
  • Admin console showing live behavioral analytics — hesitation and swipe-velocity distributions per question.
  • Diagram of the respondent app → buffered events → batched ingest → RLS-guarded Postgres → real-time admin read pipeline.
  • Product: hottakes.club
  • Internal docs cover the game-template contract, the RLS policy model, and the behavioral-event schema.

Build Logs

  • Requirements & planning. Reframed the survey as a game to fix completion, and decided the behavioral layer — not just answers — was the product.
  • Architecture. Three surfaces (RN respondent app, React admin, Supabase backend) over one Postgres system of record, with RLS as the tenancy boundary.
  • Implementation. Built the GameTemplate contract and 40+ games, the Zustand survey runtime with buffered behavioral events, RLS policies, and Deno Edge Functions for email.
  • Testing. RLS policy suite across tenants, fast-check property tests on the event pipeline, a template contract harness, and migration tests in CI.
  • Deployment. Expo builds for iOS/Android, web via Next.js, Supabase-hosted Postgres and Edge Functions.
  • Monitoring. On-device frame profiling, structured Edge Function logs, and audit logging of admin actions.
  • Retrospective. Pushing tenancy into the database and gestures onto the UI thread were the two decisions that made a fun, fast, safe multi-tenant game platform possible.