Building a Turkish Daily Puzzle Platform (Günbegün) from Scratch

When I first opened one of the popular English-language puzzle games, something felt off: I couldn't type "güneş," the category names required thinking in English, and half the games were mobile-only. The Turkish-speaking audience was largely left out of this category.
This post covers the technical decisions behind the platform, a few things that went wrong, and what I learned along the way.
What It Does
The platform currently ships five games: Kelimece (daily word guess), Türkçe Arı (letter honeycomb), Bağdaştır (category grouping), Sudoku, and İzler (themed word hunt on a grid). Each game resets daily, has a leaderboard, tracks streaks, and lets you share your result as an emoji grid.
No login. No signup. You open the page and play.
Stack
Backend is Java 21 + Spring Boot 3.5.0 with virtual threads enabled. Frontend is React 19 + TypeScript + Vite. Infrastructure is written as AWS CDK in TypeScript across five stacks: network, data, compute, monitoring, and an edge stack that must live in us-east-1 because CloudFront and ACM require it. Aurora Serverless v2 PostgreSQL runs at minimum 0 ACU and auto-pauses after five minutes of inactivity. Dev environment cost stays around $1–5/month.
Identity Without a Login
Players have no account, but their game history, ranking, and streak are all tracked. The solution is a UUID v7 signed with HMAC-SHA256 and stored in an HttpOnly cookie.
The format is {uuid}.{base64url-hmac(uuid)}. On every request the backend verifies the signature. Tamper the cookie manually and the signature breaks — the server rejects it. From a privacy standpoint it is clean: the server holds a nickname and game data, never an email or real identity.
Turkish Character Normalization
This detail looks small but it is critical for Wordle and Spelling Bee alike.
Java's default toLowerCase() ignores locale, so İ maps to I instead of i. A central TurkishText utility handles NFC normalization and Locale.forLanguageTag("tr") for the whole platform. The Wordle scorer's two-pass yellow/green algorithm runs on this normalized form, which means edge cases like double letters and dotted-I variants behave correctly.
The seed dictionary follows the same path: a 76K-word Turkish dictionary mirror is filtered down to 5-letter grapheme sequences, Turkish alphabet only, proper nouns excluded. That yields 5,239 Wordle words, each scored by letter frequency into EASY, MEDIUM, or HARD.
One Schema for Five Games
Rather than separate tables per game, daily_puzzles.payload JSONB carries game-specific data. A CHECK constraint enforces the right shape: Wordle rows must have word + difficulty, everything else must have payload. The game_attempts.guesses JSONB array is also shared across all games — Sudoku writes "r,c,v" triplets, Connections writes pipe-delimited word groups, Wordle writes plain guesses. Adding a new game requires no schema migration.
AI Integration and Prompt Caching
Connections and Strands puzzle content is generated by Claude Sonnet 4.6. Raw cost per puzzle is around $0.05, but the system prompt is placed in a five-minute ephemeral cache block. A weekly batch of 14 calls becomes 1 cache write and 13 cache reads, bringing the real cost to roughly $0.02 per week per game.
The AI only returns a word list. Grid placement is handled on the backend. For Strands, a backtracking placer with a Warnsdorff-like heuristic fills the 8×6 grid with exactly 48 characters — no filler tiles. If the AI's word set cannot be placed within 50 attempts, the method returns Optional.empty() and the curator fallback takes over. The game never breaks even when the API is unreachable.
One Spring DI lesson surfaced here: a @Bean method returning null registers a null bean, which silently blocks @ConditionalOnMissingBean from triggering. The fix is @ConditionalOnExpression on the real bean, keeping the stub wired only when the API key is absent or set to the placeholder value STUB.
CI/CD with OIDC
After manually running docker build → ECR push → App Runner redeploy for every backend change, I automated the full pipeline with GitHub Actions and OIDC. No AWS credentials are ever stored in repository secrets; GitHub's short-lived token is exchanged for a scoped IAM role at runtime.
The PR checks workflow spins up a Postgres service container, runs 213 backend tests, runs TypeScript type checking, and validates CDK synth. After merging to main, the deploy workflow handles Docker buildx with --platform linux/amd64 (required for Apple Silicon compatibility), ECR push, App Runner deployment wait, S3 sync, and CloudFront invalidation. End-to-end time from merge to live is around five to ten minutes.
Things I Would Do Differently
Aurora cold start. Min 0 ACU means Aurora pauses after five minutes. Spring Boot's default Hikari timeout is ten seconds — not enough for Aurora to wake up. Setting initialization-fail-timeout: -1 and Flyway connect-retries: 10 solves it, but the fix is non-obvious the first time.
CloudFront CORS. GET requests are "simple" so the browser sends no preflight and everything appears to work. POST triggers a preflight, Spring returns 403 because the CloudFront domain is not in corsOrigins, CloudFront caches the S3 error response as HTML, and suddenly every POST endpoint looks broken. The root cause was a single missing domain in a config list.
`actions/github-script` and dynamic content. Embedding CDK diff output with ${{ steps.output }} directly into a script body breaks the JavaScript parser whenever the output contains backticks or dollar signs. The only safe pattern is passing it through an environment variable and reading process.env.DIFF_OUTPUT in the script.
Where Things Stand
All five games are playable on the dev environment. The immediate next steps are custom domain, prod stack deployment, and a public launch. When the repository goes public I will share the link here.