No description
  • TypeScript 83.5%
  • SCSS 9.3%
  • HTML 4.7%
  • C# 1.9%
  • JavaScript 0.5%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Nathan van Dael 30d2abeccf
All checks were successful
Auto Build & Push Multi-Arch Docker Image / build-and-push (push) Successful in 2m49s
feat: add PWA support with service worker and manifest
- Implemented a service worker (sw.js) to enable offline functionality and caching strategies.
- Created a web app manifest (manifest.webmanifest) for PWA installation and configuration.
- Developed AppInstall service to manage installation prompts and service worker registration.
- Added Haptics service for device vibration feedback during gameplay.
- Introduced Navigation service to manage app navigation and history.
- Implemented ScreenAwake service to prevent screen dimming during gameplay.
- Created unit tests for core functionalities including AppInstall, Haptics, Navigation, and ScreenAwake.
- Added server-side support for serving offline files and manifest.
- Implemented tests for offline file serving and manifest response.
2026-11-05 21:35:57 +01:00
.forgejo/workflows feat: implement Snakes and Ladders game setup and logic 2026-10-23 17:28:44 +02:00
client feat: add PWA support with service worker and manifest 2026-11-05 21:35:57 +01:00
server feat: add PWA support with service worker and manifest 2026-11-05 21:35:57 +01:00
.dockerignore feat: implement Snakes and Ladders game setup and logic 2026-10-23 17:28:44 +02:00
.env.example feat: add support for running the app in a subfolder behind a reverse proxy 2026-10-28 15:28:46 +01:00
.gitignore feat: implement Snakes and Ladders game setup and logic 2026-10-23 17:28:44 +02:00
docker-compose.yml feat: implement Snakes and Ladders game setup and logic 2026-10-23 17:28:44 +02:00
Dockerfile feat: implement Snakes and Ladders game setup and logic 2026-10-23 17:28:44 +02:00
LICENSE Initial commit 2026-10-23 14:16:05 +02:00
Minigames.slnx feat: implement Snakes and Ladders game setup and logic 2026-10-23 17:28:44 +02:00
README.md feat: add PWA support with service worker and manifest 2026-11-05 21:35:57 +01:00

minigames

A collection of minigames.

Analytics are provided by a self-hosted Matomo instance.

  • client/: Angular frontend (the games, Matomo)
  • server/Minigames.Api/: ASP.NET Core Web API (.NET 10); serves the Angular app in the Docker image
  • server/Minigames.Api.Tests/: integration tests for the API (xUnit, WebApplicationFactory)

Structure

In client/src/app/:

  • features/<name>/: one folder per screen or game, loaded lazily from app.routes.ts; features/home/ is the menu with the games, one category at a time (Dice, Board, Cards, Puzzles); the browser remembers the category chosen last
  • shared/: the components every game is built from (see House style), and the player colours in players.ts
  • core/: services shared by all screens, such as Matomo

To add a game: give it a folder in features/, its two screens (setup and play) in app.routes.ts with game(…), a tile in home.ts with its category, and its texts under games.<id> in the translations, and build its screens from the shared components.

Games

Players take turns on one screen; where a game's notes say what the computer does, any of them can be the computer. Every game is saved in the browser as it goes, so a reload, or a phone closing the tab, continues it, and the setup screen remembers the last players. Each has its rules in how-to-play/ and its logic, with tests, in rules.ts, the computer's moves included. The table follows the menu: its categories, and the order within them.

