HashEx — The Hashtag Exchange
A stock-market simulation game where the "stocks" are Mastodon hashtags and prices are driven by real-time post activity. Inspired by the Hollywood Stock Exchange.
Overview
Players start with $2,000 in fake money and use it to buy and sell shares in hashtags. Every day each player earns research points which can be spent to unlock new hashtags. Prices are calculated from the number of posts per hour on Mastodon and update automatically in the background.
Tech Stack
| Layer | Technology |
|---|---|
| Framework | Next.js 14 (App Router, TypeScript) |
| Database | PostgreSQL via Prisma ORM |
| Auth | NextAuth v4 (credentials / JWT sessions) |
| Background jobs | BullMQ + Redis |
| Styling | Tailwind CSS (dark theme) |
| Charts | Recharts |
| Data source | Mastodon REST API (configurable instance) |
| Deployment | Docker / Portainer (via Gitea Actions CI) |
Project Structure
hashex/
├── prisma/
│ ├── schema.prisma # Full DB schema
│ └── seed.ts # Seeds an initial admin user
├── src/
│ ├── app/
│ │ ├── page.tsx # Home: trending + recently traded tags
│ │ ├── layout.tsx # Root layout with Navbar
│ │ ├── providers.tsx # NextAuth SessionProvider wrapper
│ │ ├── globals.css # Tailwind base + dark overrides
│ │ ├── auth/
│ │ │ ├── signin/page.tsx # Sign-in form
│ │ │ ├── signup/page.tsx # Registration form
│ │ │ └── reset-password/page.tsx # Password reset (token from URL)
│ │ ├── hashtag/[tag]/
│ │ │ ├── page.tsx # Price chart + trade/research panel
│ │ │ ├── TradePanel.tsx # BUY/SELL controls (client component)
│ │ │ └── ResearchPanel.tsx # Research prompt (client component)
│ │ ├── profile/[username]/
│ │ │ └── page.tsx # Portfolio, positions, trade history
│ │ ├── admin/
│ │ │ ├── layout.tsx # Admin guard + nav links
│ │ │ ├── page.tsx # Overview stats + leaderboard
│ │ │ ├── users/
│ │ │ │ ├── page.tsx # Paginated user table
│ │ │ │ └── AdminUserActions.tsx # Edit modal + reset-link generator
│ │ │ ├── stocks/
│ │ │ │ ├── page.tsx # Paginated hashtag table
│ │ │ │ └── AdminStockActions.tsx # Edit modal + force-refresh button
│ │ │ └── queue/
│ │ │ └── page.tsx # Live BullMQ queue monitor
│ │ └── api/
│ │ ├── auth/
│ │ │ ├── [...nextauth]/route.ts # NextAuth handler
│ │ │ ├── register/route.ts # POST — create account
│ │ │ └── reset-password/route.ts # POST — consume token, set password
│ │ ├── research/route.ts # POST — spend point, query Mastodon
│ │ ├── trade/route.ts # POST — BUY/SELL with validation
│ │ ├── user/me/route.ts # GET — current user's balance
│ │ └── admin/
│ │ ├── users/[userId]/route.ts # PATCH — edit balance/points
│ │ ├── users/[userId]/reset-link/route.ts # POST — generate reset URL
│ │ ├── stocks/[hashtagId]/route.ts # PATCH — edit price/status
│ │ └── stocks/[hashtagId]/force-update/route.ts # POST — queue job
│ ├── components/
│ │ ├── Navbar.tsx # Top nav with search, balance badge, admin icon
│ │ ├── PriceChart.tsx # Recharts line chart for price history
│ │ └── HashtagCard.tsx # Card with tag, price, % change
│ ├── lib/
│ │ ├── prisma.ts # Prisma client singleton
│ │ ├── auth.ts # NextAuth authOptions
│ │ ├── mastodon.ts # Mastodon API fetch + pagination
│ │ ├── queue.ts # BullMQ Queue definitions
│ │ ├── pricing.ts # Price formula, trade math, research-point tiers
│ │ └── utils.ts # cn(), formatCurrency(), normalizeTag(), etc.
│ ├── middleware.ts # NextAuth route protection
│ ├── types/
│ │ └── next-auth.d.ts # Session type augmentation
│ └── worker/
│ └── index.ts # BullMQ worker process (runs in separate container)
├── .env.example # All required env vars with descriptions
├── Dockerfile # Single image; CMD overridden for worker container
├── prod-compose.yml # Production compose: app + worker + postgres + redis
├── next.config.mjs
├── tailwind.config.ts
├── tsconfig.json
└── postcss.config.js
Getting Started (Local)
Prerequisites
- Node.js 20+
- PostgreSQL instance (or
docker run -e POSTGRES_PASSWORD=password -p 5432:5432 postgres:16-alpine) - Redis instance (or
docker run -p 6379:6379 redis:7-alpine)
Setup
# 1. Copy env and fill in your values
cp .env.example .env
# 2. Install dependencies
npm install
# 3. Apply the database schema
npm run db:push
# 4. Seed the admin user (see .env.example for ADMIN_USERNAME / ADMIN_PASSWORD)
npm run db:seed
# 5. Start the web server
npm run dev
# 6. In a second terminal, start the background worker
npm run worker:dev
The app will be at http://localhost:3000. The seeded admin credentials default to admin / changeme123 — change them immediately.
Environment Variables
All variables are documented in .env.example. Key ones:
| Variable | Default | Description |
|---|---|---|
DATABASE_URL |
— | PostgreSQL connection string |
NEXTAUTH_SECRET |
— | Random secret for session signing (openssl rand -base64 32) |
NEXTAUTH_URL |
http://localhost:3000 |
Base URL of your deployment |
REDIS_URL |
redis://localhost:6379 |
Redis connection string |
MASTODON_INSTANCE |
https://mastodon.social |
Mastodon instance base URL |
MASTODON_ACCESS_TOKEN |
(optional) | Bearer token — increases API rate limits |
WORKER_RATE_LIMIT_MS |
2000 |
Milliseconds to wait between Mastodon API calls |
PRICE_UPDATE_INTERVAL_MINUTES |
60 |
How often to sweep all active hashtags |
MAX_PAGES_PER_HASHTAG |
5 |
Max Mastodon pages fetched per tag (5 × 40 = up to 200 posts) |
Pricing Formula
price = max($0.25, round(postsPerHour × $0.25, 2))
Examples:
| Posts/hr | Price |
|---|---|
| 1 | $0.25 |
| 10 | $2.50 |
| 100 | $25.00 |
| 1,000 | $250.00 |
| 12,000 (e.g. #happynewyear at midnight) | $3,000.00 |
Burst handling: when all fetched posts share a very tight timestamp window the worker paginates up to MAX_PAGES_PER_HASHTAG pages to get a realistic count before the span grows to > 5 minutes.
Research System
-
Every player earns 1 research point per day (awarded at 00:05 UTC by the maintenance worker).
-
Balance milestones unlock extra daily points:
Balance Daily points < $10,000 1 $10,000+ 2 $100,000+ 3 $1,000,000+ 5 -
Spending a research point queries Mastodon for that hashtag:
- No results found → point is spent, hashtag not added.
- Results found → hashtag is created, price set, job queued for immediate update.
-
If no player holds a position in a hashtag and it returns zero results for 3 consecutive update cycles, it is automatically deactivated. A player must research it again to reactivate it.
Trading
Four trade types are supported:
| Type | Description |
|---|---|
BUY_LONG |
Buy shares betting price goes up |
SELL_LONG |
Close or reduce a long position |
BUY_SHORT |
Place collateral betting price goes down |
SELL_SHORT |
Close a short, return collateral adjusted for price movement |
Safeguards enforced server-side:
- Cannot spend more than current balance on a buy.
- Cannot sell more shares than you hold in a position.
- Trades are executed in a database transaction (balance + position + trade record atomically).
Background Worker
The worker runs as a separate Docker container using the same image with a different CMD.
Three BullMQ queues:
| Queue | Purpose |
|---|---|
hashex-price-updates |
One job per active hashtag; fetches Mastodon and updates price + price history. Concurrency = 1 to respect rate limits. |
hashex-scheduler |
Fires every PRICE_UPDATE_INTERVAL_MINUTES. Enqueues price-update jobs ordered by lastUpdated ASC (most stale first). Deduplicates by jobId to avoid pile-up. |
hashex-maintenance |
Runs daily at 00:05 UTC. Awards research points based on each player's balance. |
The worker retries failed jobs up to 3 times with exponential back-off (5 s base delay).
Admin Dashboard (/admin)
Accessible only to users with isAdmin = true.
Overview
- Platform stats (users, active/inactive hashtags, trade count)
- Top 10 players by balance
- Recent 10 trades across the platform
Users (/admin/users)
- Searchable, paginated user list
- Edit modal per user: adjust balance and research points
- Generate reset link: creates a one-time password reset URL (2-hour expiry). No email is sent — the admin copies and shares the link with the user.
Stocks (/admin/stocks)
- Searchable, filterable (active/inactive), paginated hashtag list
- Edit modal per hashtag: override price, toggle active/inactive
- Force price refresh: adds a high-priority job to the update queue immediately
Queue Monitor (/admin/queue)
- Live view of all three BullMQ queues
- Shows waiting, active, delayed, completed, and failed counts
- Lists active, waiting, and recent failed jobs with payload and failure reason
Password Reset Flow
Since there is no email system, password resets are handled manually by an admin:
- Admin navigates to
/admin/users, finds the user, opens the edit modal. - Clicks "Generate reset link".
- Copies the displayed URL and sends it to the user via Discord/chat/etc.
- User visits the URL at
/auth/reset-password?token=<token>, sets a new password. - The token is marked used and expires after 2 hours. A new link invalidates any previous unused token for that user.
Deployment
The CI pipeline is defined in .gitea/workflows/rebuild-prod.yaml. On push to main it:
- Decodes the
PROD_ENVsecret (base64-encoded.env) and writes.env. - Builds the Docker image and pushes to the registry.
- Fetches the existing Portainer stack, preserves its env vars, and redeploys with the new image.
The prod-compose.yml defines four services:
| Service | Notes |
|---|---|
app |
Web server on port 3000. Runs prisma db push before starting. |
worker |
Same image, CMD: npm run worker:prod. Handles all background jobs. |
postgres |
Postgres 16 Alpine with a named volume. Health-checked. |
redis |
Redis 7 Alpine with AOF persistence. |
Creating the PROD_ENV secret
# Generate a base64-encoded copy of your production .env file:
base64 -w 0 .env
# Paste the result as the PROD_ENV secret in Gitea.
Generating NEXTAUTH_SECRET
openssl rand -base64 32
Database Schema Summary
| Model | Key fields |
|---|---|
User |
username, passwordHash, balance, researchPoints, isAdmin |
PasswordReset |
token, userId, expiresAt, used |
Hashtag |
tag (lowercase), displayTag, currentPrice, isActive, zeroCount, lastUpdated |
PriceHistory |
hashtagId, price, postsPerHour, recordedAt |
Position |
userId, hashtagId, positionType (LONG/SHORT), shares, avgBuyPrice |
Trade |
userId, hashtagId, type, shares, price, total, profit |
Useful Scripts
npm run dev # Next.js dev server (hot reload)
npm run build # Production build
npm run start # Start production web server
npm run worker:dev # Worker with hot reload (tsx watch)
npm run worker:prod # Worker for production
npm run db:push # Apply schema changes without migrations
npm run db:migrate # Create and apply a migration
npm run db:seed # Seed the admin user
npm run db:studio # Open Prisma Studio (GUI)
Feature Roadmap
The items below are planned improvements roughly ordered by user value. They are good candidates for the next development phase.
1. Auto-Derive Display Casing from Mastodon Posts
Problem: displayTag is currently set once at research/creation time from whatever the user typed. In reality Mastodon users have an established "canonical" capitalisation for a tag (e.g. #StPatricksDay rather than #stpatricksday) and our display tag should reflect that.
Plan:
- During each price update job the worker already fetches up to 200 posts. The Mastodon API returns each post's
tagsarray wherenamecontains the tag as typed by the poster (e.g.StPatricksDay). - Count the frequency of each distinct casing variant seen across all fetched posts for the hashtag being updated.
- If the most frequent variant differs from the current
displayTag, updateHashtag.displayTagas part of the price update transaction. - Only update when a variant accounts for a meaningful majority (e.g. ≥ 50% of occurrences) to avoid flip-flopping on low-signal posts.
- Implementation touches:
mastodon.tsalready returnspost.tagsfromgetPostsData()— add acasingfield to the result ({ postsPerHour, relatedTags, displayTag? }); worker checks the returneddisplayTagand includes it in the Prisma update when it differs. - No schema changes required —
displayTagalready exists onHashtag.
2. Home Page Holdings Summary (Signed-In)
Problem: Signed-in users land on the home page and see only the generic trending list — there is no personalised hook to show how their portfolio is performing.
Plan:
- Add two summary cards above the "Trending now" section, visible only to signed-in users: Biggest Gain and Biggest Loss (by unrealised P&L across open positions).
- Both cards link to the relevant hashtag page.
- Implemented as a server component addition to
src/app/page.tsx: query the current user's open positions (joined with current price) and compute unrealised P&L per position to find the top and bottom. - If the user has no positions, the cards are omitted (no empty-state clutter).
- No schema changes required.
3. Search Autocomplete
Problem: The current search/research input is a plain text field with no discovery aid — users must know or guess full hashtag names.
Plan:
- While the user types in the search box, show a dropdown of matching hashtags that are already tracked on the exchange (fetched from a new
GET /api/hashtags/search?q=endpoint). - The autocomplete only surfaces existing active hashtags; submitting a tag that doesn't appear in the list still proceeds through the normal research flow (no interference with new-hashtag discovery).
- Client-side: debounce the input (~300 ms), cancel in-flight requests on new keystrokes, close dropdown on
Escapeor outside click. - Endpoint: case-insensitive prefix match on
Hashtag.tagwhereisActive = trueandisBanned = false, returning{ tag, displayTag, currentPrice }for up to 8 results. - No schema changes required.
4. Open Positions Full Page
Problem: The profile page shows open positions in a compact list — there is no space for richer per-position data (cost basis, current value, a mini P&L chart).
Plan:
- New page
/positions(auth-protected) showing only the signed-in user's open positions in a more detailed layout. - Each row / card: hashtag name + link, position type (LONG/SHORT), shares held, average buy price, current price, total cost basis, current value, unrealised P&L and P&L %, a sparkline chart of the hashtag's recent price history.
- Link into this page from the "Open positions" heading on the profile page.
- No new nav item needed — accessed via profile page link.
- No schema changes required.
Other Ideas / Nice-to-Haves
- Hedge funds: group of players pool money into a shared portfolio, one designated fund manager places trades.
- Email integration: SMTP-based password reset and optional trade confirmation emails.
- Multi-instance support: let users choose which Mastodon instance to pull data from per-hashtag, or aggregate across instances.
- Mobile-optimised trade panel: the current layout works but a dedicated bottom-sheet on mobile would improve UX.
- Price alerts: users subscribe to a hashtag at a threshold price; a notification appears in the UI (or email if integrated) when it crosses that level.
- Dark/light theme toggle: currently dark-only.