Compare commits

..
Author SHA1 Message Date
branderson 245f499f04 docs: note restarting the app between screenshot runs 2026-10-15 19:37:05 -07:00
branderson c1350387b1 docs: draw demo game art as genre scenes
Tiles and screenshots were abstract shapes. Each is now a small scene for
its genre, with several layouts per genre steered by the game's tags, so
no two games look alike. The screenshot script pins the browser's date so
the Game of the Day doesn't change between runs.
2026-10-15 19:36:42 -07:00
branderson 5357622c2d docs: retake screenshots on made-up demo data
The README's screenshots showed real games whose authors never agreed to
be featured. scripts/demo/build-demo-data.mjs builds a Flashpoint install
of invented games with generated art, plus Pixel Penguin Panic, a small
HTML5 game written for it. scripts/demo/take-screenshots.mjs retakes the
screenshots from an app running on it.
2026-10-15 19:13:11 -07:00
branderson 89194dd94a Merge pull request 'Publish only a latest image tag' (#15) from latest-tag-only into main
Images / app (push) Successful in 4m49s
Images / session-manager (push) Successful in 1m43s
Images / streamer (push) Successful in 3m32s
Reviewed-on: #15
Reviewed-by: branderson <brad@branderson.io>
2026-10-15 17:35:04 -07:00
branderson 97168ea0e5 ci: publish only a latest image tag, built from main
Drops version tag builds and the main tag. Images report the short
commit SHA as their version.
2026-10-15 17:34:14 -07:00
branderson a2d899be6f Merge pull request 'Mirror main to GitHub and publish images to GHCR' (#14) from github-mirror into main
Images / app (push) Successful in 5m35s
Images / session-manager (push) Successful in 1m37s
Images / streamer (push) Successful in 3m32s
Reviewed-on: #14
Reviewed-by: branderson <brad@branderson.io>
2026-10-15 17:00:58 -07:00
13 changed files with 2504 additions and 20 deletions
+8 -7
View File
@@ -10,7 +10,6 @@ name: Images
on:
push:
branches: [main]
tags: ['v*.*.*']
workflow_dispatch:
# Jobs that run docker builds use dejima-privileged; anything else should use
@@ -59,6 +58,12 @@ jobs:
echo "host=$host" >> "$GITHUB_OUTPUT"
echo "owner=${GITHUB_REPOSITORY_OWNER,,}" >> "$GITHUB_OUTPUT"
# Shown in Settings, since images carry no version number
- name: Name the version
id: version
shell: bash
run: echo "sha=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
@@ -71,11 +76,7 @@ jobs:
uses: docker/metadata-action@v5
with:
images: ${{ steps.registry.outputs.host }}/${{ steps.registry.outputs.owner }}/${{ matrix.image }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=ref,event=branch
tags: type=raw,value=latest
- uses: docker/build-push-action@v6
with:
@@ -85,5 +86,5 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64
build-args: |
VERSION=${{ steps.meta.outputs.version }}
VERSION=${{ steps.version.outputs.sha }}
provenance: false
+8 -7
View File
@@ -7,7 +7,6 @@ name: Images
on:
push:
branches: [main]
tags: ['v*.*.*']
workflow_dispatch:
permissions:
@@ -39,6 +38,12 @@ jobs:
shell: bash
run: echo "name=${GITHUB_REPOSITORY_OWNER,,}" >> "$GITHUB_OUTPUT"
# Shown in Settings, since images carry no version number
- name: Name the version
id: version
shell: bash
run: echo "sha=${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
@@ -51,11 +56,7 @@ jobs:
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ steps.owner.outputs.name }}/${{ matrix.image }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
type=ref,event=branch
tags: type=raw,value=latest
- uses: docker/build-push-action@v6
with:
@@ -65,5 +66,5 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/amd64
build-args: |
VERSION=${{ steps.meta.outputs.version }}
VERSION=${{ steps.version.outputs.sha }}
provenance: false
+1
View File
@@ -17,6 +17,7 @@ the Dotcom Arcade application.
7. **[Common Pitfalls](./common-pitfalls.md)** - Known issues and solutions
8. **[Upstream Sync](./upstream-sync.md)** - Merging changes from flashpoint-web
9. **[GitHub Mirror](./github-mirror.md)** - Publishing to GitHub and GHCR
10. **[Demo Data](./demo-data.md)** - Made-up games for development and screenshots
## Quick Start
+45
View File
@@ -0,0 +1,45 @@
# Demo data
`scripts/demo/build-demo-data.mjs` builds a small Flashpoint install of made-up
games, for developing without a 2.3 TB download and for taking the README's
screenshots. Everything in it is invented:
- about 50 games with fake titles, studios and descriptions;
- generated tile art and screenshots;
- four curated playlists;
- **Pixel Penguin Panic**, a tiny HTML5 game written for the demo, which is
the only one that plays.
The database uses Flashpoint 14's schema (`scripts/demo/flashpoint-schema.sql`,
from [FPA-Rust](https://github.com/FlashpointProject/FPA-Rust), MIT).
## Using it
```sh
npm run install:all
node scripts/demo/build-demo-data.mjs /tmp/demo-flashpoint
```
Then point the app at it, with `FLASHPOINT_PATH=/tmp/demo-flashpoint` in
`backend/.env` for development, or `FLASHPOINT_HOST_PATH` in `.env` for Docker.
## Screenshots
The README's screenshots in `docs/images` come from the demo data, so they show
no real games. To retake them after a UI change:
1. Build the frontend (`npm run build`), then run the app on the demo data with
a fresh user database, serving the built frontend (`SERVE_FRONTEND=true`).
2. Install Playwright: `npm install --no-save playwright && npx playwright install chromium`.
3. Run `node scripts/demo/take-screenshots.mjs http://localhost:3100`. Restart
the app with a fresh user database before running it again: repeated runs
trip the login rate limit.
The script creates the first account, plays a few games for "Jump back in",
fixes the browser's date so the Game of the Day is always the same, moves the
Time Machine to the week the demo fills, and writes `home.png`, `game.png` and
`playing.png`.
Each game's tile and screenshot is a small scene drawn for its genre (a road
for driving games, a block grid for puzzles, and so on), with layouts varied by
the game's tags and seeded by its title, so every build draws the same art.
+3 -6
View File
@@ -29,13 +29,11 @@ All three are built from this repository by `docker compose up -d --build`.
### Prebuilt images
Images are published to GitHub's container registry. To pull them instead of
building, set `IMAGE_PREFIX` in `.env`, ending in `/`, and optionally
`IMAGE_TAG`. `latest` is the newest release, `1.2` the newest of a minor
version, and `main` follows the `main` branch.
building, set `IMAGE_PREFIX` in `.env`, ending in `/`. There's one tag,
`latest`, built from the tip of `main`.
```sh
IMAGE_PREFIX=ghcr.io/branderson/
IMAGE_TAG=latest
```
Then run `docker compose pull && docker compose up -d`. Images from another
@@ -53,8 +51,7 @@ Two workflows build the same images with the same tags:
`.gitea/workflows/images.yml` builds all three images on Gitea Actions and
pushes them to the Gitea instance's container registry, under the repository's
owner. Pushes to `main` publish a `main` tag; `v1.2.3` tags publish `1.2.3`,
`1.2` and `latest`. Its jobs run docker builds, so they need a runner labelled
owner. Each push to `main` publishes `latest`. Its jobs run docker builds, so they need a runner labelled
`dejima-privileged`; jobs that don't build images should use `dejima-confined`.
It also needs a repository secret, `REGISTRY_TOKEN`: an access token with
package write access, belonging to the repository's owner, whom the workflow
+1
View File
@@ -127,6 +127,7 @@ for Flashpoint Ultimate.
- [Common Pitfalls](08-development/common-pitfalls.md) - Known issues
- [Upstream Sync](08-development/upstream-sync.md) - Merging changes from flashpoint-web
- [GitHub Mirror](08-development/github-mirror.md) - Publishing to GitHub and GHCR
- [Demo Data](08-development/demo-data.md) - Made-up games for development and screenshots
### 09. Deployment
Binary file not shown.

Before

Width:  |  Height:  |  Size: 571 KiB

After

Width:  |  Height:  |  Size: 294 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 800 KiB

After

Width:  |  Height:  |  Size: 472 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 730 KiB

After

Width:  |  Height:  |  Size: 248 KiB

File diff suppressed because it is too large Load Diff
+246
View File
@@ -0,0 +1,246 @@
-- Flashpoint 14's database schema, the result of the migrations in
-- https://github.com/FlashpointProject/FPA-Rust (crates/flashpoint-archive),
-- Copyright (c) 2024 Flashpoint Archive, MIT License.
CREATE TABLE "additional_app" (
"id" varchar NOT NULL,
"applicationPath" varchar NOT NULL,
"autoRunBefore" boolean NOT NULL,
"launchCommand" varchar NOT NULL,
"name" varchar NOT NULL COLLATE NOCASE,
"waitForExit" boolean NOT NULL,
"parentGameId" varchar,
PRIMARY KEY("id")
);
CREATE TABLE "custom_id_order" (
"id" VARCHAR NOT NULL
);
CREATE TABLE ext_data (
"extId" varchar NOT NULL,
"gameId" varchar NOT NULL,
"data" jsonb,
PRIMARY KEY(extId, gameId)
);
CREATE TABLE "game" (
"id" varchar NOT NULL,
"parentGameId" varchar,
"title" varchar NOT NULL,
"alternateTitles" varchar NOT NULL,
"series" varchar NOT NULL,
"developer" varchar NOT NULL,
"publisher" varchar NOT NULL,
"dateAdded" datetime NOT NULL,
"dateModified" datetime NOT NULL DEFAULT (datetime('now')),
"broken" boolean NOT NULL,
"extreme" boolean NOT NULL,
"playMode" varchar NOT NULL,
"status" varchar NOT NULL,
"notes" varchar NOT NULL,
"source" varchar NOT NULL,
"applicationPath" varchar NOT NULL,
"launchCommand" varchar NOT NULL,
"releaseDate" varchar NOT NULL,
"version" varchar NOT NULL,
"originalDescription" varchar NOT NULL,
"language" varchar NOT NULL,
"library" varchar NOT NULL,
"orderTitle" varchar NOT NULL,
"activeDataId" integer,
"activeDataOnDisk" boolean NOT NULL DEFAULT (0),
"tagsStr" varchar NOT NULL DEFAULT ('') COLLATE NOCASE,
"platformsStr" varchar,
"platformId" integer,
"lastPlayed" datetime,
"playtime" integer DEFAULT 0,
"playCounter" integer DEFAULT 0,
"archiveState" integer DEFAULT 2,
"activeGameConfigId" integer,
"activeGameConfigOwner" varchar COLLATE NOCASE, "platformName" varchar COLLATE NOCASE, "ruffleSupport" varchar NOT NULL DEFAULT '', "logoPath" varchar, "screenshotPath" varchar, "owner" varchar,
PRIMARY KEY("id"),
CONSTRAINT "FK_45a9180069d42ac8231ff11acd0" FOREIGN KEY("parentGameId") REFERENCES "game"("id") ON DELETE NO ACTION ON UPDATE NO ACTION
);
CREATE TABLE "game_config" (
"id" integer NOT NULL,
"gameId" varchar NOT NULL COLLATE NOCASE,
"name" varchar NOT NULL COLLATE NOCASE,
"owner" varchar NOT NULL COLLATE NOCASE,
"middleware" varchar,
PRIMARY KEY("id" AUTOINCREMENT)
);
CREATE TABLE "game_data" (
"id" integer NOT NULL,
"gameId" varchar,
"title" varchar NOT NULL,
"dateAdded" datetime NOT NULL,
"sha256" varchar NOT NULL,
"crc32" integer NOT NULL,
"presentOnDisk" boolean NOT NULL DEFAULT (0),
"path" varchar,
"size" integer NOT NULL,
"parameters" varchar,
"applicationPath" varchar,
"launchCommand" varchar,
PRIMARY KEY("id" AUTOINCREMENT),
CONSTRAINT "UQ_gameid_dateadded" UNIQUE("gameId", "dateAdded"),
CONSTRAINT "FK_gamedata_gameid" FOREIGN KEY("gameId") REFERENCES "game"("id") ON DELETE NO ACTION ON UPDATE NO ACTION
);
CREATE TABLE "game_platforms_platform" (
"gameId" varchar NOT NULL,
"platformId" integer NOT NULL,
PRIMARY KEY("gameId","platformId")
);
CREATE TABLE "game_redirect" (
"id" VARCHAR NOT NULL,
"sourceId" VARCHAR NOT NULL,
"dateAdded" datetime,
PRIMARY KEY("id", "sourceId")
);
CREATE TABLE "game_tags_tag" (
"gameId" varchar NOT NULL,
"tagId" integer NOT NULL,
PRIMARY KEY("gameId","tagId")
);
CREATE TABLE "platform" (
"id" integer NOT NULL,
"dateModified" datetime NOT NULL DEFAULT (datetime('now')),
"primaryAliasId" integer,
"description" varchar NOT NULL DEFAULT '',
CONSTRAINT "REL_platform_primary_alias_unique" UNIQUE("primaryAliasId"),
PRIMARY KEY("id" AUTOINCREMENT)
);
CREATE TABLE "platform_alias" (
"id" integer NOT NULL,
"platformId" integer,
"name" varchar NOT NULL COLLATE NOCASE,
CONSTRAINT "UQ_platform_alias_name_unique" UNIQUE("name"),
PRIMARY KEY("id" AUTOINCREMENT)
);
CREATE TABLE "tag" (
"id" integer NOT NULL,
"dateModified" datetime NOT NULL DEFAULT (datetime('now')),
"primaryAliasId" integer,
"categoryId" integer,
"description" varchar NOT NULL DEFAULT '',
CONSTRAINT "REL_3c002904ab97fb1b4e61e8493c" UNIQUE("primaryAliasId"),
PRIMARY KEY("id" AUTOINCREMENT)
);
CREATE TABLE "tag_alias" (
"id" integer NOT NULL,
"tagId" integer,
"name" varchar NOT NULL COLLATE NOCASE,
CONSTRAINT "UQ_34d6ff6807129b3b193aea26789" UNIQUE("name"),
PRIMARY KEY("id" AUTOINCREMENT)
);
CREATE TABLE "tag_category" (
"id" integer NOT NULL,
"name" varchar NOT NULL COLLATE NOCASE,
"color" varchar NOT NULL,
"description" varchar,
CONSTRAINT "UQ_tag_category_name" UNIQUE("name"),
PRIMARY KEY("id" AUTOINCREMENT)
);
CREATE TABLE "tag_filter_index" (
"id" VARCHAR NOT NULL,
PRIMARY KEY("id")
);
CREATE TABLE "tag_filter_index_info" (
"key" VARCHAR NOT NULL, dirty INTEGER DEFAULT 1,
PRIMARY KEY("key")
);
CREATE INDEX "IDX_34d6ff6807129b3b193aea2678" ON "tag_alias" (
"name"
);
CREATE INDEX "IDX_6366e7093c3571f85f1b5ffd4f" ON "game_tags_tag" (
"gameId"
);
CREATE INDEX "IDX_d12253f0cbce01f030a9ced11d" ON "game_tags_tag" (
"tagId"
);
CREATE INDEX "IDX_ext_data_extId" ON "ext_data" (
"extId"
);
CREATE INDEX "IDX_ext_data_gameId" ON "ext_data" (
"gameId"
);
CREATE INDEX "IDX_gameTitle" ON "game" (
"title"
);
CREATE INDEX "IDX_game_activeDataId" ON "game" (
"activeDataId"
);
CREATE INDEX "IDX_game_config_game_id" ON "game_config" (
"gameId"
);
CREATE INDEX "IDX_lookup_dateAdded" ON "game" (
"library",
"dateAdded"
);
CREATE INDEX "IDX_lookup_dateModified" ON "game" (
"library",
"dateModified"
);
CREATE INDEX "IDX_lookup_developer" ON "game" (
"library",
"developer"
);
CREATE INDEX "IDX_lookup_lastPlayed" ON "game" (
"library",
"lastPlayed"
);
CREATE INDEX "IDX_lookup_playtime" ON "game" (
"library",
"playtime"
);
CREATE INDEX "IDX_lookup_publisher" ON "game" (
"library",
"publisher"
);
CREATE INDEX "IDX_lookup_series" ON "game" (
"library",
"series"
);
CREATE INDEX "IDX_lookup_title" ON "game" (
"library",
"title"
);
CREATE INDEX "IDX_redirect_sourceId" ON "game_redirect" (
"sourceId"
);
CREATE INDEX "IDX_total" ON "game" (
"library",
"broken",
"extreme"
);
+109
View File
@@ -0,0 +1,109 @@
<!doctype html>
<!-- Pixel Penguin Panic: a tiny game made for Dotcom Arcade's demo data. Catch
the fish, dodge the rocks. Plays itself until a key is pressed. -->
<html>
<head>
<meta charset="utf-8">
<title>Pixel Penguin Panic</title>
<style>
html, body { margin: 0; height: 100%; background: #0b1730; }
canvas { display: block; width: 100%; height: 100%; object-fit: contain; image-rendering: pixelated; }
</style>
</head>
<body>
<canvas id="game" width="640" height="480"></canvas>
<script>
const canvas = document.getElementById('game');
const ctx = canvas.getContext('2d');
const W = 640, H = 480, GROUND = 420;
let penguin = { x: W / 2, dir: 0 }, things = [], score = 0, best = 0, lives = 3;
let demo = true, tick = 0, flash = 0;
const keys = {};
addEventListener('keydown', (e) => { keys[e.key] = true; if (demo) { demo = false; reset(); } });
addEventListener('keyup', (e) => { keys[e.key] = false; });
canvas.addEventListener('pointerdown', () => { if (demo) { demo = false; reset(); } });
canvas.addEventListener('pointermove', (e) => {
if (demo) return;
const r = canvas.getBoundingClientRect();
penguin.x = ((e.clientX - r.left) / r.width) * W;
});
function reset() { things = []; score = 0; lives = 3; penguin.x = W / 2; }
function spawn() {
const rock = Math.random() < 0.3;
things.push({ x: 30 + Math.random() * (W - 60), y: -20, vy: 2 + Math.random() * 2 + score / 400, rock });
}
function update() {
tick++;
if (tick % 28 === 0) spawn();
if (demo) {
const fish = things.filter((t) => !t.rock).sort((a, b) => b.y - a.y)[0];
const target = fish ? fish.x : W / 2;
penguin.x += Math.max(-5, Math.min(5, target - penguin.x));
} else {
if (keys.ArrowLeft || keys.a) penguin.x -= 6;
if (keys.ArrowRight || keys.d) penguin.x += 6;
}
penguin.x = Math.max(24, Math.min(W - 24, penguin.x));
for (const t of things) {
t.y += t.vy;
if (t.y > GROUND - 40 && t.y < GROUND && Math.abs(t.x - penguin.x) < 30) {
t.done = true;
if (t.rock) { lives--; flash = 12; if (lives <= 0) { best = Math.max(best, score); reset(); } }
else score += 10;
}
if (t.y > H) t.done = true;
}
things = things.filter((t) => !t.done);
if (flash > 0) flash--;
}
function drawPenguin(x) {
const y = GROUND;
ctx.fillStyle = '#111827'; ctx.beginPath(); ctx.ellipse(x, y - 26, 22, 28, 0, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#f8fafc'; ctx.beginPath(); ctx.ellipse(x, y - 22, 14, 20, 0, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#fff'; ctx.fillRect(x - 9, y - 46, 7, 7); ctx.fillRect(x + 3, y - 46, 7, 7);
ctx.fillStyle = '#000'; ctx.fillRect(x - 6, y - 44, 3, 4); ctx.fillRect(x + 6, y - 44, 3, 4);
ctx.fillStyle = '#f59e0b'; ctx.fillRect(x - 5, y - 36, 10, 5); ctx.fillRect(x - 16, y - 2, 12, 4); ctx.fillRect(x + 4, y - 2, 12, 4);
}
function drawFish(t) {
ctx.fillStyle = '#38bdf8';
ctx.beginPath(); ctx.ellipse(t.x, t.y, 14, 8, 0, 0, Math.PI * 2); ctx.fill();
ctx.beginPath(); ctx.moveTo(t.x + 12, t.y); ctx.lineTo(t.x + 22, t.y - 8); ctx.lineTo(t.x + 22, t.y + 8); ctx.fill();
ctx.fillStyle = '#000'; ctx.fillRect(t.x - 8, t.y - 3, 3, 3);
}
function drawRock(t) {
ctx.fillStyle = '#64748b';
ctx.beginPath(); ctx.arc(t.x, t.y, 13, 0, Math.PI * 2); ctx.fill();
ctx.fillStyle = '#94a3b8'; ctx.fillRect(t.x - 6, t.y - 7, 5, 4);
}
function draw() {
const sky = ctx.createLinearGradient(0, 0, 0, H);
sky.addColorStop(0, '#1e3a8a'); sky.addColorStop(1, '#7dd3fc');
ctx.fillStyle = sky; ctx.fillRect(0, 0, W, H);
ctx.fillStyle = 'rgba(255,255,255,0.8)';
for (let i = 0; i < 40; i++) ctx.fillRect((i * 97 + tick * 0.3) % W, (i * 53) % 260, 2, 2);
ctx.fillStyle = '#e0f2fe'; ctx.fillRect(0, GROUND, W, H - GROUND);
ctx.fillStyle = '#bae6fd'; for (let x = 0; x < W; x += 40) ctx.fillRect(x, GROUND, 20, 6);
things.forEach((t) => (t.rock ? drawRock(t) : drawFish(t)));
drawPenguin(penguin.x);
if (flash % 4 > 1) { ctx.fillStyle = 'rgba(239,68,68,0.3)'; ctx.fillRect(0, 0, W, H); }
ctx.fillStyle = '#fff'; ctx.font = 'bold 20px monospace';
ctx.fillText(`SCORE ${String(score).padStart(5, '0')}`, 16, 32);
ctx.fillText('♥'.repeat(lives), W - 80, 32);
ctx.textAlign = 'center';
ctx.font = 'bold 34px monospace'; ctx.fillText('PIXEL PENGUIN PANIC', W / 2, 90);
if (demo && Math.floor(tick / 30) % 2) { ctx.font = 'bold 18px monospace'; ctx.fillText('PRESS ANY KEY', W / 2, 124); }
ctx.textAlign = 'left';
}
(function loop() { update(); draw(); requestAnimationFrame(loop); })();
</script>
</body>
</html>
+105
View File
@@ -0,0 +1,105 @@
#!/usr/bin/env node
// Takes the README's screenshots (docs/images) from an app running on the demo
// data, with a fresh user database. See docs/08-development/demo-data.md.
//
// node scripts/demo/take-screenshots.mjs [base URL, default http://localhost:3100]
//
// Needs Playwright: npm install --no-save playwright && npx playwright install chromium
import { dirname, join } from 'node:path';
import { chromium } from 'playwright';
const base = process.argv[2] ?? 'http://localhost:3100';
const images = join(dirname(new URL(import.meta.url).pathname), '../../docs/images');
const user = { username: 'demo', email: 'demo@example.com', password: 'demo-password' };
const browser = await chromium.launch();
const context = await browser.newContext({
baseURL: base,
viewport: { width: 1280, height: 1060 },
});
const page = await context.newPage();
const api = context.request;
async function call(method, url, data) {
const response = await api.fetch(url, { method, data });
if (!response.ok())
throw new Error(`${method} ${url}: ${response.status()} ${await response.text()}`);
return response.json();
}
const { needsSetup } = await call('GET', '/api/auth/setup-status');
if (needsSetup) await call('POST', '/api/auth/register', user);
const { tokens } = await call('POST', '/api/auth/login', {
username: user.username,
password: user.password,
});
const auth = { Authorization: `Bearer ${tokens.accessToken}` };
async function findGame(title) {
const response = await api.post('/api/games', {
data: { search: title, limit: 1 },
headers: auth,
});
const [game] = (await response.json()).data;
if (game?.title !== title) throw new Error(`demo game not found: ${title}`);
return game;
}
// Recently played, oldest first, for the homepage's "Jump back in"
for (const title of [
'Moon Mower',
'Jelly Jump',
'Laser Llama',
'Lemonade Empire',
'Crayon Kart',
'Drum Doodle',
'Pocket Pirates',
]) {
const game = await findGame(title);
const start = await api.post('/api/play/start', {
data: { gameId: game.id, gameTitle: game.title },
headers: auth,
});
const { sessionId } = await start.json();
await api.post('/api/play/end', { data: { sessionId }, headers: auth });
}
async function settle() {
await page.waitForLoadState('networkidle');
await page.waitForFunction(() => [...document.images].every((image) => image.complete));
await page.waitForTimeout(500);
}
// The Game of the Day follows the browser's date; this one picks The Lost Floppy
await page.clock.setFixedTime(new Date('2026-06-08T12:00:00'));
await page.goto('/login');
await page.getByPlaceholder('Enter your username').fill(user.username);
await page.getByPlaceholder('Enter your password').fill(user.password);
await page.locator('button[type="submit"]').click();
await page.waitForURL((url) => url.pathname !== '/login');
await page.goto('/');
await settle();
// The Time Machine opens on a random week; travel to the one the demo fills
await page.getByTitle('Pick a date').click();
await page.getByRole('combobox', { name: /month/i }).selectOption({ label: 'Jun' });
await page.getByRole('combobox', { name: /year/i }).selectOption('2008');
await page.getByRole('button', { name: /June 16(th)?, 2008/ }).click();
await settle();
await page.screenshot({ path: join(images, 'home.png') });
const penguin = await findGame('Pixel Penguin Panic');
await page.goto(`/browse/${penguin.id}`);
await settle();
await page.screenshot({ path: join(images, 'game.png') });
await page
.getByRole('button', { name: /^play$/i })
.first()
.click();
await page.waitForTimeout(6000);
await page.screenshot({ path: join(images, 'playing.png') });
await browser.close();
console.log(`Screenshots written to ${images}`);