Category Game Folder Players Notes
Dice Snakes and Ladders snakes-and-ladders 2–4 Roll the die and walk that many squares; the foot of a ladder takes you up, the head of a snake down. Land exactly on 100 to win; a roll past it counts back. A 6 rolls again. Every game gets a new random board: 9 ladders and 10 snakes, as on the classic board, two of each long (30–50 squares) and the rest medium or short. Squares 1 and 100 stay free, no square holds two ends, and a row has at most two ladder feet and two snake heads. The board is then tidied up: ladders and snakes move until as few as possible cross or touch
Dice Game of the Goose game-of-the-goose 2–4 Throw two dice and walk the total along 63 squares that wind inward from the start in the corner; wooden walls between the rounds show the way. A goose (5, 9, 14, 18, 23, 27, 32, 36, 41, 45, 50, 54, 59) walks the throw again. A 9 from the start goes to 26 (6 and 3) or 53 (5 and 4), else the geese would carry it to 63 in one throw. 6 Bridge: on to 12. 19 Inn: sit out one turn. 31 Well: wait until another player falls in, who takes your place. 42 Maze: back to 37. 52 Prison: sit out three turns (not "wait to be freed": with two players, one in the well and one in prison would both be stuck). 58 Death: back to the start. Land exactly on 63 to win; a throw past it counts back. The goose is drawn in goose.svg, as the goose emoji is missing on many phones
Dice Ludo ludo 2–4 The classic cross: out with a 6, three throws for it with no pawn on the track, a 6 throws again, knocking back, the home row exact and without jumping your own. Two players sit opposite. The player taps the pawn to move; the only move goes by itself. The computer knocks back first, then brings out, and avoids stopping right in front of another pawn
Dice Yahtzee yahtzee 1–4 Three throws a turn, dice kept with a tap, 13 boxes, a bonus of 35 from 63 in the upper half, 100 for another Yahtzee (which then also counts as a full house or straight). The computer keeps the start of a straight or the face it has most of, and scores where the dice do best compared with what a box gives on average. Alone, the best score is kept
Dice Mia mexen 2–4 The bluffing dice game: the dice show only to who threw them, who then says a value, true or not, that beats the claim before. The next player believes and throws, or opens the dice; who was wrong loses a life, two over Mia (2 and 1, the highest). Between two people a screen asks the others to look away; against computers it is not needed. Three lives each; who loses begins the next round. The computer tells the truth when it can and else bluffs just above the claim, and opens a claim the less likely it is
Dice Backgammon backgammon 2 The classic start, hitting, entering from the bar first, bearing off with the exact number or a higher one from the farthest point. As many dice must be played as can be, and when only one fits, the higher. A player taps a checker and then where it goes, die by die, and confirms with Done, or takes the turn back. Without any move, the dice pass on by themselves. The computer tries every way to play its dice and takes the one that leads to the best position: the race, its points held (most at home and side by side), and the checkers it leaves alone where they could be hit
Board Four in a Row connect-four 2 Who begins changes every game; wins are counted. The computer looks five moves ahead
Board Tic-tac-toe tic-tac-toe 2 The computer plays perfectly but for one move in five, else every game would be a draw; it never misses a win
Board Draughts draughts 2 International rules on 10 by 10: capturing is a must and takes the most pieces, backwards too; kings fly; captured pieces go after the move and cannot be jumped twice; a man becomes king only when its move ends on the far row; 25 moves each of only kings without a capture is a draw. The computer looks four moves ahead
Board Battleship battleship 2 The classic fleet of 5, 4, 3, 3 and 2 on 10 by 10; ships may touch but not overlap. A hit fires again, unless "Turn over" is chosen on the setup screen (the choice is remembered); a miss ends the turn. Each person places their fleet after a screen that asks the other one to look away (skipped against the computer); a reload while placing shows that screen again. In the battle the sea being fired at is large and the other one small, both always in view, with the ships hidden until they sink; against the computer a person sees their own fleet. The computer counts where the ships still afloat could lie, finishes a ship once it hit one, and while searching fires one shot in three at random
Board Dots and Boxes dots-and-boxes 2–4 4, 5 or 6 boxes on each side (the choice is remembered). Who closes a box draws again; the lines of the last turn show in that player's colour. Every open line is a diamond-shaped button around its middle, so the diamonds fill the sheet and a tap anywhere draws the nearest line. Who begins moves on one seat every game. The computer closes a box when it can, else draws a line that gives nothing away, and when every line gives something away, the one that gives the fewest boxes
Board Nine Men's Morris mill 2 Nine pieces each, placed one by one, then moved along the lines. A mill takes one of the other's pieces, one in a mill only when they have nothing else; with three pieces left a player may jump. Down to two pieces or unable to move loses; 50 turns without a piece taken is a draw. The pieces slide from point to point, and a piece taken fades where it stood. The computer looks ahead as far as it gets in 150 ms, one turn deeper each time, so it is as quick on a slow phone (only less deep)
Board Chess chess 2 All the rules: castling (not out of, through or into check), en passant, promotion to the piece chosen, mate and stalemate, the same position three times, fifty moves without a capture or pawn move, and too little left to mate. The pieces are chess signs in the colours of their players, sliding from square to square; the starter plays from the bottom, and the players swap sides every game. The computer counts material and where the pieces stand, follows exchanges to the end, and looks ahead as far as it gets in 400 ms
Board Reversi reversi 2 Four discs in the middle to begin; a disc must close in some of the other's, which turn over with a flip. A player without a move passes by itself, and when neither can move, the most discs win. Dots show where the person to move can go. The computer weighs the squares (the corners most, the ones next to them a danger) and room to move, and looks ahead as far as it gets in 300 ms
Board Kalah kalaha 2 Six pits each and a store, 4 or 6 seeds per pit (the choice is remembered). The seeds go round anticlockwise one by one, so the sowing can be followed; a last seed in the own store goes again, in an own empty pit it takes the pit across. Once a side is empty the rest goes to the other side's store. The seeds show as pebbles in the house colours. The computer looks ahead as far as it gets in 200 ms, an extra turn counted as its own
Board Halma halma 2–4 On 10 by 10 rather than the classic 16 by 16, so the squares stay big enough to tap on a phone: 15 pieces each in opposite corners with two players, 10 each in the four corners with three or four. A step to any square next to it, or a chain of jumps over any pieces; a tap on a piece shows every square it can reach, and the piece hops there jump by jump, leaving a trail. Whoever first fills the camp across, with at least one of their own in it, wins. The computer brings its pieces nearer the camp across, the ones lagging behind first, and does not go straight back
Cards Memory memory 1–4 16, 24 or 36 cards; a pair found goes again. The computer remembers seen cards seven times in ten. Alone, the fewest turns per size are kept
Cards Solitaire patience 1 Klondike: seven columns built down in alternating colours, the foundations up by suit, a king on an empty column, and the stock drawn one or three at a time (the choice is remembered), turned back over without limit. A tap moves a card where it fits when that is one place only; when it fits more, those light up to choose from. Undo goes back up to 40 steps. Once every card is face up, Finish puts them up one by one. The fewest moves per draw are kept
Cards Crazy Eights pesten 2–4 52 cards and two jokers, seven each, an ordinary card to begin. A 2 makes the next player take two and a joker five, passed on with another 2 or joker; a 7 plays again, an 8 skips the next player, an ace turns the direction round, and a jack goes on anything with the suit asked for. A card taken that fits may be played at once. The hand shows sorted by suit, the cards that may be played raised. Between two people a screen asks the others to look away, as in Mia. The computer follows the suit it holds most of and keeps its special cards for when the next player is nearly out, or to pass on cards to take
Puzzles 2048 twenty-forty-eight 1 Swipe or arrow keys. The tiles take the house colours: the board tints, the pawn colours, the purple and the wood. The best score is kept
Puzzles Minesweeper minesweeper 1 Three sizes that fit a phone held upright. The first tap is always safe; flags by a long press, a right click, or the 🚩 mode below the field; a tap on a number with enough flags opens around it. The fastest time per size is kept
Puzzles Hangman hangman 1 A word from public/words in the language chosen in the menu (German with Ä, Ö and Ü); words played recently are not picked again. Ten mistakes complete the drawing
Puzzles Sudoku sudoku 1 Every puzzle is new: a random full grid, with digits taken away one by one as long as it keeps exactly one solution, until 40, 32 or 25 are left (easy, medium, hard; the choice is remembered). Tap a cell and a digit; notes put small digits in a cell, and a digit placed leaves the notes of its row, column and box. A digit twice in a row, column or box turns red. Undo, and on a keyboard digits, Backspace, the arrow keys and N for notes. The fastest time per level is kept
Puzzles Sliding Puzzle sliding-puzzle 1 3, 4 or 5 tiles on each side (the choice is remembered), shuffled by sliding at random from solved, so every puzzle can be solved. A tap on a tile in the row or column of the empty place slides it and the tiles between at once; the arrow keys work too. A tile's tint tells its row, and its number turns purple in its place. The fewest moves per size are kept
Puzzles Codebreaker codebreaker 1–2 Ten tries to crack a code of 4 holes from 6 colours (easy: no colour twice; medium: repeats), or 5 from 8 (hard); the level is remembered. After each try a black mark per right colour in the right hole, a white one per right colour elsewhere. Each colour also has its own shape, for those who see colours alike. Alone, the computer makes the code; with two people, one makes it after a screen asks the other to look away, and they swap every game
Puzzles Peg Solitaire peg-solitaire 1 The English cross of 33 holes, all filled but the middle. A peg jumps straight over the one next to it into an empty hole, and the one jumped over goes; a tap on a peg shows where it can land. Over when no jump is left: one peg is done, one in the middle perfect. Back takes jumps back, all the way. The fewest pegs left are kept

