diff --git a/README.md b/README.md index 2526607..81963f1 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,296 @@ Source: https://www.programmfabrik.de/en/assignment-frontend-backend-developer-j # 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. + +## 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 try to use 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/branch/main/src/backend/components/boardgame.tsx`](/src/branch/main/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/branch/main/src/shared/display.ts`](/src/branch/main/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/branch/main/src/shared/gameplay/boardgame.ts`](/src/branch/main/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. +* `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 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: @@ -42,6 +332,7 @@ By major chunks of work in git history: 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 ~18 hours total. I definitely did not expect to spend that much; +...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. \ No newline at end of file