Prototyping a Chess Puzzle Game
in chess, javascriptMonster Chess is my attempt at a chess puzzle game that doesn’t require any knowledge of chess except the movement of the pieces.
Rules of Monster Chess:
- You can only move white pieces.
- Capturing a piece turns your current piece into the kind that you captured.
- Pawns do not promote on the back rank.
- You can refresh the page to reset the level.
There are 12 puzzles in all. Give them a try!
Read on to learn how the game was made.
Creating Monster Chess
I love chess tactics puzzles, but I hate playing chess. It’s a very contradictory hobby.
Unfortunately, to solve a chess puzzle one must have a deep understanding of the game of chess. Not just the movement of the pieces, mind you, but various tactics that make up fundamental strategy. Mating puzzles are the most obvious, but deeper into the realms of chess puzzles are forks, pins, skewers, sacrifices, discovered checks, zugzwang, and so on. Enjoying a chess puzzle is entirely hinged on knowing why a particular sequence of moves is the single correct answer. Not an easy feat!
Despite this, chess puzzles are beautifully expressive. A board position just begs to be solved, each piece artfully contributing constraints to the possibility space. White to move, mate in 2.

So, I got to thinking. What would a chess puzzle look like if it didn’t assume any prior knowledge of chess, except for the movement of the pieces?
The first thing I removed was the opponent. If we’re no longer playing the game of chess, but simply using the pieces of chess to define a puzzle language, the need for opponent interactions are gone. No more deducing opponent reactions in response to a move.
I recall Puzzmo’s paper puzzle remix of Really Bad Chess. It tweaked the classic mating puzzle format by introducing multiple kings into a single board position and asking for unique checkmate combinations by different pieces. It was a unique spin on the format, but retained too much chess tactic DNA for the result I wanted.
Without an opponent, tactic puzzles fall apart. Mating puzzles are fun, but lacking originality. This is when I stumbled into the central puzzle idea for Monster Chess: the piece that captures morphs into the one taken captive.
With the main idea out of the way, it didn’t take long to pin down the rest of the puzzle constraints. The win condition becomes very natural: clear the board of enemy pieces. Building interesting puzzles means constraining that movement set with pieces that are limited, like pawns. Each puzzle is focused on ensuring the player executes the proper sequence of moves else they get stuck in an irredeemable position.
Lichess-inspired tech stack
Around the time I was thinking about Monster Chess I was investigating the Lichess frontend architecture. Lichess doesn’t use a popular frontend framework, like React or Vue. Instead, it uses a tiny virtual DOM (VDOM) library called snabbdom. The architecture that stitches the snabbdom views, game logic, and server-rendered data together is custom-made by Lichess.
I can speculate why Lichess chose snabbdom over alternatives:
- Lichess predates modern frameworks
- Performance is key, fewer framework layers are a positive
- Maintainability takes precedence over shiny tools
- Lichess has unique UI considerations
Then again, why bother with a VDOM library at all?
I think the answer boils down to philosophical alignment with the Scala backend. Vanilla JS (or jQuery, which would’ve been more relevant in the 2010s) is fundamentally imperative. It’s up to the developer to figure out how they want to stitch together state and UI, and that process often involves wrapping state updates with targeted DOM mutations.
Here’s a simple example comparing a vanilla JS counter to a snabbdom counter.
Vanilla JS:
<button id="counter">Count: 0</button>
<!-- UI updates live a ways away from the DOM -->
<script>
let count = 0;
const button = document.getElementById("counter");
button.addEventListener("click", () => {
count++;
// Targeted DOM mutation in response to a state update
button.textContent = `Count: ${count}`;
});
</script>
Snabbdom:
let count = 0;
let container = document.getElementById("app");
// UI = f(state)
function view(count) {
return h("button", {
on: {
click: () => {
count++;
render();
}
}
}, `Count: ${count}`);
}
// The library handles figuring out which bits of state
// correspond to which DOM updates
function render() {
container = patch(container, view(count));
}
Snabbdom allows the UI to be represented declaratively as a function of state. Re-rendering concerns the entire application, not just a single unit of the DOM. Developers don’t need to worry about applying DOM updates as a result of state changes; instead they write their UI, write their application logic, and let re-renders handle the rest.
As you might expect, re-rendering the entire DOM tree would be prohibitively expensive and wasteful. Hence the virtual DOM. Snabbdom figures out which DOM nodes need to be updated based on state changes, using the VDOM diff as a guide. Then it updates only the necessary nodes.
Flux architecture for snabbdom
Zooming out a bit, it’s important to note that snabbdom is just a VDOM library. It does not prescribe a technique for managing state in your application or a philosophy around triggering re-renders. That’s the realm of a framework.
Lichess organizes state into controllers. A simplified example looks something like this:
class EditorCtrl {
castlingRights: CastlingRights
onChange() {
// Update state
this.castlingRights = computeCastlingRights();
// And trigger a re-render
this.rerender();
}
}
// UI = f(ctrl)
function view(ctrl: EditorCtrl) {
return h('div.board-editor',
{
on: {
click: () => {
ctrl.onChange()
}
}
},
[...])
}
// Initialize snabbdom onto the current page
export function initModule() {
// Container for game state and logic
const ctrl = new EditorCtrl(rerender);
// First render pass to generate initial DOM
const el = document.getElementById('board-editor')!;
let vnode = patch(el, view(ctrl));
// Trigger re-renders to update the UI after a state change
function rerender() {
vnode = patch(vnode, view(ctrl));
}
}
For the purposes of Monster Chess, I didn’t want to worry about manually triggering re-renders on state changes. Any meaningful action in Monster Chess is going to result in a UI update, so I can simply re-render indiscriminately. So, I took a few liberties with the Lichess style of things by adapting the data flow to something that resembles Flux (so 2014, dude).
Here’s a simplified counter example, demonstrating the structure:
class Store {
state = { count: 0 };
// If you don't like mutation, you could always go redux-style:
// this.state = reduce(action)
update(action) {
switch (action.type) {
case "inc":
this.state.count++;
break;
case "dec":
this.state.count--;
break;
}
}
}
const view = (dispatch) => (state) => {
return h("div", [
h("p", `count: ${state.count}`),
h("button", {
on: { click: () => dispatch({ type: "inc" }) }
}, "+"),
h("button", {
on: { click: () => dispatch({ type: "dec" }) }
}, "-"),
]);
};
// Bootstrap boilerplate
window.addEventListener("DOMContentLoaded", () => {
const container = document.querySelector("#app");
const store = new Store();
// When actions are dispatched to the store, we always
// re-render afterwards.
function dispatch(action: Action) {
store.update(action);
render();
}
const makeView = view(dispatch);
let vnode = patch(container, makeView(store.state));
function render() {
vnode = patch(vnode, makeView(store.state));
}
});
The view is constructed with two parameters, dispatch and state.
dispatch passes messages through the store and triggers a re-render.
During the render pass, snabbdom diffs the VDOM and applies changes to
the UI based on the most recent value of state.
I’m sure that Lichess benefits from more control over the render lifecycle of its application, but I definitely appreciate the simplicity of Flux. The view doesn’t call directly into a stateful controller, it merely reads state for a render pass and dispatches the occasional action back into the store.
To put things into more concrete terms, here’s a view into Monster
Chess’s Action type, demonstrating all of the events that are
dispatched by the view:
export type Action =
| { type: 'change_mode'; payload: { mode: 'select' } }
| { type: 'select_level'; payload: { level: number } }
| { type: 'select_piece'; payload: { piece: Piece } }
| { type: 'deselect' }
| {
type: 'move'
payload: {
to: Square
original: Piece
captured: Piece | undefined
}
}
Why not just use React?
Simply put: novelty. I wanted to see how far I could take a VDOM library without all of the extra framework primitives.
That said, I would generally avoid React for projects like Monster
Chess. React is the kind of tool that is acceptable across the
board, but doesn’t excel in any particular category. Its main draw is
mindshare, reducing the number of decisions made by large teams. Its
API is complicated (useEffect dependencies, anyone?) and its VDOM
algorithm is now a complicated concurrent scheduler. For tiny web games
it’s overkill.
If you’re interested in making your own games with this snabbdom stack, check out my game template.