The names are the English ones; the app shows each in the language chosen in the menu. A few folders carry the game's Dutch name (mexen, pesten, kalaha).

The API's controllers go in server/Minigames.Api/Controllers/, under the route api/…. In development the Angular dev server forwards /api to the API (see proxy.conf.json).

Mobile first

The games are made for a phone first. A tablet shows the same layout, only larger, and a desktop shows it as a column in the middle of the screen. The base for this is in styles.scss and app.scss:

  • Size everything in rem. The root font size grows with the screen's shorter side, from 16px on a phone to 22px on a large tablet, so a tablet enlarges the phone layout by itself. Turning the device sideways does not change the size. No breakpoints are needed for a tablet.
  • The column is at most --content-width (32rem) wide, with --gutter around it. The app's padding stays clear of the notch and the home bar.
  • Taps: make everything tappable at least --touch-target (2.75rem, 44px on a phone), and give it its own :active style; the grey tap flash is off. Double tapping does not zoom in.
  • Hover effects go inside @media (hover: hover), so they do not stick after a tap on a touch screen.
  • Full height: every screen takes the class screen (see House style), which fills the height. Use dvh, not vh, so a phone's browser bar is taken into account.

Test with the device toolbar of the browser's developer tools, for example at 390×844 (phone) and 820×1180 (tablet), and on a real phone through http://<your-pc's-ip>:4200 with npm start -- --host 0.0.0.0.

