20 KiB
Table of Contents
Assignment
Source: https://www.programmfabrik.de/en/assignment-frontend-backend-developer-job-berlin/
Code a tic-tac-toe game
Depending on the job you are applying for, you can code in Javascript (ECMA) or C++.
Requirements
- Use your own code only and start from scratch
- Player can choose the opponent to be human or computer
- Use [L]GPL'ed libraries if necessary, please include copyright notes
- Let us know how long it took you to code the game
JavaScript version
- Implement in Javascript so that it works in Mozilla Firefox
- Make use of CSS, provide nice visuals
- Make the back button work
C++ version
- Implement in C++, so that i works on the command line under Linux
- The opponent has to be unbeatable
Solution
Features
- Does not have any client-side dependencies, does not use any third-party libraries on the client.
- Works in modern browsers as a single-page application, in offline mode.
- Works in older browsers / browsers with JS disabled as a plain old multi-page application (requiring internet connection).
- Hopefully more accessible than a typical single-page application.
- Supports multiple games on the same page, playable at the same time.
- Supports different game rules (the provided main page has two games with different rule sets).
- Supports custom board size.
- The computer opponent is unbeatable.
- The user can choose for any player to become a computer opponent at any time.
- The user can even choose for both players to become computer opponents and let them finish the game.
- Browser back and forward buttons work as undo / redo buttons for user moves (and other user actions).
- Visuals are acceptable.
Screenshot
Usage
Tested to work on Node.js v20.15.1, should probably also work on all v20 and v22. Not tested with earlier Node.js versions. Node.js v23.2.0 (unstable; probably other releases of Node.js v23 as well) is known to result in errors inside tests runner (in third-party code).
npm ci
to install dependencies;npm run check-and-start
to run linting, typechecking, tests, build everything and serve it fromPORT
environment variable or from port 3000.npm run build-and-start
to only build everything and serve (if linting, typechecking or tests are failing for some reason).
(Yes, strictly speaking it's not just a static webpage with a bunch of client-side scripts. It's better. See "User interface" section in "Design" for more details.)
Design
Project layout
A monorepo would complicate things, so everything here is in a single package.
src/backend
for code that's running on backend only;src/frontend
for code (and static assets) that's running in browser only;src/shared
for code that's running on both.
No bundlers are used; all TS (and TSX) code is compiled to JS, preserving module imports;
and then files in src/frontend
and src/shared
are served at /frontend
and /shared
respectively.
User interface / handling interactions
Of course the obvious choice would be to implement everything in React or another similar framework, but where is the fun in that? I wanted to minimise the size of the resulting frontend application, and the best way to reduce the number of dependencies on frontend is to not have any dependencies, to write vanilla JS.
I've been hearing a lot about Web Components lately, so I decided to use this as an opportunity to learn more about them. But there are some major design choices to be made with Web Components too, different people use them in different ways.
I liked what I've read at https://adactio.com/journal/20618, https://toot.cat/@zkat/113134268521905449 and https://toot.cat/@zkat/113034798494038446, so I decided to use them to augment / enhance regular static HTML. I'm not sure if I was successful in trying to follow this approach, but it works!
So the key ideas are:
- Store game state in a query string parameter; make every interactable element on the page a button submitting this state;
- On backend, when the page is requested, generate a complete HTML page for this game state;
- On frontend, when JS is enabled and Web Components are supported: intercept button clicks and update the URL accordingly on the client side;
- On frontend, when JS is enabled and Web Components are supported: subscribe to URL changes and update the DOM accordingly (which in most cases (except for resizing the board) only involves setting some class names and button values and inner texts).
As a result:
- On the first visit, a complete fully working HTML page is served, and can be interacted with immediately.
- If JS is disabled, everything will still work! You click on a button, it submits a form, server generates another updated fully working HTML.
- But if JS is enabled, magic happens, you don't even need the network connection anymore. You click on a button, client-side script updates the URL, another client-side script notices this and updates the gaming board.
- When you click on back/forward browser buttons with JS disabled, server is going to serve fully working HTML every time for the relevant game state.
- When you click on back/forward browser buttons with JS enabled, client-side script notices this just the same as if you clicked on a game button, and updates the gaming board accordingly.
Plus some additional glue to make the components independent from each other, so that there can be many games on the same page at the same time, all in varying states, without conflicting or competing with each other.
And, since I had to generate HTML on the server, I needed a templating library for that. While I'm not using React on frontend here, JSX is a really nice language with good IDE support, so I decided to use it on backend, just as a template engine.
Main components of interest:
Progressive form
The idea: no matter whether JS is enabled or disabled, no matter whether the browser supports Web Components or not, submitting the form should result in page URL being updated with all the new parameters overriding the old ones (while the old parameters not present in the form should stay intact).
Additionally, if JS is enabled and Web Components are supported, this should happen entirely on the client, without any network requests.
In order to achieve this, the form must be submitted with method=POST (so that the current query parameters are not all erased), and then either the server will merge current query parameters with submitted data and redirect the client to the page with the new query parameters, or the client-side web component will do the merging.
- Server part is implemented in
src/backend/progressive-form.ts
. - Client part is implemented in
src/frontend/components/progressive-form.ts
. - Usage example is in
src/backend/components/counter.tsx
.
Game board
The idea: minimize the amount of DOM tree changes, and minimize the amount of duplicate code between frontend and backend.
So we have the main game element, to which a bunch of classes are applied dynamically, depending on the current state of the game. And various blocks of text / buttons which are displayed or not depending on the classes applied to the main element, according to CSS stylesheet. This helps to avoid having to store any texts in client-side JS; everything is already present in the generated HTML.
Additionally, the board itself is just a table with a button in every cell. And these buttons get their values and texts set dynamically, and get enabled (when it's a valid move) or disabled (when the cell is occupied, or when the game is over) automatically.
- Server part is implemented in
src/backend/components/boardgame.tsx
. - Client part is implemented as web component in
src/frontend/components/board-game.ts
. - Shared part is implemented in
src/shared/display.ts
.
Computer opponent
The idea: if the game is configured by user to use a computer player, and it's the move of a computer player, then compute the board state after the move of a computer player, and redirect the user to this new board state (in client-server mode) or replace the location with this new board state (in offline SPA mode), without leaving a trace of the intermediate state (when it was computer player's turn) in the history, so that the history back/forward button only work as undo/redo for user actions (with subsequent computer actions coupled to user actions that caused them).
- Server part is implemented in
src/backend/main/boardgame-handler.ts
- Client part is implemented in the same
src/frontend/components/board-game.ts
(see call toreplaceLocation
). - Shared part is implemented in
src/shared/gameplay/boardgame.ts
.
Gameplay
Mostly defined in src/shared/datatypes
, src/shared/gameplay
and src/shared/game-variants
.
The main idea is that every possible state of the board belongs to one of the four categories: player X won, player Y won, undecided (neither won but moves can still be made) and draw (neither won and moves can no longer be made because the board is full).
Different game rulesets differ in how they define the win.
And knowing which states are final in which way, we can walk through all possible gameplays (since thankfully the game graph is cycle-free, because on every move the number of free squares decreases until it reaches zero) to determine the eventual game outcome assuming perfect play, and the moves left until this outcome.
For example, if it's X move now, and by adding X to one of the free squares we can achieve the state "X won", this means that for the current state, the eventual outcome is "X wins in 1 move". If, no matter where we add X, we end up in a state with eventual outcome "O wins in 3 moves", this means that for the current state, the eventual outcome is "O wins in 4 moves". And so on.
The possible outcomes are ordered by their desirability (wins for the current player are the most preferable, then draws, then loses; smaller number of moves to win is preferable to larger number of moves to win, while for draws and loses the prolonged game is preferred), and then the most desirable one is picked and saved.
So for every board state that can be achieved in legal game, we know what's the most desirable outcome for the current player, achievable assuming that both players play perfectly in their own interests. And having only this data, it is easy to get the optimal move: just pick the one that will get you to the state where the most desirable outcome for the opponent is the same, just with one less move until the end.
This means that we can compute desirable outcomes for all possible achievable board states once, and then use this information to generate moves for the computer player.
The problem is that computing these states is somewhat computationally expensive. Easy to do for 3x3 board, but the number of possible achievable board states explodes exponentially with board size; for example for 4x4 board there are a bit under ten million achievable board states, according to https://math.stackexchange.com/a/613505.
If I was doing this in low-level language such as Zig, and for one fixed board size, I would probably manage to optimize the algorithm to run in a reasonable time and consume reasonable amounts of memory: e.g. every 4x4 board state could easily be compressed to a 32-bit value, which would also allow one to do some bit flipping to relatively quickly determine which squares are still free and generate next boards; determining winning boards would be as easy as applying 10 different bit masks... all in all, it would probably take O(n*log(n)) ~= on the order of hundreds of millions of instructions (i.e. fractions of a second), and under a hundred megabytes of RAM.
But writing such a heavily optimized code in JS, while also supporting custom board sizes, is very much outside of the scope I wanted to take, and I spent too much time on this project already.
The current inefficient JS implementation takes on the order of minute to compute all solutions for 4x4 board, while consuming gigabytes of RAM, so I decided to limit it to 12 cells (i.e. 4x3/3x4 boards) max.
The solutions are computed where they are needed (i.e. on backend for no-JS MPA, on frontend for SPA), and then cached.
Originally my idea was to use WebWorkers to compute them on frontend, to avoid blocking the UI, but since boards larger than 3x4 are not supported anyway, and all solutions for 3x4 boards are computed in fractions of a second, I decided that this complexity is not needed.
If computer player is selected for boards larger than 12 squares, no computer moves will be made, and humans will have to make moves both for O and for X.
Implementation: src/shared/gameplay/solver.ts
.
Tests
In addition to strict TS configuration (npm run typecheck
)
and strict typechecked eslint configuration (npm run lint
),
most of the shared code files are covered by extensive tests,
defined in .spec.ts
files next to the code files (and also src/shared/integration-tests
),
written with node-tap and runnable with npm test
(which also checks code coverage for tested files,
and by default returns an error if the coverage is not 100%).
Dependencies
Listed in package.json
, installed with npm ci
, not vendored.
Dev dependencies (build/compile-time)
tap
, a test framework (https://node-tap.org/).typescript
, because it's so much easier to write even small projects in TS than in JS.@tsconfig/strictest
, to avoid having to enable all the strict TS compiler options manually.eslint
, for linting.prettier
, to ensure common code style.typescript-eslint
, for type-aware and type-checked linting.copyfiles
, to copy static assets to thedist
directory so that they can be next to compiled JS for frontend.rimraf
, to cleanupdist
directory before building.
Backend dependencies (runtime)
express
, because I needed a minimal server framework to handle requests and serve static files.body-parser
, to handle POST requests in express.preact
,preact-render-to-string
, to render intermediate JSX code to HTML (because I'm using JSX as a template language on backend) (https://preactjs.com/).
Frontend dependencies (runtime)
None.
Supported browser engines
- Gecko (Firefox): works in v132, both with JS enabled (offline mode) and with JS disabled (plain old client-server mode). Should work in offline mode starting with Firefox 63 (2018); should work in plain old client-server mode in all versions, although the design in Firefox 51 (2017) and older will probably not look very good due to the lack of the recent CSS features.
- Blink (e.g. Chromium and Chromium-based browsers): superficially checked in Chromium 130, basic functionality seems to work. Should work in offline mode starting with Chrome 67 (2018); should work in plain old client-server mode in all versions, although the design in Chrome 56 (2017) and older will probably not look very good due to the lack of the recent CSS features.
- WebKit (e.g. qutebrowser and notably Safari): not checked; should not work in offline mode, because WebKit does not fully implement decade-old standard which Gecko and Blink supported since 2018: https://github.com/WebKit/standards-positions/issues/97. Everything should still work in online mode, falling back on client-server communication, except that the design in Safari before 10.1 (2017) will not look very good (but will still be functional), and will probably become disfunctional in Safari before 3.1 (2008).
- Servo: checked, does not work in nightly as of 2024-11-20, and I don't have an opportunity to figure out why (it's not packaged for Alpine (...yet), their regular Linux builds don't work on Alpine, so I had to check it on someone else's Windows machine).
Known issues and quirks
- The game does not work offline fully on client (i.e. without falling back on client-server communication) in WebKit-based browsers (such as qutebrowser or Safari), see above.
- Playing against the computer opponent is only supported for boards under 12 squares (i.e. 4x3 or 3x4 max).
- The computer player moves might be counterintuitive at times, and it might be difficult for a human player to get to lose the game, because the computer player always assumes that the other player plays perfectly.
- Boards larger than 12 squares are not supported with computer players (regardless of computer players selected, all moves on large boards must be made by humans); but the user does not get any explicit visible notification about this, the only explicit indication that something is wrong is an error message in console.
Remaining tasks
- Implement error handling and handling of incorrect / malformed game states (since they come from the client).
- Improve UI for large boards (disable autoplayers when board is too large, hide "enable autoplayer" buttons, provide clear indication to the user instead).
- Figure out better API for game board,
board.get(row, column)
andboard.get(row, column)
seemed to be a good way to encapsulate readonly state, but they became very inconvenient as the codebase grew and became more complex, considering that board does not even expose its dimensions. - Figure out better and more consistent naming and project structure.
- Implement end-to-end tests with puppeteer / chromedriver (and run them both with JS enabled and with JS disabled).
- Implement unit tests for remaining shared code.
- Implement integration tests for backend and frontend code, maybe?
- Ensure that everything is accessible.
- For better accessibility, maybe there is also a need for a form where an user can enter row number and column number to make a move, instead of clicking on a square (or activating it from keyboard, which works just fine now, but idk how it is compatible with screen readers exactly).
- Should I also add row number and column number hints to squares for screen readers? idk.
- Rewrite solver so that it doesn't take a minute to compute all games on 4x4 board (which only has under 10 millions states that can be encountered during legal play).
Time spent
By major chunks of work in git history:
- ~0.5 hours to set up the project;
- ~5 hours to implement game board serialization, tic-tac-toe rules and game solver (with tests);
- ~1 hour to set up the backend / frontend structure with JSX templating and static resources;
- ~2 hours to implement progressively enhanced form and query string value trackers (both on backend and in web components);
- ~3 hours to implement basic game UI in client-server mode;
- ~2 hours to enhance the most basic game UI features on frontend for offline mode;
- ~2 hours to make UI fully functional, to cleanup the code and reduce duplication between frontend and backend, and to make the resulting page somewhat presentable
- ~2 hours to implement another set of tic-tac-toe rules (with tests), support for changing board size (both on backend and in web components).
- ~2 hours to write this README completely.
...which makes it ~20 hours total. I definitely did not expect to spend that much; but also originally I didn't think that the scope will expand this much.