A real-time Capture The Flag competition platform with a brutalist retro-hacker aesthetic.
Built with Next.js 16, Convex (real-time backend), and Clerk (auth). Designed for cybersecurity competitions where teams register, solve challenges, and compete on a live scoreboard.
- Team System — Create or join a team (up to 3 members) with a unique joining code
- Challenge Dashboard — Browse challenges by difficulty (easy / medium / hard), submit flags, track progress
- Live Activity Feed — Real-time sidebar showing solves and first bloods across all teams
- Submission History — View all past attempts per challenge with timestamps
- Public Leaderboard — Live-updating scoreboard at
/leaderboardwith animated podium, rankings, and activity ticker
- Challenge Management — Create, edit, delete challenges with title, description, points, flag, category, and optional file links
- Team Oversight — View all teams, members, scores, and solve history
- Event Controls — Toggle event active/inactive, freeze scoreboard, open/close registration, lock/unlock dashboard
- Audit Log — Timestamped log of all admin actions
- Dashboard Stats — Real-time counts of teams, users, challenges, submissions, and solve rates
- Brutalist retro-hacker theme: VT323 display font, JetBrains Mono, pixel art SVG icons
- Grain/scanline overlays, grid-paper background, crosshair cursor
#39FF14neon green accent on dark#111111and light#EDEDEDpalette- Custom animated cursor (GSAP) on the landing page
- Responsive layout with mobile breakpoints
| Layer | Technology |
|---|---|
| Framework | Next.js 16 (App Router) |
| Runtime | React 19 |
| Backend | Convex (real-time, serverless) |
| Auth | Clerk (JWT → Convex integration) |
| Styling | Tailwind CSS 4 + custom CSS tokens |
| Animation | GSAP, Motion (framer-motion) |
| UI | Radix UI primitives, Lucide icons |
| Language | TypeScript |
├── app/
│ ├── layout.tsx # Root layout (Clerk + Convex providers)
│ ├── page.tsx # Landing page → auth → dashboard routing
│ ├── globals.css # Design tokens, brutalist theme, component styles
│ ├── admin/
│ │ ├── layout.tsx # Admin sidebar layout
│ │ ├── page.tsx # Admin dashboard with stats
│ │ ├── challenges/ # Challenge CRUD
│ │ ├── teams/ # Team management
│ │ ├── settings/ # Event config & access controls
│ │ └── audit/ # Audit log viewer
│ └── leaderboard/
│ └── page.tsx # Public live scoreboard
├── components/
│ ├── ConvexClientProvider.tsx # Convex + Clerk wiring
│ ├── Dashboard.tsx # Main CTF challenge dashboard
│ ├── LandingPage.tsx # Animated landing / login page
│ ├── Lobby.tsx # Team creation / join lobby
│ ├── TargetCursor.jsx # GSAP custom crosshair cursor
│ ├── DecryptedText.jsx # Text reveal animation
│ └── Shuffle.tsx # Shuffle text effect
├── convex/
│ ├── schema.ts # Database schema (7 tables)
│ ├── admin.ts # Admin mutations & queries
│ ├── challenges.ts # Challenge & submission logic
│ ├── teams.ts # Team creation / joining
│ ├── users.ts # User sync & leaderboard query
│ └── auth.config.ts # Clerk JWT config for Convex
├── lib/
│ └── utils.ts # cn() utility (clsx + tailwind-merge)
└── middleware.ts # Clerk auth middleware
| Table | Purpose |
|---|---|
user |
Clerk-synced users with optional team assignment |
teams |
Teams with name, joining code, leader, score |
questions |
CTF challenges (title, description, points, flag) |
submissions |
Flag submission attempts per team per challenge |
config |
Event state (active, frozen, registration, dashboard) |
activityFeed |
Real-time solve/first-blood events for live feed |
auditLog |
Admin action history with timestamps |
- Node.js ≥ 18
- npm (or pnpm / yarn)
- A Clerk account — clerk.com
- A Convex account — convex.dev
git clone <repo-url>
cd ctfnpm install- Create a new application at dashboard.clerk.com
- In Clerk → JWT Templates, create a template named
convexwith the Convex JWT format (see Convex docs) - Copy your Publishable Key and Secret Key from Clerk → API Keys
-
If you haven't already, install the Convex CLI globally:
npm install -g convex
-
Initialize or link your Convex project:
npx convex dev
This will prompt you to log in and create/select a project. It will also generate the
convex/_generated/directory. -
In the Convex Dashboard → Settings → Environment Variables, add:
CLERK_JWT_ISSUER_DOMAIN=<your-clerk-issuer-url>(Find this in Clerk → JWT Templates →
convextemplate → Issuer URL)
Create a .env.local file in the project root:
# Clerk
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
CLERK_SECRET_KEY=sk_test_...
# Convex
NEXT_PUBLIC_CONVEX_URL=https://<your-project>.convex.cloudBefore first use, ensure there's a config document in Convex. You can do this from the Convex Dashboard data browser or by running a one-off mutation. The config table expects:
{
"isEventActive": false,
"isScoreBoardFrozen": false,
"isRegistrationOpen": true,
"isDashboardOpen": false
}- Sign in through the app with Clerk
- In the Convex Dashboard, find your user in the
usertable and setisAdmin: true - You'll now have access to the
/adminpanel
Start the Next.js dev server and Convex in parallel:
# Terminal 1 — Convex backend (watches for schema/function changes)
npx convex dev
# Terminal 2 — Next.js frontend
npm run devThe app will be available at http://localhost:3000.
npm run build
npm startFor Convex production deployment:
npx convex deploySet the same environment variables (CLERK_JWT_ISSUER_DOMAIN) in your Convex production environment via the dashboard.
| Route | Access | Description |
|---|---|---|
/ |
Public | Landing page → login → dashboard |
/leaderboard |
Public | Live scoreboard (no auth required) |
/admin |
Admin | Admin dashboard with stats |
/admin/challenges |
Admin | Challenge management |
/admin/teams |
Admin | Team oversight |
/admin/settings |
Admin | Event config & access controls |
/admin/audit |
Admin | Audit log |
| Variable | Where | Description |
|---|---|---|
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY |
.env.local |
Clerk publishable key |
CLERK_SECRET_KEY |
.env.local |
Clerk secret key |
NEXT_PUBLIC_CONVEX_URL |
.env.local |
Convex deployment URL |
CLERK_JWT_ISSUER_DOMAIN |
Convex Dashboard | Clerk JWT issuer for Convex auth |
Private — not licensed for redistribution.