House style

Every game looks like part of the same app. Everything around the play area is the same in every game; only the play area itself is the game's own, and it is made from the same colours and materials. Snakes and Ladders and Game of the Goose are the examples.

Screens. A setup screen is app-game-setup with the game's rules between its tags, and its own choices, if any, in an element marked setupOptions (an app-segmented in a .card). A game screen has, from top to bottom: app-top-bar with app-rules-button in it, app-players-bar, the play area, and the controls in .game-controls; at the end app-result-dialog.

Games for one player (2048, Minesweeper, Hangman, Solitaire, Sudoku, Sliding Puzzle, Peg Solitaire) leave out players on app-game-setup and the players bar in the game; with the play area they show their own counts (score, mines, moves, time) as small .cards, the time through clock() in clock.ts. Their result dialog says how it went in detail and leads back to the menu (leaveLabel).

Rules. Every game has its rules in one component of its own, how-to-play/ in its folder, as a ul.rules. The same component stands on the setup screen and behind the ? button in the game, so the rules can always be read back. A square or piece that does something has the same sign in the rules as in the play area. While the rules are open, a computer player waits with its turn ([(opened)] on app-rules-button).

Component (shared/) What it is
app-top-bar The round back button and the screen's title; on the right whatever is put between its tags
app-rules-button A round ? button that opens the rules (between its tags) in a panel from the bottom
app-game-setup The setup screen: continue the running game, the players, Start, and the rules folded away
app-player-list Names, a person or the computer, adding and removing players (inside app-game-setup)
app-players-bar The players side by side; whose turn it is gets a border in their colour. detail shows how a player is doing: a square, a score
app-turn-info "Anna's turn" in the player's colour, and what is happening
app-die A die, or a few thrown together, to tap; in the colour of whose turn it is
app-die-face One face of a die, the look of every die; --die-size makes it smaller, such as the five of Yahtzee
app-pawn-layer The pawns over a board in a grid: they walk from cell to cell, stand side by side when they share one, and whose turn it is bobs. Pawns in selectable glow and can be tapped
app-segmented A choice between a few options in one rounded bar, such as the language or how hard
app-result-dialog Who won (or how it went, in detail), with "Play again" and "Other players" or another leaveLabel. It shows a moment after the game ended, so the last move is seen first; "View the game" folds it into a bar at the end of the screen, below the game, from which it opens again. So it must be the last element of the screen

