Why Authentication Matters
The daily game works fine without accounts. Your progress is stored in localStorage, and that's enough for casual play. But for multiplayer to feel meaningful, players need persistent identities. You want to know your win rate, see your rank, and show up on a leaderboard. That requires authentication.
Google OAuth Without the Bloat
On a traditional Node.js server, you'd reach for Passport.js or a similar library. But on Cloudflare Workers, there's no Express, no middleware stack, no sessions library. I had to implement the OAuth flow from scratch, which turned out to be surprisingly simple:
// API route: /api/auth/google
export async function GET({ request, env }: APIContext) {
const url = new URL('https://accounts.google.com/o/oauth2/v2/auth');
url.searchParams.set('client_id', env.GOOGLE_CLIENT_ID);
url.searchParams.set('redirect_uri', env.GOOGLE_REDIRECT_URI);
url.searchParams.set('response_type', 'code');
url.searchParams.set('scope', 'openid email profile');
url.searchParams.set('state', generateCSRFToken());
return Response.redirect(url.toString());
}
// Callback: /api/auth/callback
export async function GET({ request, env }: APIContext) {
const code = new URL(request.url).searchParams.get('code');
// Exchange code for tokens
const tokens = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
body: new URLSearchParams({
code,
client_id: env.GOOGLE_CLIENT_ID,
client_secret: env.GOOGLE_CLIENT_SECRET,
redirect_uri: env.GOOGLE_REDIRECT_URI,
grant_type: 'authorization_code',
}),
}).then(r => r.json());
// Decode JWT to get user info
const payload = decodeJWT(tokens.id_token);
const user = await upsertUser(env.DB, payload);
// Set session cookie
const sessionId = crypto.randomUUID();
await env.DB.prepare(
'INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, ?)'
).bind(sessionId, user.id, Date.now() + 30 * 86400000).run();
return new Response(null, {
status: 302,
headers: {
'Location': '/',
'Set-Cookie': `session=${sessionId}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=2592000`,
},
});
}
I chose Google-only auth deliberately. Adding email/password means handling password hashing, reset flows, and security concerns. Adding multiple OAuth providers means more redirect URIs, more token handling, more edge cases. Google covers virtually everyone, and it's one less thing to maintain.
Session Management
Sessions are stored in D1 with an expiry timestamp. Every API request checks the session cookie, looks up the session in the database, and attaches the user to the request context. Simple, stateless-friendly, and works perfectly with Cloudflare's edge network since D1 is available everywhere.
The Rating System
For ranked 1v1, I implemented an ELO-inspired rating system. Every player starts at 1000 points. After each match, the winner gains points and the loser drops. The amount gained or lost depends on the rating difference between players:
function calculateEloChange(
winnerRating: number,
loserRating: number
): { winnerDelta: number; loserDelta: number } {
const K = 32;
const expected = 1 / (1 + Math.pow(10,
(loserRating - winnerRating) / 400
));
const delta = Math.round(K * (1 - expected));
return {
winnerDelta: delta,
loserDelta: -delta,
};
}
Beat someone rated much higher than you? Big gain. Beat someone much lower? Small gain. This naturally creates a balanced ladder where your rating converges on your true skill level.
Leagues and Tiers
Raw numbers don't feel as exciting as league names. I mapped rating ranges to tiers:
- Bronze: 0-999 (starting tier)
- Silver: 1000-1299
- Gold: 1300-1599
- Platinum: 1600-1899
- Diamond: 1900+
Each tier has a visual badge on your profile and next to your name in matches. Climbing from Silver to Gold feels like an achievement, even though it's just crossing a number threshold.
Merging Anonymous Data
One tricky problem: what if someone has been playing the daily game for weeks as an anonymous user (localStorage only), and then signs up? I didn't want them to lose their history. When a new user signs in for the first time, the client sends their localStorage game history to the server, which merges it into their new account. Their streaks, stats, and daily results are preserved.
The Leaderboard
The leaderboard is a simple D1 query, ordered by rating, with pagination. It shows the top players, their win/loss records, and their tier badges. Players can also view their own profile with detailed stats: games played, win rate, average guesses, longest streak. It gives people a reason to keep playing and improving.
With auth and rankings in place, PriceGuess felt like a complete product. The final challenge was getting it all deployed and running smoothly on Cloudflare's infrastructure.