Demo application (tic-tac-toe game and more) built with Web Components, with progressive enhancement / graceful degradation.
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.
 
 
 
Inga 🏳‍🌈 668d2ced3b readme: minor clarification 2 weeks ago
docs README: added ToC, added screenshot, fixed links 1 month ago
src improved display of decided board 2 weeks ago
.gitignore initial npm project structure, linting / ts 1 month ago
.prettierrc improved formatting (line width set to 120) 1 month ago
LICENSE Initial commit 1 month ago
README.md readme: minor clarification 2 weeks ago
eslint.config.js implemented generic solver tests; extracted tictactoe-specific solver tests to integration tests file 1 month ago
package-lock.json added typed-query-selector dev dependency in preparation to templates migration 3 weeks ago
package.json added typed-query-selector dev dependency in preparation to templates migration 3 weeks ago
tsconfig.build.json implemented tictactoe-all ruleset 1 month ago
tsconfig.json downgrade target from ES2022 to ES6 for better compatibility with older browsers 4 weeks ago

README.md

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 (which includes at least 96% overall and at least 98% desktop userbase; includes Chrome since 2017, Firefox since 2018, Safari since 2017) as a single-page application, in offline mode.
  • Works in older browsers / browsers with JS disabled (which, together with the previous point, covers 99.9%+ overall userbase, including text-based browsers and presumably IE6 and probably IE5) as a plain old multi-page application (requiring internet connection).
  • Doesn't use browser detection / sniffing, instead gracefully degrading depending on the feature set available.
  • Hopefully more accessible than a typical single-page application.
  • Supports multiple games on the same page, playable at the same time, concurrently and independently.
  • 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

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 from PORT 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.

Also, I wanted to make this work everywhere (or almost everywhere), in the spirit of GOV.UK public services: https://shkspr.mobi/blog/2021/01/the-unreasonable-effectiveness-of-simple-html/

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 (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 the dist directory so that they can be next to compiled JS for frontend.
  • rimraf, to cleanup dist directory before building.
  • typed-query-selector, because https://github.com/microsoft/TypeScript/issues/29037 is not addressed yet.

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

There are three main fully functional modes:

  1. Main simple offline-based JS-only mode, using modern web standards;
  2. More elaborate (almost) offline-based (almost) JS-only mode (game works offline, but demo counters on the top of the page (separate from the game) only work online because I don't have the motivation to redesign ReactiveButton and ReactiveSpan), using modern web standards without custom built-in elements (which means old, but not too old Blink-based browsers like Chromium 54-66, and all modern WebKit-based browsers like Safari 10.1+);
  3. Online-based (plain old client-server) mode available in most browsers even with JS disabled.

The main required features are:

Therefore, for desktop browsers:

  • Gecko (Firefox): checked and works in v132, both with JS enabled (mode 1) and with JS disabled (mode 3). Should work in mode 1 starting with Firefox 63 (2018); should fall back 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 in mode 1. Should work in mode 1 starting with Chrome 67 (2018); should fall back to mode 2 in Chrome 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, Konqueror, Epiphany and notably Safari): superficialy checked in BadWolf 1.3 / webkit2gtk-4.1 2.46.3, basic functionality seems to work both with JS enabled (in mode 2) and with JS disabled (in mode 3). Should work with JS enabled in mode 2 in all browsers supporting custom elements (e.g. in Safari 10.1 (2017) and newer); should fall back to mode 3 in older browsers, 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 as of 2024-11-25 (even progressive form doesn't work), and its developer tools host seemingly crashes on this page (at least on the borrowed Windows machine; Servo is not packaged for Alpine, and its regular Linux builds don't run on Alpine). I decided to not disable dynamic features in Servo because:
    1. Servo is not production-ready, its users know that it is not suitable to be used as a daily driver;
    2. This is likely a bug in servo, it will be fixed in the future, so
    3. Gating offline JS mode for non-servo browsers only will mean that in the future, when servo is fixed, this demo will only work in client-server mode, just because servo once had a bug. Not ideal. (Also not even attempting to use dynamic features in servo will make it more difficult to debug the problem in it.)
  • EdgeHTML (classic MS Edge before they migrated to Chromium): not checked, should fall back to mode 3 in all versions, but the page will not look very pretty in Edge before 16 (2017).
  • Trident (Internet Explorer): not checked, should fall back to mode 3, UI should be functional but not pretty.
  • links: checked on 2.30, mostly functional:
    • Links doesn't support disabled buttons so all board fields are always clickable (even if they're already filled), this does not cause any functional issues (clicking on the occupied field, or clicking on an empty field when the game is over, does not change anything), but still that's a poor UX.
  • elinks: checked on 0.17.1.1, mostly functional:
    • elinks just like links does not support disabled buttons in 0.17.1.1, but this is fixed in https://github.com/rkd77/elinks/issues/341.
    • elinks does not support hidden attribute and by default does not support display: none in 0.17.1.1, so it will display the content of all (unused) templates. Support for hidden attribute is already implemented in https://github.com/rkd77/elinks/issues/341, and will automatically work in newer versions; in older versions, the user should add set document.css.ignore_display_none = 0 to their elinks config.
  • lynx: checked on 2.9.2, somewhat functional (mostly broken but can be used by those who wish):
    • lynx just like links and elinks does not support disabled buttons;
    • It does not support neither hidden attribute nor styles, so all (unused) templates are displayed;
    • It displays the value of submit buttons along with their text, which means that, for example, in every cell of the board along with its state (X/O/_) it also displays full serialized state of the next board.
  • w3m: checked on 0.5.3.20230718 (the newest version that my distro has to offer), broken due to 5d9c728592, might work in newer versions.

So presumably, according to caniuse.com,

  • mode 1 should work in 88% desktop browsers and 80% total browsers (with Safari being the notable exception contributing 9% on desktop and 18% overall);
  • Fallback to mode 2 (which is just as functional but with a bit more convoluted JS code) should bring the total to at least 96% overall (98% on desktop; for some less popular mobile browsers support status is unknown);
  • The demo should at least fall back to functional mode 3 (client-server) with the functional UI in all more or less mainstream browsers (including presumably Internet Explorer 6 and NetFront 3, unchecked), and in some non-mainstream ones, including text browsers such as links and elinks (checked) but notably not lynx.

Known issues and quirks

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