I Built My Own Chess Platform — Here's Everything That Went Wrong

Chess has been a part of my life for quite a time now. I'd used WebSockets before in a past Node.js/Express project, so the concept wasn't new to me. But as I've been transitioning into the Spring Boot ecosystem, I wanted to figure out how Spring handles all of it — WebSocket connections, STOMP messaging, security context. And what better way to do that than build something I actually care about? So the choice was obvious — a chess platform. Not a tutorial clone. A real one. With auth, timers, move validation, game persistence, and all the game logic on top.
The result is Chess-Web — a multiplayer chess platform built with Next.js 16 on the frontend and Spring Boot on the backend, connected via WebSocket + STOMP. You can browse a game lobby, create a match with custom time controls, play live with real-time move sync, chat with your opponent, and export your games in PGN format.
The Stack
Frontend: Next.js 16 (App Router), React 19, Tailwind CSS 4, Zustand + Context API, react-chessboard + chess.js, @stomp/stompjs + SockJS, Framer Motion + GSAP
Backend: Spring Boot 4.x, Java 21 (with Virtual Threads), Spring Security + JWT, Spring WebSocket + STOMP, chesslib for server-side move validation, PostgreSQL + Flyway, Resend for transactional email
The architecture is a decoupled client-server setup where the backend is the single source of truth for all game state. Every move is validated server-side using chesslib — no client-side trust.
Why WebSockets? Why Chess?
Most WebSocket tutorials show you a basic chat app. You follow along, it works, and then you close the tab and forget everything in 48 hours. I wanted something with actual complexity — multiple concurrent game sessions, authenticated connections, real-time state that needs to stay in sync between two players. Chess hits all of that.
Plus, I actually play chess. Building something in a domain you know about makes the hard debugging sessions less painful.
How Live Game State Actually Works
Here's something that isn't obvious until you think about it: while a game is actively being played, moves flying back and forth every few seconds over WebSocket — you cannot afford a database read/write on every single move. The latency would kill the real-time feel entirely.
So the backend uses an in-memory GameStore backed by a ConcurrentHashMap, keyed by gameId. Here's how the full flow works:
Game creation (REST): When a player creates, a POST /api/game/create call hits the GameService, which builds a GameSession object and puts it into the GameStore in memory. The session starts with status WAITING until the opponent joins using POST /api/game/join and status changes to ACTIVE.
Live play (WebSocket): Every move comes in as a STOMP message to /app/game.move. The service grabs the session from the store via store.get(gameId), validates the move using chesslib, mutates the game state in memory, and broadcasts the updated state back to both players. No DB touch.
Game over: Only when the game ends does the state get flushed to PostgreSQL — the games table gets the result, the moves table gets the full move record. Then store.remove(gameId) cleans it up and frees memory.
This pattern — hot state in memory, cold state in the DB — is what makes real-time feel snappy. The tradeoff is that if the server restarts mid-game, in-flight sessions are lost. For this project that's an acceptable trade-off. For production chess at scale, you'd want something like Redis to back the store.
The Interesting Problems
1. Hibernate vs PostgreSQL Native Enums
This one caught me off guard. I was using native PostgreSQL enum types for things like termination_reason — makes sense, it's cleaner at the DB level. But Hibernate, by default, tries to persist Java enums as VARCHAR. The schema mismatch caused it to blow up at runtime.
The fix was a single annotation I hadn't seen before:
@JdbcType(PostgreSQLEnumJdbcType.class)
@Column(name = "termination_reason", columnDefinition = "termination_reason")
private TerminationReason terminationReason;
@JdbcType(PostgreSQLEnumJdbcType.class) tells Hibernate to use PostgreSQL's native enum JDBC handling instead of treating it like a string. Once I found this, the fix was two lines. Finding it took... longer.
2. WebSocket Auth Was a Completely Different Beast
JWT auth for HTTP endpoints? Straightforward — write a filter, plug it into the security chain, done. I had that working fine with the experience I gained from the Time Capsule project.
Then I tried to authenticate the WebSocket connection and hit a wall. My HTTP security config was completely ignored for STOMP connections.
The reason: HTTP requests and STOMP WebSocket connections live in different inbound channel threads. Spring Security's context doesn't automatically carry over. You need a separate ChannelInterceptor that validates the JWT on the STOMP CONNECT frame — essentially, your own auth layer for the WebSocket handshake.
This was the biggest "I didn't know what I didn't know" moment of the project. The two protocols look similar from the outside but Spring treats them as entirely separate security concerns.
3. Pawn Promotion Logic
Chess move validation sounds simple — use a library and call it a day. And chesslib does handle most of it. But pawn promotion has its own edge cases: detecting when a pawn reaches the back rank, presenting promotion options to the player, sending the choice back through WebSocket, and then executing the correct promotion on the server.
The tricky part was the round-trip. You can't just validate the move client-side and send a result — the server needs to know which piece the player is promoting to and apply it before broadcasting the new game state to both players. Getting the timing and state transitions right here took a few iterations.
4. Server-Side Timer Logic
Client-side timers are easy. Server-side timers that need to be accurate, survive server load, and correctly handle timeout conditions are not.
I implemented a server-managed clock that tracks remaining time per player, decrements on each move, and triggers a game-over event when a player runs out. The challenge was making sure the timer state stayed consistent with the game state — especially on reconnects and when handling edge cases like a player timing out right as a move comes in. Java 21's Virtual Threads helped here, making concurrent game session management a lot cleaner than it would've been otherwise.
How I Used AI
I was deliberate about this. My rule: AI handles the parts I already understand well and would just be typing from memory. It doesn't touch the parts where I'm actually learning something.
Most of the heavy frontend lifting — page layouts, UI components, TypeScript type definitions — was handled by AI (specifically, Antigravity). On the backend, AI wrote Flyway migration schemas and boilerplate enum classes.
The four problems I described above? I worked through all of those myself. That's where the actual learning happened, and I didn't want to shortcut it.
The Embarrassing Yet Funny Moment
I was testing the sound effects — move sounds, capture sounds, check alerts, game start. Every single sound was playing twice. My first thought was React Strict Mode rendering things twice. Disabled it. Still double sounds.
Spent a while digging through the audio logic, checked event listeners, looked for duplicate subscriptions. Nothing obvious. Then I realised .
I was testing by opening two browser tabs and playing against myself. Each tab was playing the sound once. I was hearing both. Muted one tab. Single sound. Every time.
It was working perfectly the entire time. I was the bug :)
What's Next
A few things on the roadmap:
ELO Rating System — proper skill-based matchmaking
Game Analysis — post-game engine suggestions
Friend System — challenge people directly
Tournament Mode — multi-round bracket support
Making
GameStoreproperly thread-safe — this one is a known gap I want to address. Right now,ConcurrentHashMaponly protects the map-level operations (put, get, remove). It does not protect the mutable fields inside eachGameSession— things like the move list (ArrayList) or compound operations like "check state, then update." Under concurrent load (think two moves arriving simultaneously, or a move and a timeout firing at the same time), this could cause subtle race conditions. The fix is centralizing locking insideGameStoreitself — asynchronizedblock orReentrantLockper game session — so that any read-modify-write on aGameSessionis atomic. Something I'll be cleaning up in the next iteration.
If you're looking for a project to actually learn WebSockets with Spring Boot, I'd push you toward something with real complexity — not a chat app. Chess (or anything with real-time state shared between multiple users) will force you to deal with auth, concurrency, and state consistency in ways that a simple broadcast doesn't.
The repo is open: github.com/Akshansh029/Chess-Web
Drop a ⭐ if you find it useful, and feel free to open a PR — contributions are welcome.



