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
Prices follow a saturating curve (Michaelis-Menten) so that viral hashtags don't produce runaway prices:
price = max($0.25, round((base × pph) / (1 + k × pph), 2))
k is derived from two anchor points: floor price $0.25 and a target of $250 at 3,600 PPH (one post per second).
| Posts/hr | Price |
|---|---|
| 1 | ~$0.25 |
| 10 | ~$2.48 |
| 100 | ~$23.32 |
| 1,000 | ~$145 |
| 3,600 (one post/sec) | ~$250 |
| ∞ (theoretical) | ~$346 (asymptote) |
At low activity the curve is approximately linear (≈ $0.25 per post/hr). At high activity it flattens, preventing a single trending hashtag from dwarfing the entire market.
Burst handling: the worker fetches up to MAX_PAGES_PER_HASHTAG pages of Mastodon results and uses only posts within the most recent hour when calculating PPH. If the fetched results are exhausted before covering a full hour, PPH is extrapolated from the covered window.
Research System
-
Every player earns 1 research point per day (awarded at midnight EST 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 midnight EST. 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
- Retry failed button per queue to requeue all failed jobs
Hedge Funds (/admin/funds)
- Create a named hedge fund with an initial starting balance
- Add or remove managers (by username) per fund
- Delete a fund (removes all its positions and trade history)
Hedge Funds
Multiple players can collaborate via a Hedge Fund — a shared pool of capital with its own balance, positions, and trade history.
How it works
- An admin creates a fund at
/admin/funds(providing a name and initial balance). - The admin adds one or more players as managers of the fund.
- A manager visits
/fund/[slug]to see the fund's portfolio. - From there they can click any held position (or browse all stocks) — the link includes
?fund=[slug], putting the hashtag trade page in Fund Mode. - In Fund Mode a banner confirms which fund the trade will be on behalf of. All buys and sells deduct from / credit to the fund's balance, not the manager's.
- The manager can return to their
/profileat any time to trade under their own account.
Fund page (/fund/[slug])
- Public — anyone can view the fund name, balance, positions, managers, and trade history.
- Managers see a management panel with quick links to trade each held position in Fund Mode.
Key rules
- A fund account cannot sign in directly — it is a shadow account controlled via the manager interface.
- A user can manage multiple funds simultaneously.
- Funds do not earn research points or play the daily lottery.
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: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.
Other Ideas / Nice-to-Haves
- Email integration: SMTP-based password reset and optional trade confirmation emails.
- Multi-instance support: fallback to another instance if the primary instance is unavailable or throttles API calls.
- 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.