The iOS & Android app that turns raw water polo match data into a tactical edge.
Player stats · Penalty shot maps · Animated tactics · Live match tracking
Live site · Request early access · Blog
Water polo moves fast — decisions shouldn't rely on memory. Kouci gives coaches and analysts:
- Roster management — player profiles, positions and caps, tracked across the season.
- Penalty shot intelligence — every 5-metre plotted on the goal mouth: placement, outcome, keeper tendencies.
- Animated tactics — draw a set play once, animate it in 3D, export it as video/GIF and send it straight to the squad.
- Live match stats — log events in real time; season numbers are ready at the final whistle.
Built for head coaches, analysts, clubs and federations who demand precision.
This is Kouci's public landing site — dark, sporty, and built around an interactive 3D pool scene (the ball and goal above are rendered live in WebGL, not a video).
- Vite + React + TypeScript, statically generated with vite-react-ssg (every route ships real HTML + per-page meta and JSON-LD)
- Three.js via React Three Fiber + @react-three/drei
- GSAP (ScrollTrigger) + Lenis smooth scroll; maath easing for the camera
- Tailwind CSS for all 2D UI · Vercel Analytics with a signup conversion event
The hero's cinematic grade is done without a post-processing library (to keep the bundle light and hold 60fps): ACES Filmic tone mapping, MSAA, a faux bloom sprite and a CSS vignette. WebGL 2 with automatic fallback to WebGL 1 — WebGPU is intentionally not assumed.
npm install # also installs the Husky pre-commit hook
npm run dev # start the dev server (http://localhost:5173)
npm run build # sitemap + type-check + static-generate the site → dist/
npm run preview # preview the production build locally
npm run check # type-check + lint + format check (what CI runs)Node 18+ recommended.
src/
├─ App.tsx # routes (landing, /blog, /blog/:slug, /privacy)
├─ RootLayout.tsx # navbar/footer shell shared by every route
├─ index.css # Tailwind layers + palette CSS variables
├─ lib/
│ ├─ theme.ts # palette as TS values (for Three.js)
│ ├─ site.ts # canonical URL + socials (SEO)
│ ├─ blog.ts # markdown posts → typed data (build time)
│ └─ scrollStore.ts # GSAP ↔ R3F scroll bridge (no re-renders)
├─ hooks/
│ ├─ usePrefersReducedMotion.ts
│ ├─ usePageScroll.ts # ScrollTrigger → scrollStore
│ ├─ useSmoothScroll.ts # Lenis inertial scroll
│ └─ useCanvasActivation.ts # defer-mount heavy canvases + pause off-screen
├─ content/blog/ # the blog: one .md file per post (no CMS)
├─ pages/ # LandingPage, BlogIndex, BlogPost, Privacy, NotFound
├─ components/
│ ├─ layout/ # Navbar, Footer
│ ├─ sections/ # Hero, Promise, Features, Showcase, Audience, EarlyAccess
│ ├─ ui/ # Button, Field, Reveal, ErrorBoundary, …
│ └─ Seo.tsx # per-page title/OG/Twitter/JSON-LD
└─ three/ # all the WebGL
├─ HeroCanvas.tsx # pool scene: sky + lighting + ball + goal (lazy)
├─ hero/ # Water, Ball, Goal, BallScene, effects, constants
├─ CameraRig.tsx # low cinematic camera (maath damping + ball follow)
├─ TacticsCanvas.tsx # field + caps + animated 3D arrows (lazy)
├─ PenaltyCanvas.tsx # goal + plotted shots (lazy)
└─ Loader.tsx # in-canvas + DOM loaders
The brand palette is defined in three mirrored places — keep them in sync:
| Token | Hex | Tailwind | CSS var |
|---|---|---|---|
| Background | #131512 |
bg |
--color-bg |
| Surface | #1F221B |
surface |
--color-surface |
| Brand (olive) | #7E8B63 |
brand |
--color-brand |
| Brand light | #9FAC82 |
brand-light |
--color-brand-light |
| Silver | #C5C9C0 |
silver |
--color-silver |
| Text | #E6E8E2 |
ink |
--color-ink |
- Tailwind tokens:
tailwind.config.js - CSS variables:
src/index.css - TypeScript values (used in Three.js):
src/lib/theme.ts
- Email capture →
src/components/sections/EarlyAccess.tsxposts to Formspree viaVITE_FORMSPREE_ENDPOINT(see.env.example); set that in your environment and in the host's env vars. - Blog posts → add a Markdown file with frontmatter to
src/content/blog/; it's picked up at build time (no CMS). Images and cover art go inpublic/assets/blog/— see that folder'sREADME.md. - Analytics → Vercel Analytics is wired in
App.tsx, with anearly_access_signupconversion event fired on a successful signup. - Social links → live Kouci profiles in
src/components/layout/Footer.tsx(toggle each with itsenabledflag). - Canonical domain → update
SITE_URLinsrc/lib/site.ts(and the same value inscripts/gen-seo.mjs) when the custom domain lands.
- Heavy 3D scenes are code-split (
React.lazy).useCanvasActivationdefers each canvas's mount to browser-idle and pauses its render loop when scrolled off-screen (frameloop="never"), so idle canvases don't fight the scroll. - The hero shows a static poster instantly and crossfades the 3D in once the renderer is ready; each canvas is wrapped in an ErrorBoundary that falls back to the poster if WebGL fails.
- Repeated 3D elements (pins, shot markers, spray) use instancing; the
water is a single shader-displaced plane; mobile drops subdivision counts
and caps the device pixel ratio (
AdaptiveDpr). - Generated textures and materials are explicitly disposed on unmount.
- A parallel, screen-reader-only description accompanies each 3D scene; decorative canvases are hidden from assistive tech.
prefers-reduced-motionfreezes the water, camera, arrows and DOM reveals (even the wave in this README stops).- Skip link, semantic landmarks, on-brand focus rings, and a fully validated, keyboard-operable signup form.
PRs welcome — see CONTRIBUTING.md. npm run check and
npm run build must pass; a Husky pre-commit hook lints staged files, and CI
runs the same checks.
Static output — deploy dist/ anywhere. For Vercel or Netlify:
- Build command:
npm run build - Output directory:
dist - Env var:
VITE_FORMSPREE_ENDPOINT