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.
426 lines
25 KiB
426 lines
25 KiB
---
|
|
gitea: none
|
|
include_toc: true
|
|
---
|
|
|
|
# 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, 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](docs/screenshot.png)
|
|
|
|
## 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.
|
|
|
|
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`](src/backend/progressive-form.ts).
|
|
* Client part is implemented in [`src/frontend/components/progressive-form.ts`](src/frontend/components/progressive-form.ts).
|
|
* Usage example is in [`src/backend/components/counter.tsx`](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`](src/backend/components/boardgame.tsx).
|
|
* Client part is implemented as web component in [`src/frontend/components/board-game.ts`](src/frontend/components/board-game.ts).
|
|
* Shared part is implemented in [`src/shared/display.ts`](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`](src/backend/main/boardgame-handler.ts)
|
|
* Client part is implemented in the same [`src/frontend/components/board-game.ts`](src/frontend/components/board-game.ts) (see call to `replaceLocation`).
|
|
* Shared part is implemented in [`src/shared/gameplay/boardgame.ts`](src/shared/gameplay/boardgame.ts).
|
|
|
|
### Gameplay
|
|
|
|
Mostly defined in [`src/shared/datatypes`](src/shared/datatypes), [`src/shared/gameplay`](src/shared/gameplay) and [`src/shared/game-variants`](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`](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
|
|
|
|
The main required features are:
|
|
|
|
* Custom elements: https://developer.mozilla.org/en-US/docs/Web/API/CustomElementRegistry/define
|
|
(this demo will fall back to client-server mode if `window.customElements.define` is not available).
|
|
* Custom build-in elements: https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/is
|
|
(again, this demo will fall back to client-server mode if this is not supported).
|
|
* ES modules: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type.
|
|
* Also CSS grid for nice presentation: https://developer.mozilla.org/en-US/docs/Web/CSS/grid-template
|
|
(presentation is less nice on small screens, should be easy to make layout responsive, but I already spent too much time on this project).
|
|
* And `:not` CSS selector for critical UI features,
|
|
(hiding and showing different messages and buttons depending on the game state):
|
|
https://developer.mozilla.org/en-US/docs/Web/CSS/:not.
|
|
|
|
Therefore, for desktop browsers:
|
|
|
|
* Gecko (Firefox): checked and 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 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.
|
|
Should work in offline mode starting with Chrome 67 (2018); should fall back to 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): 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 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 client-server mode 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 client-server mode,
|
|
UI should be functional (not pretty) in IE8+ (2008) and dysfunctional in IE7 and lower
|
|
(fixable by refactoring the approach to displaying/hiding elements to avoid the usage of `:not` selector,
|
|
but this is outside the scope of the task).
|
|
* links: checked on 2.30, half-broken:
|
|
* Everything that depends on the game state is always displayed,
|
|
i.e. we get "Start game", "Restart game", "make computer play for X",
|
|
"make X moves manually", and most importantly,
|
|
"Player X won", "Player Y won" and "Draw" all displayed always;
|
|
this can be worked around by using Web Components templates
|
|
instead of controlling state visibility by CSS.
|
|
* Additionally, 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, half broken in the same way as on links, not easily fixable
|
|
because even with Web Components templates, elinks will still display template content:
|
|
https://github.com/rkd77/elinks/issues/341.
|
|
Fixable by building all state-dependent components on frontend manually
|
|
(so that backend only sends those that are relevant for the current state),
|
|
but that would be too significant redesign for no benefit besides making things work in elinks.
|
|
|
|
So presumably, according to caniuse.com,
|
|
|
|
* Offline JS mode should work in 88% desktop browsers and 80% total browsers
|
|
(with Safari being the notable exception contributing 9% on desktop and 18% overall);
|
|
* The demo should at least fall back to functional client-server model with the same UI
|
|
in at least 98% desktop and at least 96% overall (and likely in at least 98% overall)
|
|
(with IE lacking support for grids contributing ~1% on desktop and ~0.5% overall,
|
|
and UC Browser and QQ Browser with unknown status of support for grids contributing further 2% overall);
|
|
* This demo should at least fall back to functional client-server model with at least functional (but not neccessary pretty) UI
|
|
in 99.9% desktop and at least 97% overall browsers (and likely at least 99% overall)
|
|
(with older versions of IE lacking support for `:not` selectors contributing 0.1% on desktop,
|
|
and UC Browser and QQ Browser with unknown status contributing 2% overall;
|
|
it is highly likely that they do actually support `:not` selectors).
|
|
|
|
## 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
|
|
|
|
* Use HTML `<template>`s for rendering state messages,
|
|
instead of having them all in HTML and then hiding irrelevant with CSS,
|
|
for better graceful degradation in browsers that don't support complex CSS queries with `:not`.
|
|
* Implement custom progressive form wrapper element (use `MutationObserver` to find when forms are added/removed),
|
|
use it instead of custom built-in element in browsers not supporting custom built-in elements
|
|
(i.e. all modern WebKit browsers including Safari 10.1+, and Chrome 54 to 66),
|
|
so that these browsers will get offline client-only support too,
|
|
instead of falling back to client-server mode.
|
|
* 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.
|
|
|