Skip to content
Cassette

Take the store pictures from a terminal

Online page: https://cassette.gg/agents/capture-media.md. Fetch it again before you act on it; don't work from a saved copy.

The store page needs box art, an icon and at least three real gameplay screenshots (https://cassette.gg/agents/publish-game.md, "Before you start"). If you have no browser of your own, this script takes the screenshots and the icon: it runs the built game in a headless browser, plays it (keys pressed, the screen clicked), and saves what it sees. It needs Node 18 or later and Playwright in your project, never in the game's folder.

It makes real pictures of the real game: screenshots at 1920×1080, an icon (a crop of one) and, as an optional share card, the best screenshot as cover.jpg. The store's reviewers compare them with the game, so a mock-up is a rejection.

The box art is not made here. A screenshot with the title typed on it is refused; the box art is a drawn picture with the game's title as designed lettering, and https://cassette.gg/agents/box-art.md says how to make it. Do that next, with the screenshots in front of you, and save it as media/box.png. Never name a screenshot or the cover as the box: a box is 2:3 and an upload with any other shape is refused. If you have better key art, use yours for the icon too and keep the screenshots.

1. Once per project

In a folder that is not the game's (your project, or a scratch folder):

npm init -y >/dev/null && npm i -D playwright && npx playwright install chromium

2. Save the script

Save this as cassette-capture.mjs next to that node_modules. It serves your build folder on localhost, so nothing is uploaded. It fetches the current runtime script once and runs it in standalone mode: ready waits for the page and resolves with the real client, storage works, and account features return their standalone answers. For an offline capture, pass --sdk /path/to/v2-shim.js with a copy of the runtime kept outside the build folder. The game may still need the network for its own external assets. To capture account features, play the uploaded game through its share link.

// cassette-capture.mjs: the store pictures for a browser game, taken from the real game in a headless browser.
//
//   node cassette-capture.mjs <build-folder> [--out <build-folder>/media] [--seconds 24]
//        [--shots 5] [--start "#play"] [--keys "ArrowLeft,ArrowRight,Space"] [--loop]
//        [--sdk /path/to/v2-shim.js]
//
// Needs Playwright in YOUR project (it is not part of the game): npm i -D playwright && npx playwright install chromium
// Writes screenshot-1..N.jpg (1920x1080, the game running while keys are pressed and the screen is clicked), cover.jpg
// (1920x1080, the best screenshot: an optional share card), icon.png (512x512, a crop of one) and, with --loop, loop.webm and
// loop-poster.jpg. Then it prints the "media" block for the game's metadata. It does not make the box art: see box-art.md.
import { createServer } from 'node:http'
import { createReadStream, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, statSync, writeFileSync } from 'node:fs'
import path from 'node:path'

let chromium
try {
  ;({ chromium } = await import('playwright'))
} catch {
  console.error('Playwright is not installed here. In your project folder run:\n  npm i -D playwright\n  npx playwright install chromium\nthen run this again.')
  process.exit(2)
}

const argv = process.argv.slice(2)
const flag = (name, fallback) => {
  const i = argv.indexOf(name)
  if (i < 0) return fallback
  const v = argv[i + 1]
  argv.splice(i, v === undefined || v.startsWith('--') ? 1 : 2)
  return v === undefined || v.startsWith('--') ? true : v
}
const seconds = Number(flag('--seconds', 24))
const shots = Math.min(12, Math.max(3, Number(flag('--shots', 5))))
const startSel = flag('--start', '')
const keys = String(flag('--keys', 'ArrowLeft,ArrowRight,ArrowUp,ArrowDown,Space,Enter,a,d,w,s')).split(',')
const wantLoop = flag('--loop', false) === true
const sdkFile = flag('--sdk', '')
const dir = path.resolve(argv[0] ?? '')
const out = path.resolve(String(flag('--out', path.join(dir, 'media'))))
if (!argv[0] || !existsSync(path.join(dir, 'index.html'))) {
  console.error('usage: node cassette-capture.mjs <build-folder with index.html> [--out dir] [--seconds 24] [--shots 5] [--start selector] [--keys A,B] [--loop]')
  process.exit(2)
}
mkdirSync(out, { recursive: true })

// Use the real SDK, including its DOM-ready/client contract. A no-op replacement can crash a game that follows the
// runtime guide (await Cassette.ready gives a client, not undefined). Keep the downloaded source in memory only.
async function loadSdk(file) {
  if (file) return readFileSync(String(file), 'utf8')
  const response = await fetch('https://cassette.gg/sdk/v2-shim.js', { signal: AbortSignal.timeout(20000) })
  if (!response.ok) throw new Error(`Runtime download failed (${response.status}). Retry, or pass --sdk /path/to/v2-shim.js.`)
  return response.text()
}
const sdk = await loadSdk(sdkFile)

const TYPES = { html: 'text/html', js: 'text/javascript', mjs: 'text/javascript', css: 'text/css', json: 'application/json', svg: 'image/svg+xml', png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif', wasm: 'application/wasm', mp3: 'audio/mpeg', ogg: 'audio/ogg', wav: 'audio/wav', woff2: 'font/woff2', woff: 'font/woff', ttf: 'font/ttf', txt: 'text/plain' }
const server = createServer((req, res) => {
  const p = decodeURIComponent((req.url ?? '/').split('?')[0])
  const file = path.join(dir, p === '/' ? 'index.html' : p)
  if (!file.startsWith(dir + path.sep) || !statSync(file, { throwIfNoEntry: false })?.isFile()) {
    res.writeHead(404)
    return res.end()
  }
  res.writeHead(200, { 'content-type': TYPES[path.extname(file).slice(1).toLowerCase()] ?? 'application/octet-stream' })
  createReadStream(file).pipe(res)
})
await new Promise((r) => server.listen(0, '127.0.0.1', r))
const base = `http://127.0.0.1:${server.address().port}`

const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
const browser = await chromium.launch({ args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader', '--autoplay-policy=no-user-gesture-required'] })

async function open(context) {
  const page = await context.newPage()
  page.on('pageerror', (e) => console.error('game error:', e.message))
  // The shim includes the direct-storage client too; handle either documented script tag (and query strings).
  await page.route(/\/sdk\/v2(?:-shim)?\.js(?:\?.*)?$/, (route) => route.fulfill({ contentType: 'text/javascript', body: sdk }))
  await page.goto(`${base}/index.html`, { waitUntil: 'load' })
  await page.evaluate(async () => { if (window.Cassette) await window.Cassette.ready })
  await sleep(1500)
  if (startSel) await page.click(String(startSel), { timeout: 5000 }).catch(() => console.error(`--start ${startSel}: nothing to click`))
  else {
    await page.mouse.click(960, 540).catch(() => {})
    await page.keyboard.press('Enter').catch(() => {})
  }
  return page
}

// plays: a key now and then, a click somewhere now and then
async function play(page, ms, onTick = async () => {}) {
  const until = Date.now() + ms
  let n = 0
  while (Date.now() < until) {
    const key = keys[Math.floor(Math.random() * keys.length)].trim()
    await page.keyboard.down(key).catch(() => {})
    await sleep(120 + Math.random() * 280)
    await page.keyboard.up(key).catch(() => {})
    if (n++ % 4 === 3) await page.mouse.click(300 + Math.random() * 1320, 200 + Math.random() * 680).catch(() => {})
    await onTick()
  }
}

// 1. screenshots, spread over the run, never the same frame twice
const context = await browser.newContext({ viewport: { width: 1920, height: 1080 } })
const page = await open(context)
const taken = []
let last = ''
for (let i = 0; i < shots; i++) {
  await play(page, (seconds * 1000) / shots)
  let buf = await page.screenshot({ type: 'jpeg', quality: 88 })
  for (let tries = 0; tries < 3 && buf.toString('base64') === last; tries++) {
    await play(page, 1500)
    buf = await page.screenshot({ type: 'jpeg', quality: 88 })
  }
  last = buf.toString('base64')
  const file = `screenshot-${i + 1}.jpg`
  writeFileSync(path.join(out, file), buf)
  taken.push({ file, bytes: buf.length })
}
await context.close()
const best = taken.reduce((a, b) => (b.bytes > a.bytes ? b : a))
const blank = taken.filter((t) => t.bytes < 12000)
if (blank.length) console.error(`warning: ${blank.map((t) => t.file).join(', ')} look nearly empty (a menu or a blank screen?). Look at them; re-run with --start, --keys or a longer --seconds.`)

// 2. the cover (the best screenshot, an optional share card) and the icon (a crop of it). Not the box art: a screenshot with a
//    title typed on it is refused, the box art is drawn (box-art.md)
const shotUrl = (f) => `data:image/jpeg;base64,${readFileSync(path.join(out, f)).toString('base64')}`
const art = await browser.newPage({ viewport: { width: 512, height: 512 } })
writeFileSync(path.join(out, 'cover.jpg'), readFileSync(path.join(out, best.file)))
await art.setContent(`<body style="margin:0;width:512px;height:512px;overflow:hidden"><img src="${shotUrl(best.file)}" style="width:512px;height:512px;object-fit:cover;object-position:50% 50%"></body>`)
await art.screenshot({ path: path.join(out, 'icon.png') })
await art.close()

// 3. a short looping clip (optional)
let loop = null
if (wantLoop) {
  const vdir = path.join(out, '.video')
  const vctx = await browser.newContext({ viewport: { width: 1280, height: 720 }, recordVideo: { dir: vdir, size: { width: 1280, height: 720 } } })
  const vpage = await open(vctx)
  await play(vpage, 8000)
  const poster = await vpage.screenshot({ type: 'jpeg', quality: 88 })
  writeFileSync(path.join(out, 'loop-poster.jpg'), poster)
  await vctx.close()
  const video = readdirSync(vdir).find((f) => f.endsWith('.webm'))
  if (video) {
    renameSync(path.join(vdir, video), path.join(out, 'loop.webm'))
    loop = { webm: 'media/loop.webm', poster: 'media/loop-poster.jpg' }
  }
}
await browser.close()
server.close()

const rel = (f) => `${path.relative(dir, out) || '.'}/${f}`.replace(/\\/g, '/')
const media = { cover: rel('cover.jpg'), icon: rel('icon.png'), screenshots: taken.map((t) => rel(t.file)) }
if (loop) media.loop = { webm: rel('loop.webm'), poster: rel('loop-poster.jpg') }
console.log(`wrote ${taken.length} screenshots, the cover and the icon to ${out}\nPut this in the game's metadata, and add "box": the box art you make next (box-art.md on the store's site):\n${JSON.stringify({ media }, null, 2)}`)

3. Run it

The build folder is the one you zip: index.html at the top.

node cassette-capture.mjs ./build
  • --seconds 24 is how long the game is played for, --shots 5 how many screenshots (3 to 12).
  • --start "#play" clicks that selector first, for a game that waits on a start button; without it the script clicks the middle of the screen and presses Enter.
  • --keys "ArrowLeft,ArrowRight,Space" replaces the keys it presses (names as in Playwright: KeyA or a, Space, …).
  • --loop also records an 8-second clip, loop.webm with loop-poster.jpg. Optional.
  • --out dir writes elsewhere. The default is media/ inside the build folder, which is where the zip needs them.
  • --sdk /path/to/v2-shim.js uses a local runtime instead of downloading it. Keep that file outside the game you zip.

It prints the media block for the metadata, without box: make the box art next (https://cassette.gg/agents/box-art.md).

4. Look at what it took

Open every image (or have your tool show them). A menu, a loading screen, a blank or a nearly empty frame is not a screenshot of the game: run it again with --start, --keys for the game's real controls, or a longer --seconds, and look again. The script warns about frames that are close to empty. A game that needs precise play to get anywhere (a level to reach, a story to advance) may need --keys and --seconds tuned, or a one-off scripted run of your own with Playwright's page.keyboard. Heavy WebGL runs on a software renderer here and can be slow: raise --seconds.

5. Put them in the game

  1. The media/ folder sits inside the build folder: zip it with the game (https://cassette.gg/agents/publish-game.md §4).
  2. Add the printed block to the game's metadata, and send it: PUT $API/games/{game}/metadata with the whole document (§5). Once the page names all of the pictures, the build goes to review.

The files can be as large as you like up to the zip's limits; a JPEG at quality 88 is about 30 to 300 KB at 1920×1080.


Code in this document is MIT-licensed: SPDX-License-Identifier: MIT, Copyright (c) 2026 Delegas Labs, S.L. Copy and adapt it freely.