DEV Community

Cover image for Building an Adaptive Chess Trainer with Typelevel Scala and Tyrian
Dimos Michailidis
Dimos Michailidis

Posted on

Building an Adaptive Chess Trainer with Typelevel Scala and Tyrian

A web application with heavy domain logic involves a surprisingly high amount of state to manage. In a chess puzzle trainer, you have the state of the board itself, legal move validation, sequences of puzzles to load, and engine evaluation syncing. In ChessKinetics, we also have to manage the progression between difficulty waves and active recovery phases.

To provide context for the state complexity, the application is a puzzle trainer designed to structure cognitive load. Standard platforms serve an open-ended stream of puzzles, which often leads to mental fatigue. Instead, the algorithm groups puzzles into bounded waves consisting of six puzzles. These scale in difficulty based on a target bucket derived from the user's rating. It evaluates performance continuously: failing a puzzle in the early phases immediately triggers a two-minute active recovery period of very simple patterns. Furthermore, if a player accumulates two failures in the same wave, the system automatically aborts the remaining harder puzzles entirely to prevent fatigue.

When I started building the application, I knew that a traditional mutable state approach could easily lead to race conditions. Handling asynchronous events, like a user dragging a piece while a background process calculates the wave target, requires strict predictability.

I decided to build the entire stack using Scala 3. This allowed me to enforce a purely functional approach from the database all the way to the browser DOM.

The Elm Architecture in Scala

For the frontend, I used Scala.js. Instead of adopting a standard JavaScript framework, I built the UI using Tyrian. Tyrian is a purely functional frontend framework for Scala that is heavily inspired by Elm's Model-View-Update (MVU) architecture.

The MVU pattern made handling the complex state of a chess trainer much simpler. Tyrian enforces a strict, unidirectional data flow. There is a single, immutable Model that represents the entire state of the application.

In practice, the application tracks a PuzzleState which keeps an immutable log of pastFens and solutionFens. When a user attempts a move, it dispatches a specific message to the update function. This function pattern matches the message, checks the attempt against the solutionFens, and returns a completely new PuzzleState without mutating the DOM directly. Because the update function is pure, the logic is highly testable. If the application enters an unexpected state, the sequence of messages provides a clear audit trail of what happened.

Managing Side Effects Cleanly

A web application cannot be entirely pure; it has to interact with the outside world. The application requires side effects, such as making HTTP requests to the backend or running chess engine analysis.

In Tyrian, side effects are handled cleanly. For example, it runs engine analysis in the browser by offloading Stockfish to a Web Worker. Instead of using Tyrian commands for direct execution, the frontend sends data via JavaScript interop (worker.postMessage) and listens to the engine's evaluations using Tyrian's Subscription (Sub) model. The worker's output is parsed into pure EngineMsg types, which re-enter the main update loop seamlessly. This keeps the core business logic pure while still allowing the application to utilize complex external WebAssembly tools in a separate thread.

Consistency Across the Stack

The backend mirrors this philosophy. It is built on the Typelevel stack, utilizing cats-effect for asynchronous execution, http4s for routing, and Skunk for database access.

Using Skunk allows the backend to query PostgreSQL for puzzle data and user statistics without blocking or relying on hidden mutable state. The algorithm that orchestrates the mathematical 6-puzzle wave buckets and time-based recovery periods relies heavily on this rigid, purely functional approach. Time-based active recovery loops and state-dependent puzzle bucket calculations would be difficult to manage securely with mutable state. The same functional principles applied on the frontend apply to the backend logic.

Takeaways

Using a unified functional language across the entire stack has been a practical choice. It reduces context switching and allows for sharing code, such as domain models and validation logic, between the frontend and backend.

More importantly, it provides a level of predictability that is highly valuable when dealing with complex, stateful applications. By strictly separating state transformations from side effects, debugging becomes a matter of tracing data flow rather than hunting down race conditions.

If you are interested in seeing the result, you can visit ChessKinetics.com.

Top comments (0)