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.
357 lines
19 KiB
357 lines
19 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.
|
|
* 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
|
|
|
|
* `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.
|
|
|
|
(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.
|
|
|
|
* 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.
|
|
* `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?
|
|
* 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.
|
|
|