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
- A cron job runs every minute and checks if there's an active round
- If not -
start-roundedge 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) - Players connect their wallet, pay 0.01 SOL →
verify-paymentchecks the Solana transaction and credits the prize pool - Players submit guesses →
check-guessruns the Wordle color algorithm server-side and returns colored tiles - First correct guess →
payout-winnersends 95% of the pool to the winner's wallet automatically - Round ends →
end-roundreveals the word,start-roundfires 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);
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();
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:
- Transaction exists and succeeded
- Game wallet received funds
- Amount is ≥ entry fee
- 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');
}
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();
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:
- PostgreSQL advisory lock in
start-round - Unique partial index on the table
CREATE UNIQUE INDEX idx_one_active_round
ON global_rounds (status)
WHERE status = 'active';
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 ...
-
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!`
})
});
}
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
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)