Demo application (tic-tac-toe game and more) built with Web Components, with progressive enhancement.
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
sample-tictactoe/README.md

18 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

screenshot

Usage

  • npm ci to install dependencies;
  • npm check-and-run to run linting, typechecking, tests, build everything and serve it from PORT environment variable or from port 3000.

(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 reactive 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.

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.

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).

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.
  • typescript, because it's so much easier to write even small projects in TS than in JS (fixed at 5.7-rc because (1) for code to run in browser as-is without a bundler, I'm using ES modules; (2) for ts-node (used by node-tap) to work properly, imports need to be with .ts extension; (3) for frontend to work, imports need to be with compiled .js extension, and rewriting import extensions was only implemented in TS 5.7: https://devblogs.microsoft.com/typescript/announcing-typescript-5-7-rc/#path-rewriting-for-relative-paths which is not yet stable).
  • @tsconfig/strictest, to avoid
  • eslint, for linting (fixed at 9.14 because 9.15 had breaking changes in it, https://github.com/typescript-eslint/typescript-eslint/issues/10338).
  • prettier, to ensure common code style.
  • typescript-eslint, for type-aware and type-checked linting.
  • copyfiles, to copy static assets to the dist directory so that they can be next to compiled JS for frontend.
  • rimraf, to cleanup dist 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).

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).
  • Blink (e.g. Chromium and Chromium-based browsers): not checked, should work in reasonably recent versions, both with JS enabled (offline mode) and with JS disabled (plain old client-server mode).
  • 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.

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).
  • Target older ES versions on the frontend, so that offline-only game can work in all browsers fully supporting Web Components (i.e. Blink-based and Firefox not older than 2018); right now ES2022 is targeted.
  • Figure out better API for game board, board.get(row, column) and board.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?
  • 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.