Single-page globe visualizing 6-degrees-of-separation referral chains. Anonymous, fingerprint-based, viral growth experiment.
Visitor lands → sees a living globe of human connections (arcs = who-brought-whom). Gets a personal reach + depth score, climbs a leaderboard, shares their link everywhere. When landing from a referral, the referrer's stats are shown first as the hook ("Maya brought 1,247 people across 38 countries — continue the chain").
Goal = viral growth. Every UI decision optimizes for re-share.
Fingerprints drift — never rely on them alone. Store 3 independent IDs; resolve node by best available.
- localId — random UUID generated client-side, stored in
localStorage+ cookie + mirrored in DB. Primary stable handle; survives fingerprint drift. - fingerprint — FingerprintJS visitorId. Fuzzy fallback to re-link a lost localId (cleared storage / new browser).
- accountId — email-verified. Strongest. Merges multiple (localId, fingerprint) pairs cross-device.
Resolution order on visit: cookie/localStorage localId → else fingerprint match (re-issue localId, link to found node) → else create new node.
Node tiers (priority verified > human > ephemeral > non-human):
-
Incognito / private window → detect (storage-quota probe, FS API). Node
ephemeral=true. Banner: "Private window — your chain won't reliably link back. Verify email to keep it." Dimmed on globe until claimed. -
Bot / crawler / link-inspector → server classifies: UA + FingerprintJS Pro bot signal + Vercel BotID.
class ∈ {human, bot, crawler, preview}.preview= Slack/Discord/WhatsApp unfurlers. Non-human nodes excluded from reach + leaderboard, dimmed or not created. -
Verified (email) → ranked higher, brighter, badge. Persistent UI nudge for cross-device linking (see §2 email nudge).
-
Optional magic-link email (cross-device linking only). Email never shown publicly. The account becomes the identity; device nodes become its leaves (see §5.5 merge rules).
-
Partner angle: FingerprintJS sponsorship/discount (Pro for server-side signals + bot resistance).
Cross-device linking is the value prop → drive conversion via loss-aversion, but stay respectful:
- Persistent subtle banner always visible ("Link your devices · keep your chain forever").
- Milestone hard-prompt at reach milestones ("You reached 100! Verify to never lose this.").
- Contextual urgent prompt whenever link is actually at risk (incognito / ephemeral / cleared storage).
- Each node has a short code → share URL
worldhello.io/<code>. - Landing with
?ref=<code>(or/<code>) sets referrer edge on first node creation only. referrer_idis write-once. Never rewritten. This is the invariant the whole 6-degrees integrity rests on.- Self/loop/duplicate-IP-spam guarded (see §6).
depth= distance from a root (no referrer). Max chain depth is the headline 6-degrees metric.
Scenario: Alice refers Bob → Bob shares → Alice clicks Bob's link.
Rule: chains are immutable. A referee never inherits its referrer's chain, and parentage is never re-written. Inheritance/re-parenting would splice chains and make depth (the 6-degrees metric) meaningless.
- Known node clicks any
?ref(Alice already exists via localId/fingerprint/account): no new node, no re-parent. Re-parenting Alice under Bob = cycle → rejected by ancestry guard.- UX (recognize + celebrate): "You started this chain! Bob is in your tree (depth 2)." Pull up Alice's own dashboard with Bob highlighted in her subtree. Reinforces ownership → re-share.
- New device clicks
?ref: new node created under referrer. Normal join flow. - The only edges that ever change are at account merge (§5.5) — and even then device
referrer_idrows stay; only per-account metric aggregation changes.
- Land from referral → referrer's stat card front-and-center: avatar-glyph, reach, countries, depth, their position on globe pulsing.
- CTA: "Join the chain" → creates your node under them.
- Your own score panel appears + your share tools.
- Reach = total descendants (whole subtree size).
- Direct = immediate children.
- Depth = max depth of your subtree (how far your link traveled).
- Countries = distinct countries in subtree.
- Rank = global leaderboard position by reach.
- Global top-N by reach. Also "rising" (most growth last 24h).
- Anti-hopeless design: show your local rank + percentile even if not top-N, and a "biggest single chain you started" stat so late joiners still have a win.
Tech: react-three-fiber + three.js, custom shaders. Chosen for art-quality arcs/particles + full LOD control to scale to millions of nodes.
- Nodes = points on sphere at geo position (coarse IP geo, jittered).
- Edges = great-circle arcs (referrer → referee), animated draw-on.
- LOD / aggregation: at scale, server returns binned/clustered node density per viewport zoom (H3 or geohash buckets), not raw millions. Individual arcs only rendered for: your lineage, the active referrer chain, and a sampled "live" stream of recent joins.
- Your path highlighted: trace root→you lit up; your subtree glows.
- Live joins: websocket/SSE stream of new nodes → arc animates in ("someone in Lagos just joined").
- Perf budget: instanced meshes for points, merged geometry for arcs, GPU picking. Mobile fallback = reduced particle count + lower-res sphere.
- Default: coarse IP geo, city-level, jittered ±few km. Server-side (Vercel request geo / IP lookup). Never prompts.
- Opt-in precise: browser geolocation, only if user explicitly clicks "pin me precisely." Never asked by default.
- Country-level fallback when IP geo unavailable (random point in country).
Start: Neon Postgres only. Recursive CTE for subtree/ancestry. Redis added later when counters get hot (§7).
-- nodes
id bigint PK
code text unique -- short share code
referrer_id bigint null FK nodes(id) -- WRITE-ONCE, never updated
local_id text -- client UUID (localStorage+cookie), indexed, primary stable handle
fingerprint text -- FingerprintJS visitorId (hashed), fuzzy re-link fallback
account_id bigint null FK accounts(id) -- set if email-linked
class text default 'human' -- human|bot|crawler|preview
ephemeral boolean default false -- incognito / unclaimed private-window
verified boolean default false -- node belongs to a verified account
country text -- ISO
lat, lng double precision -- jittered display coords
geo_precise boolean default false
created_at timestamptz
path ltree -- materialized path (root.../id) for fast subtree
-- accounts (optional magic-link)
id bigint PK
email_hash text unique
created_at timestamptz
-- node_signals (audit + re-link history; many per node)
node_id bigint FK
fingerprint text
ip_hash text
ua text
botid_verdict text
incognito_guess boolean
seen_at timestamptz
-- cached_metrics (denormalized, refreshed; per node AND per account)
node_id bigint PK FK
reach int -- subtree count (human-class only)
direct int
max_depth int
countries int
updated_at timestamptz- Subtree (reach/descendants):
ltreepath <@ :myPath— O(subtree), indexed GiST. Avoids deep recursive CTE cost. - Ancestry (your path to root): parse
pathsegments → singleWHERE id = ANY(...). - Leaderboard:
ORDER BY cached_metrics.reach DESC LIMIT N, indexed. - Metric updates: on new node insert, increment ancestors'
reach(walkpathids, single UPDATE ... WHERE id = ANY). Cheap because path is materialized.
Materialized path = O(1) ancestor list + index-only subtree scan. Recursive CTE kept as fallback/verification but ltree is the hot path. Both available.
- Reach / leaderboard count only
class='human'nodes. Bot/crawler/preview excluded. - Ephemeral nodes count toward their referrer's reach but render dimmed on globe until claimed (verified).
- Verified nodes get a leaderboard rank boost + brighter globe render.
When a verified email proves two device-nodes are one person (e.g. Alice-laptop + Alice-phone):
- Device
referrer_idrows are NOT rewritten. The account becomes the identity; nodes become its devices. - Per-account reach/depth = union of its nodes' subtrees, deduped, with internal edges removed (if Alice-phone was referred by Alice-laptop, that self-edge doesn't count as reach).
- Cycle-safety: if a merge would make the account an ancestor of itself, the internal edge is dropped from metrics only (row stays for audit). Graph stays a DAG-of-accounts.
- Result: device nodes are immutable leaves of truth; the account aggregates. Sidesteps every re-parenting paradox.
- Identity layering (localId + fingerprint + account) hardens node resolution against fingerprint drift and spoofing.
- Rate-limit by
localId(primary),fingerprint(secondary),referrer_id(fan-out). NOT by IP as a hard gate — see §6.6. - Self-referral / cycle rejection (referrer can't be in your ancestry) — also blocks the referrer-clicks-referee loop re-parent.
- Node classification at create time: UA + FingerprintJS Pro bot signal + Vercel BotID →
human|bot|crawler|preview. Non-human excluded from metrics, dimmed/uncreated. Link-unfurlers (preview) never mint human nodes. - Incognito detection →
ephemeral, deprioritized until verified. - Bot resistance via FingerprintJS Pro server signals (the sponsorship angle pays off here).
- Suspicious-velocity dampening: a referrer minting 1000s of identical-fingerprint children gets their reach discounted in leaderboard.
- Vercel BotID on the create-node endpoint.
Viral site = write-amplification target. Each malicious node insert also walks ancestors to bump metrics → one insert fans out. Block before the row exists. Defend in layers, cheapest/earliest first.
- Vercel WAF rate limiting on
POST /api/node— per-IP request cap, generous (see §6.6). Rejects at edge, zero DB hit. - Vercel BotID on create endpoint — kills headless/automated traffic pre-insert.
- Vercel Firewall Attack Mode toggle for active assault.
- Turnstile / proof-of-work before node creation — invisible challenge, steps up to interactive under load. Mass minting becomes CPU-expensive for attacker, free for real users.
- Layered rate-limit counters — localId, fingerprint, referrer_id (+ IP only as a velocity multiplier, §6.6). Redis pulled forward to v1 for this (§7) — Postgres rate state contends under the exact attack being defended.
- Per-referrer mint cap — one referrer can't spawn >N children/hour. Caps the farm-your-own-link fan-out attack.
- Global circuit breaker — system-wide inserts/sec over threshold → flip to challenge-everyone / queue mode. Protects DB from total write saturation regardless of source.
- Async write queue (defer) — node creation enqueues; worker drains at bounded rate. Caps DB write throughput to a survivable ceiling; attacker just fills a shed-able queue.
- Neon pooled endpoint (PgBouncer) — hard connection cap so a flood can't exhaust the pool and starve legit reads. Mandatory on serverless (fns × concurrency = connection storm).
statement_timeout— no single query hangs a connection.- Bounded fan-out write — metric bump = ONE
UPDATE ... WHERE id = ANY(path_ids), not N queries. One insert stays O(1) round-trips regardless of depth. - Cap chain depth (e.g. 50) — stops pathological linear chains inflating
ltreepaths + ancestor-walk cost. 6-degrees → real depth is small anyway. - Partial indexes (
WHERE class='human') — bot rows cheap to insert, never bloat hot indexes. - Unique constraints + idempotency key —
unique(local_id),unique(referrer_id, fingerprint), client idempotency key → retries/dupes collapse to upsert no-ops, not new rows. DB-level idempotency.
- Don't persist non-human nodes as real rows — bot/crawler/preview dropped or written to cheap append-only
suspect_events, nevernodes. Hot table stays small. - Ephemeral TTL reaper — incognito/unclaimed nodes that never verify + never refer → reaped after N days.
- Row-size discipline —
nodesnarrow; UA/IP/audit pushed tonode_signals(append-only, time-partitioned, droppable). - Neon autoscaling + spend cap — compute ceiling so an attack can't run an unbounded bill (cost-DoS is the real serverless-DB risk). Alarm on insert-rate + storage-growth anomalies.
- Shed, don't crash — under breaker return "join queued / try later". Site stays up, attacker gets nothing persisted.
- Reads off cache — globe/leaderboard served from snapshot tiles, so write-side flooding never degrades the read experience.
- Async/batched metric bumps — reach updates eventually-consistent; removes per-insert synchronous ancestor-write under load.
| Must-have v1 | Defer until scale/attack |
|---|---|
| WAF per-IP rate limit | async write queue |
| BotID on create | global circuit breaker |
| Redis rate-counters | challenge step-up |
Neon pooled conn + statement_timeout |
ephemeral TTL reaper |
| Single bounded ancestor-UPDATE | partitioned signals table |
| Depth cap + unique/idempotency guards | Neon spend cap alarms |
Cheapest wins, biggest payoff: WAF per-IP rate-limit (zero code) + idempotency unique constraint (DB refuses dupes for free). Start there.
Many real users share one IP — IP ≠ user:
- Home/office NAT — whole household/company on one public IP.
- Carrier-grade NAT (CGNAT) — mobile carriers put thousands of phone users behind one IP. Huge on mobile.
- Corporate / university — entire campus egresses one IP.
- Public wifi — café, airport, conference: many distinct people, one IP. This is the in-person viral-share scenario — your target audience.
- VPN / proxy / Tor — many users funneled through shared exit IPs.
Rules:
- Never use IP as an identity key. localId / fingerprint / account are identity. Collapsing users by IP = catastrophic.
- Never hard-block on per-IP node count. A conference room or CGNAT'd city block of real joiners would get killed — exactly the viral moment you want.
- IP = velocity multiplier in a risk score, plus a very high global per-IP ceiling that only trips on script-grade volume.
- Suspicious = high velocity AND same fingerprint AND same UA AND no localId variance. One IP + 500 distinct fingerprints = conference (good). One IP + 1 fingerprint + 500 inserts = script (bad).
- Per-/24 subnet limits even looser than per-IP (CGNAT spreads users across ranges).
- Real abuse gates = fingerprint + BotID + PoW + localId velocity. IP only nudges confidence.
v1 exception — rate-limit counters use Upstash Redis from day one. Postgres rate state contends under exactly the DoS attack §6.5 defends against; a fixed-window counter in Redis is O(1) and keeps rate-limiting off the primary DB. This is the only Redis use at launch.
Everything else stays Postgres-only until a trigger fires (leaderboard reads hot / live-join fanout heavy / metric writes contend):
- Redis sorted set for leaderboard (ZADD reach) → O(log n) rank, no table scan.
- Redis counters for reach (write-through, Postgres = source of truth).
- Redis pub/sub for live-join SSE fanout.
- Globe density tiles cached (Vercel Runtime Cache / Redis) per zoom bucket.
v1 ships Postgres-for-graph + Redis-for-rate-counters; code isolates metric reads behind a metrics service interface so the rest of Redis drops in without touching callers.
- Next.js (App Router) on Vercel, Fluid Compute.
- react-three-fiber + three.js + drei globe.
- Neon Postgres (Vercel Marketplace) + ltree.
- FingerprintJS client SDK (+ Pro server signals later).
- SSE (Vercel Functions streaming) for live joins; swap to Redis pub/sub at scale.
- Tailwind + shadcn/ui for panels/cards.
- Share: Web Share API + per-platform intent links (X, WhatsApp, Telegram, FB, LinkedIn, copy, QR).
┌─────────────────────────────────────────────┐
│ [worldhello.io] [globe controls] │
│ │
│ ╭──────────────────╮ │
│ │ 3D GLOBE │ ← arcs, │
│ │ (fullscreen bg) │ your path │
│ ╰──────────────────╯ glowing │
│ │
│ ┌── referrer hook card (if ?ref) ──┐ │
│ │ Maya · reach 1,247 · 38 countries │ │
│ └───────────────────────────────────┘ │
│ │
│ ┌── YOUR SCORE ──┐ ┌── SHARE ──┐ ┌─LEADER─┐ │
│ │ reach depth ct │ │ X WA TG… │ │ top 10 │ │
│ └────────────────┘ └───────────┘ └────────┘ │
│ "someone in Lagos joined" ticker │
└─────────────────────────────────────────────┘
- Next.js + Neon + schema (ltree) + node-create API (
?refhandling). - FingerprintJS identity + node resolution.
- Globe (r3f) with seeded data → nodes + your-path arcs + LOD density.
- Score panel + metrics queries (ltree subtree).
- Share tools (Web Share + intents + QR).
- Referrer hook card on referral landing.
- Leaderboard (Postgres ORDER BY).
- Live-join SSE ticker.
- Magic-link email (cross-device merge).
- Anti-abuse (BotID, rate limit, cycle guard, velocity dampening).
- Domain/short-code length (collision vs. brevity).
- Email provider for magic link (Resend on Vercel).
- Privacy/legal copy for fingerprinting (consent banner? GDPR posture given anonymity).