playghost is a small fake PlaydateAPI for driving a Playdate game from
an ordinary C program that you can use to run end-to-end tests for your Playdate game.
It only supports C-based games for now (that compile to pdex.so). Lua support is not planned.
playghost_open() loads your pdex.so and calls its
eventHandler(kEventInit) with a PlaydateAPI whose system, graphics,
file, sound, and display tables point at playghost's own functions.
From there:
playghost_tick()advances a fake clock and runs your update callback once, the way a real 50 Hz refresh would.playghost_set_buttons()sets the held state for the next tick. playghost derives pushed/released from the previous tick, same as hardware.playghost_set_crank_angle()sets an absolute crank angle;getCrankChange()is derived from the delta between ticks.fillRect,fillTriangle, andclearare real software rasterizers writing into an actual 1bpp framebuffer, not stubs that return success and do nothing. playghost double-buffers properly --getFrame()is the buffer being drawn into this tick,getDisplayFrame()is what was on screen going into it, and they are genuinely different memory, because some games (seeexamples/life) read one while writing the other.playghost_dump_pbm()writes the current frame out as a PBM you can diff or eyeball;playghost_framebuffer()hands you the raw bytes if you'd rather assert on pixels directly.playghost_seed_file()makesplaydate->file->open()return an in-memory fixture instead of failing. playghost never touches your real filesystem on the game's behalf, in either direction.playghost_symbol()isdlsym()against your loaded game. Export a debug function from your game and call it directly from your test. This is the intended way to assert on state that never reaches the framebuffer.
playghost needs the Playdate SDK's headers and nothing else.
export PLAYDATE_SDK_PATH=/path/to/PlaydateSDK
make check
A game exports one required symbol, eventHandler, and whatever debug hooks
you want your tests to call:
/* game.c */
#include "pd_api.h"
static PlaydateAPI *pd;
static int score = 0;
static int update(void *ud) {
PDButtons pushed;
pd->system->getButtonState(NULL, &pushed, NULL);
if (pushed & kButtonA) score++;
pd->graphics->clear(kColorWhite);
pd->graphics->fillRect(10, 10, score, 10, kColorBlack);
return 1;
}
/* Test-only. A real device build never calls this. */
int debug_score(void) { return score; }
int eventHandler(PlaydateAPI *playdate, PDSystemEvent event, uint32_t arg) {
if (event == kEventInit) {
pd = playdate;
pd->system->setUpdateCallback(update, NULL);
}
return 0;
}A test drives it like any other C program, because now it is one:
/* test.c */
#include <assert.h>
#include "playghost.h"
int main(void) {
PlayghostDevice *dev = playghost_open("pdex.so");
assert(dev);
int (*score)(void) = playghost_symbol(dev, "debug_score");
assert(score() == 0);
playghost_set_buttons(dev, kButtonA);
playghost_tick(dev, 20);
playghost_set_buttons(dev, 0);
assert(score() == 1);
playghost_dump_pbm(dev, "after-press.pbm");
playghost_close(dev);
return 0;
}Seeding a save file your game reads during its own init, so a test can skip a first-run tutorial path instead of steering through it:
static const char save[] = "level=3\nlives=2\n";
PlayghostDevice *dev = playghost_create("pdex.so");
playghost_seed_file(dev, "save.json", save, sizeof save - 1);
playghost_init(dev); /* only now does the game's kEventInit see the file */Driving the crank and reading a specific frame back as pixels, e.g. to check a rotating dial lands in the right place:
playghost_set_crank_angle(dev, 90.0f);
for (int i = 0; i < 30; i++) playghost_tick(dev, 20); /* let it settle */
playghost_dump_pbm(dev, "dial-at-90.pbm");Rather than hand-roll pixel loops over the raw framebuffer, three helpers read the current frame directly:
long black = playghost_count_pixels(dev, kColorBlack); /* or kColorWhite */
assert(black > 0); /* something drew */
/* Is a rectangle a solid color? (clipped to the screen) */
assert(playghost_region_is(dev, 0, 0, 400, 20, kColorWhite)); /* top bar clear */
/* Compare against a PBM you dumped earlier: 1 equal, 0 differ, -1 missing. */
assert(playghost_frame_equals(dev, "golden.pbm") == 1);playghost_frame_equals() reads back exactly what playghost_dump_pbm()
wrote, so a dumped frame always matches itself. Note the fidelity limits under
What's faked: a golden dumped by playghost is
only comparable to another playghost frame, never to one from the real
simulator, because pattern fills and text don't render the same.
playghost records every effectful call your game makes into the fake API — the
draws, asset loads, file and sound calls, and log lines — as a plain-text
trace. playghost_verify() snapshots that trace against a cassette on disk,
the same record-then-replay idea as HTTP VCR libraries:
/* first run: no cassette yet -> records it, returns 0. commit the file.
every run after: compares the live trace to it, returns 0 if identical,
-1 if not (and writes "<cassette>.actual" so you can diff the two). */
assert(playghost_verify(dev, "tests/bounce.cassette") == 0);The cassette is a readable log, so a diff on a failure is legible:
clear W
fillRect 174 228 60 6 B
fillTriangle 203 98 199 106 207 106 B
...
It captures what the game did (which coordinates, which colors, which files) rather than the pixels it produced, so it catches behavioral regressions that a framebuffer check would miss — a changed file access, a dropped sound, a draw that moved — and stays stable across runs because it never records pointers, only semantic arguments. The buttons, crank, and clock your test drives are inputs, not effects, and aren't recorded. Delete the cassette to re-record after an intended change.
Because a fresh recording passes, a cassette that isn't committed makes CI green without ever comparing — so commit your cassettes.
examples/bounce is a complete, if pointless, game written for this
README: a ball bounces around the screen while a paddle answers to left and
right. test.c scripts input, reads the ball's position through a debug
export, and dumps a few frames to /tmp — a template for wiring playghost
into your own game's test suite.
examples/life is Panic's own Conway's Game of Life example from the
SDK (C_API/Examples/Life), copied in with one addition: a
debug_alive_count() export so the test can assert the population actually
changes instead of just checking that nothing crashed.
Both examples pass make check, and are Playdate SDK code, not
playghost's — nothing about them is aware playghost exists except the one
debug export in life/game.c.
examples/*/record.c runs a game for a fixed number of frames and dumps
every one, instead of the sparse sampling a real test would use:
make gif
writes examples/bounce/demo.gif and examples/life/demo.gif from 180
recorded frames each, via ImageMagick. It is not a fast recipe -- 180 PBMs
through convert -- and it is not meant to run in CI. It is meant to let
you look at what your test actually saw.
Very little of the API is covered. This was made for a superhexagon port,
which doens't use a lot of APIs. Known gaps and planned work live in
TODO.md.
MIT. See LICENSE.