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

  1. An admin creates a fund at /admin/funds (providing a name and initial balance).
  2. The admin adds one or more players as managers of the fund.
  3. A manager visits /fund/[slug] to see the fund's portfolio.
  4. 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.
  5. 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.
  6. The manager can return to their /profile at 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:

  1. Admin navigates to /admin/users, finds the user, opens the edit modal.
  2. Clicks "Generate reset link".
  3. Copies the displayed URL and sends it to the user via Discord/chat/etc.
  4. User visits the URL at /auth/reset-password?token=<token>, sets a new password.
  5. 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:

  1. Decodes the PROD_ENV secret (base64-encoded .env) and writes .env.
  2. Builds the Docker image and pushes to the registry.
  3. 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.
S
Description
No description provided
Readme 2.1 MiB
Languages
TypeScript 99.7%
Dockerfile 0.1%