Saving a game in the browser goes through storage.ts, and remembering the players of the last game through readPlayerSetup and writePlayerSetup in players.ts. The card games give their playing cards one look with the mixins in _playing-card.scss. A game with something to hide between people (Battleship, Mia, Crazy Eights, Codebreaker) shows a screen that asks the others to look away before the next person's turn; against the computer it is not needed.

On a phone. Three things make the app feel like one:

  • Going back is a step back in the browser's history, with the app's back button (app-top-bar) as with the phone's: navigation.ts keeps the screens visited in the tab and goes back as many steps as the screen lies, or takes the screen's place when it is not in the history (after opening a link straight to a game). So leaving a game, with ‹ or with "Other players", never adds a screen, and the phone's back button follows the same way: game, setup, menu. A game leaves through Navigation.back(), not router.navigateByUrl().
  • The screen stays on during a game (screen-awake.ts, with the browser's Wake Lock), on every /<game>/play screen and nowhere else.
  • The phone gives a short buzz when the turn goes to a person (app-players-bar) and a longer one at a win (app-result-dialog with 🏆), through haptics.ts. The menu has a switch for it on a phone or tablet that can vibrate; an iPhone cannot.

As an app (PWA). The app can be installed on a phone's home screen and then also works offline, every game in it:

  • manifest.webmanifest names it and points to its icons in public/icons (app-192.png, app-512.png, a maskable app-maskable-512.png for Android, and app-180.png for an iPhone): a white die on purple, as the favicon. Its paths are relative, so it works in a folder too.
  • sw.js is the service worker, written for this app rather than Angular's: Angular's lists the files with the folder of the build, while this app gets its folder only at start (BASE_PATH). It fetches the page from the network first, so a new version shows at once, and keeps it as the copy for every screen offline; the build's hashed files come from the cache, the texts and words too while it fetches newer ones. On every start it reads precache.json, the server's list of all the app's files (OfflineFiles.cs), keeps what it lacks and drops what is gone. api/ is never kept. When the device refuses to keep anything, the network's answer still goes through.
  • app-install.ts registers the worker (in a production build only, in the folder of the page's base address) and keeps the browser's offer to install, for the quiet "Install as an app" link at the end of the menu. On an iPhone: Share, "Add to Home Screen".

Themes. The menu has a dropdown with ten themes, remembered by the browser: Classic (the house style), Night, Ocean, Forest, Candy, Sunset, Space, Neon, Card table and Winter. A theme gives the same tokens other colours in styles.scss, under :root[data-theme='…']; theme.ts puts it on <html> and sets the browser's theme-color, and a small script in index.html puts the saved one there before the app starts, so the colours do not change as it opens. Each theme also has a faint background pattern (--pattern: dotted paper, stars, waves, polka dots, a grid, baize, snow…) and eight stickers of its own, emoji with a white edge, a little turned and slowly floating, scattered along the edges of the screen by decor.ts; the menu shows the theme's own sticker next to the title. The stickers lie behind the screen, faded, cannot be tapped and are hidden from screen readers; with reduced motion they stand still. The pawn colours stay the same in every theme, and playing cards stay white in the dark ones (--card-face, --card-ink). So a game's styles only ever use the tokens, also for a glow in the accent colour (color-mix(in srgb, var(--accent) 40%, transparent)). To add a theme, add its tokens and pattern, an entry with its icon and stickers in THEMES, and its name under themes.<id> in the translations.

Colours. Only the tokens in styles.scss:

  • The app: --bg, --surface, --text, --muted, --border and the purple --accent.
  • The play area: --tint-1, --tint-2 and --tint-3 (pink, yellow, blue) for squares, cells and cards; --board-edge, --wood and --wood-light for edges and wooden parts.
  • The players: red, blue, green and yellow from players.ts, in that order, so a seat has the same colour in every game. For CSS they are also --red, --blue, --green and --yellow, such as for the numbers of Minesweeper.
  • A game may have its own colours for its own pieces, such as the snakes, but not for buttons, text or backgrounds.

Play areas. A board lies on wood (--wood, edged with --board-edge) with sunken holes or squares in --bg or the tints, as in Four in a Row, Memory and 2048. Pieces are .pawns, marks and numbers in the player or house colours. Who found or won something gets an edge in their colour.

Shapes and classes. Panels are a .card (with an h2), or a details.card to fold away. Buttons are button, a quieter one button.secondary, a round one with a single sign (‹, ?) .round-button, and the one big button of a screen .main-action. A player's piece is a .pawn with --pawn-color. Corners are rounded (--radius), shadows soft (--shadow), and a board has a wooden edge of 0.25rem. A screen component takes host: { class: 'screen' }: a column at least as tall as the screen, its parts 1rem apart. A screen that may spread out on a large screen also takes wide-screen: its column is then up to --wide-width (62rem) wide instead of 32rem. The menu does so, with its tiles in as many columns as fit, so on a tablet or desktop all games of a category show without scrolling.

Text. Never in the code: every text is in the translations (see Languages), a game's own texts under games.<id>. Short, and addressed to the player ("Roll the die."). The title of a game's screens is the game's name; its tile in the menu has an icon and one sentence.

Languages

The app is available in Dutch, German and English. The texts are in client/public/i18n (nl.json, de.json, en.json). In templates they go through the t pipe ({{ 'turn.current' | t: { name } }}), in code through I18n.t(); see i18n.ts.

  • English is the fallback: a text missing in a language shows in English. A test makes sure every language has every text, with the same {placeholders}, so nothing quietly falls back.
  • The language is the browser's, unless the player chooses another one in the menu; the browser remembers that choice. The tab title follows it.
  • To add a language, create <code>.json and add the code to LANGUAGES in i18n.ts.

Local development

# API on http://localhost:5294; reads .env
cp .env.example .env
dotnet run --project server/Minigames.Api

# Frontend in a second terminal; open http://127.0.0.1:4200
cd client
npm install
npm start

Frontend tests: cd client && npm test. Server tests: dotnet test Minigames.slnx.

Analytics

Page views can be counted in a self-hosted Matomo. Add the minigames as a website in Matomo, or count them in an existing one, and set:

MATOMO_URL=https://analytics.example.org/
MATOMO_SITE_ID=2
  • Without both settings nothing is counted, and development builds (npm start) never count.
  • The browser gets the settings from /api/analytics. Matomo is loaded from the app's own code, not from an inline snippet.
  • The counted address is the page's path without its query; the page title (for example "Snakes and Ladders · Minigames") tells the games apart, also in a Matomo site shared with another website.

API

Method Path Description
GET /api/analytics The Matomo settings for the browser

Docker

The Dockerfile builds the frontend and the API into one image; the API serves the Angular app.

cp .env.example .env
docker compose up -d --build

Then open http://127.0.0.1:8080.

In a folder behind a reverse proxy

The app can also run in a folder of another website, for example https://games.example.org/minigames/. Set the folder:

BASE_PATH=/minigames
  • The server writes the folder into the page's <base href>, so the app's files, its pages and api/analytics are all found in the folder.
  • /minigames is sent on to /minigames/, and every page in the folder (/minigames/memory/play) gets the app, also after a reload.
  • The proxy forwards the full path, folder included; it should not cut the folder off.
  • Without BASE_PATH the app runs at the root, as before.

In Nginx Proxy Manager: open the proxy host of the website, add a Custom location /minigames, and forward it to the minigames container (for example http://docker-host port 40024) without a path after the host name.

Forgejo Actions

.forgejo/workflows/docker-publish.yml builds the image on every push to main (and monthly) and pushes it with a version tag and latest to the Forgejo container registry. This needs the repository secret REGISTRY_TOKEN.