DEV Community

Cover image for I built a multiplayer Wordle where you win real SOL
Fdasert
Fdasert

Posted on

I built a multiplayer Wordle where you win real SOL

I built a multiplayer Wordle where you win real SOL - here's how

A few weeks ago I had a simple idea: what if Wordle had real money on the line?

The result is WordGuess - a multiplayer word game on Solana mainnet. Every 2 minutes, a new 7-letter word is chosen. Players pay 0.01 SOL per guess. First to guess correctly wins 95% of the prize pool. Fully automated, no custodial wallets, no signups.

Here's how I built it and what I learned.


The stack

  • Next.js — frontend, deployed on Vercel
  • Supabase — PostgreSQL + Edge Functions + Realtime
  • Solana mainnet — payments and payouts via Helius RPC
  • Phantom / Solflare — wallet connection

The core idea: keep the game logic server-side (so the secret word is never exposed), but make payments fully on-chain.


How a round works

  1. A cron job runs every minute and checks if there's an active round
  2. If not - start-round edge function picks a random word from a 632-word dictionary and creates a new round with a 132-second timer (120s gameplay + 10s overlay + 2s buffer)
  3. Players connect their wallet, pay 0.01 SOL → verify-payment checks the Solana transaction and credits the prize pool
  4. Players submit guesses → check-guess runs the Wordle color algorithm server-side and returns colored tiles
  5. First correct guess → payout-winner sends 95% of the pool to the winner's wallet automatically
  6. Round ends → end-round reveals the word, start-round fires again

The secret word problem

The trickiest part: how do you hide the secret word from the client while still letting the server validate guesses?

Simple solution - the word lives only in the database. The global_rounds table has a secret_word_id (foreign key to a dictionary table). The dictionary table has RLS set to block all anon reads. The frontend never sees the word - only the server-side Edge Functions do.

When the round ends, the word gets copied to revealed_word column which IS readable by anon. So players see it after the round.

-- RLS: block anon from reading the dictionary
CREATE POLICY "dict_no_anon_read" ON dictionary
  FOR SELECT TO anon USING (false);
Enter fullscreen mode Exit fullscreen mode

Real-time with Supabase

All guesses are visible to everyone in real time. I used Supabase Realtime with postgres_changes subscriptions:

const ch = supabase.channel(`g:${round.id}`)
  .on('postgres_changes', {
    event: 'INSERT',
    schema: 'public',
    table: 'guesses',
    filter: `round_id=eq.${round.id}`
  }, payload => {
    const g = payload.new as Guess;
    setAllGuesses(prev => [...prev, g]);
  })
  .subscribe();
Enter fullscreen mode Exit fullscreen mode

This creates a live feed where you can watch other players' attempts in real time - green/yellow/gray tiles appearing as people guess.


Payment verification

When a player pays 0.01 SOL, the frontend sends the transaction signature to verify-payment. The function fetches the transaction from Solana via Helius RPC and checks:

  1. Transaction exists and succeeded
  2. Game wallet received funds
  3. Amount is ≥ entry fee
  4. Signature hasn't been used before (replay protection)
const delta = (postBalances[gameIdx] ?? 0) - (preBalances[gameIdx] ?? 0);
const amountSol = delta / 1_000_000_000;

if (amountSol < required * 0.99) {
  return error('Insufficient payment');
}
Enter fullscreen mode Exit fullscreen mode

One edge case I hit: if the round ends while the transaction is being confirmed (2-4 seconds), verify-payment used to return 404. Fixed by falling back to the current active round if the original round is no longer active - the SOL is already on-chain, so we just credit it to the next round.


Automated payouts

When someone wins, check-guess creates a pending_payouts record with a 5-minute delay. A cron job runs every minute and processes it:

SELECT net.http_post(
  url := 'https://.../functions/v1/payout-winner',
  body := jsonb_build_object('round_id', round_id)
)
FROM pending_payouts
WHERE status = 'pending'
  AND payout_after <= NOW();
Enter fullscreen mode Exit fullscreen mode

The 5-minute delay gives Solana time to fully confirm everything before we send the payout transaction.


Preventing duplicate rounds

Early in testing I had a race condition where multiple cron triggers would fire simultaneously and create several active rounds at once.

Fixed with two layers:

  1. PostgreSQL advisory lock in start-round
  2. Unique partial index on the table
CREATE UNIQUE INDEX idx_one_active_round
  ON global_rounds (status)
  WHERE status = 'active';
Enter fullscreen mode Exit fullscreen mode

No matter how many times the cron fires simultaneously, only one active round can exist.


Rate limiting

To prevent spam, I built rate limiting directly in PostgreSQL using an upsert pattern:

CREATE OR REPLACE FUNCTION upsert_rate_limit(
  p_key text, p_window_secs int, p_max_calls int
) RETURNS json ...
Enter fullscreen mode Exit fullscreen mode
  • verify-payment: 5 calls per wallet per 60 seconds
  • check-guess: 20 calls per wallet per 60 seconds

No Redis needed - Postgres handles it fine at this scale.


Balance monitoring

The game wallet needs SOL to send payouts. If it runs dry, payouts fail silently. I built a check-balance edge function that runs every 10 minutes and sends a Telegram alert if the balance drops below 0.05 SOL:

if (sol < ALERT_THRESHOLD && TELEGRAM_BOT_TOKEN) {
  await fetch(`https://api.telegram.org/bot${token}/sendMessage`, {
    body: JSON.stringify({
      chat_id: TELEGRAM_CHAT_ID,
      text: `⚠️ Game wallet balance: ${sol} SOL — top up soon!`
    })
  });
}
Enter fullscreen mode Exit fullscreen mode

What I'd do differently

Longer rounds. 2 minutes is very short - players barely have time to think. I'd experiment with 5-10 minute rounds to allow more people to join and compete.

Word difficulty tiers. Easy words for small pools, hard words for big pools. Makes it more strategic.

Mobile keyboard. The current keyboard works on mobile but feels cramped. Should be the first UX improvement.


Try it

wordguess.space

Connect Phantom or Solflare, no account needed. Would love feedback - especially if you find bugs or UX issues.

The codebase is built on a pretty standard Next.js + Supabase setup if you want to build something similar. Happy to answer questions in the comments.


Built solo with Next.js, Supabase, and Helius RPC.

Top comments (0)