Small games in LÖVE and PixiJS, with a harness that boots them headless and screenshots them.
  • Lua 44.8%
  • JavaScript 29.5%
  • Shell 24.8%
  • HTML 0.9%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
Jake Gaylor 4b526f29c6
mktemp templates GNU accepts, and luacheck's std is LuaJIT
The two mktemp calls used the BSD -t form, which GNU coreutils refuses
outright — the gate died on its first line in CI before it had run
anything. And LÖVE 11 runs LuaJIT, so the lint should be reading Lua 5.1,
not 5.4.
2026-08-24 03:43:12 -04:00
.github/workflows The harness first: two engines, one screenshot script 2026-08-24 03:39:50 -04:00
tools mktemp templates GNU accepts, and luacheck's std is LuaJIT 2026-08-24 03:43:12 -04:00
.gitignore The harness first: two engines, one screenshot script 2026-08-24 03:39:50 -04:00
.luacheckrc mktemp templates GNU accepts, and luacheck's std is LuaJIT 2026-08-24 03:43:12 -04:00
AGENTS.md The harness first: two engines, one screenshot script 2026-08-24 03:39:50 -04:00
package-lock.json The harness first: two engines, one screenshot script 2026-08-24 03:39:50 -04:00
package.json The harness first: two engines, one screenshot script 2026-08-24 03:39:50 -04:00
README.md The harness first: two engines, one screenshot script 2026-08-24 03:39:50 -04:00

games

Small games, and the harness that lets a machine play them.

Two engines live here. LÖVE (Lua) for games that want to be a window on a desktop, and PixiJS (JS, built with Vite) for games that want to be a page. A game is one directory at the top level; which engine it uses is visible from its files — main.lua means LÖVE, index.html means the web.

tictactoe/            a game
  main.lua            LÖVE: the game
  conf.lua            LÖVE: window size, save identity
  shot.lua            a copy of tools/shot.lua — the screenshot harness
  screenshots/        committed PNGs, the ones the pull request links to
tools/
  love-shot           run a LÖVE game headless, collect its screenshots
  web-shot            the same, for a web game, in headless Chromium
  shot.lua            the canonical screenshot module LÖVE games copy
  sync-shot.sh        put that copy in every LÖVE game
  check.sh            the gate: lint, and boot-and-screenshot every game
  new-game.sh         start a game from the matching selftest
  selftest/           the smallest LÖVE game that proves the harness works
  selftest-web/       the same, in PixiJS

Playing a game

love tictactoe                          # LÖVE
npm install && npm run dev -w tictactoe  # web, then open the URL it prints

Screenshotting a game

This is the part that matters, because nobody reviewing a change to a game wants to check out a branch to find out what it looks like. Both engines are driven by the same sentence — a list of steps, run at a fixed 60 frames a second so the same script gives the same picture every time:

tools/love-shot tictactoe tictactoe/screenshots \
  "wait:20 shot:empty-board click:150,150 wait:5 shot:x-plays key:r shot:reset"

tools/web-shot  tictactoe tictactoe/screenshots \
  "wait:20 shot:empty-board click:150,150 wait:5 shot:x-plays key:r shot:reset"
step what it does
wait:30 advance 30 frames
key:return press and release a key (LÖVE names for LÖVE, Chromium names for the web)
text:x a text input event
move:120,240 move the mouse there
click:120,240 move there and click
shot:name write name.png into the output directory
quit stop early

The events go through the engine's own queue, so love.keypressed and a Pixi pointerdown see them exactly as they see a real one. What they do not touch is device state: love.keyboard.isDown stays false. Drive a game from its callbacks and it is scriptable; poll the keyboard inside update and it is not.

The gate

tools/check.sh          # every game and both selftests
tools/check.sh tictactoe

It lints, checks that each LÖVE game's shot.lua still matches tools/shot.lua, and then boots every game headless and takes one screenshot of it. A game that cannot boot and draw a frame with no human present fails here, which is the whole point: that is also the state a reviewer sees it in.

.github/workflows/ci.yml runs the same script.

Two things the harness knows that you would otherwise learn the hard way

A LÖVE game can only require inside its own directory, so the screenshot module cannot be shared by reference. Every game carries a copy of tools/shot.lua and ends its main.lua with require("shot").install(), which does nothing at all unless LOVE_SHOT is set. tools/sync-shot.sh refreshes the copies and the gate fails when one has drifted.

A web game must not use top-level await in its entry module. PixiJS finishes booting by dynamically importing its browser extensions, and that import lands in a chunk that cannot finish while the entry module it was split from is still suspended on an await. The result is not an error — the page simply sits there forever with no canvas. Put the boot in async function main() and call it, the way tools/selftest-web does.