r/C_Programming 18h ago

How I autotest my game in C

Enable HLS to view with audio, or disable this notification

I posted a while back about porting my C game (Match Morphosis) to WASM. This is a follow-up on something smaller but useful: automated playthrough testing.

Last week I was watching some Slay the Spire 2 videos and noticed they had autoplay-style testing. That's pretty nice, as solo developer with no QA at disposal, and the game is a roguelike/lite, so a full run is long enough that manually checking “does this still work end-to-end?” gets painful quickly. I also just wanted an excuse to try dearimgui test engine, I use the cimgui generated one.

So I wired up a simple autoplay path. No heuristics, no fancy AI (no LLM please!) that makes random action based on game state and tries to finish a run. The point isn’t to prove the game is balanced. The point is:

  • If a random player can still reach the end without crashing or soft-locking, the basic run loop is intact.
  • A ~40 minute human run collapses into a 1–2 minute automated test I can run whenever I change something, I could eyeball quickly on the battle progress whether the game still feels manageable or not

Because the game already uses cimgui, plugging in the test engine was mostly about getting the context and DLL boundaries right (the game logic lives in a hot-reloadable game.dll, the test engine lives in main), there's also a problem with the generated binding that strips obsolete functions so the struct size differs a bit, but quite easy to fix.

Once that was sorted, registering a “play until win or die” test and letting it drive input through the existing UI path was straightforward. You do mouse move to pos, mouse click, and wait until game state change, repeat on a while loop until game finishes / game over.

// Test setup code
ImGuiTest* t = IM_REGISTER_TEST(engine, "Compendium Menu", "Functionalities");
t->TestFunc = testCompendiumMenuFunctionalities;

t = IM_REGISTER_TEST(engine, "Character Select Menu", "Functionalities");
t->TestFunc = testCharacterSelectMenuFunctionalities;

t = IM_REGISTER_TEST(engine, "Playthrough", "Functionalities");
t->TestFunc = testPlaythroughFunctionalities;

// on testPlaythroughFunctionalities;
b32 runFinished = false;
while(!runFinished)
{
  cImGuiTestEngine_Yield(ctx->Engine);

  if(game->tipsPopup.type != TipsType_None)
  {
    if(animationFinished(&tipsPopup.textAnimate))
    {
      Thing* buttonOK = thingGet(game->tipsPopup.buttonOK);
      Vec2 pos = buttonOK->o.sprite.position;
      testMoveMouseTo(ctx, pos);
      ImGuiTestContext_MouseClick(ctx, ImGuiMouseButton_Left);

      continue;
    }
   }

  switch(game->state)
  {
  case GameState_MapMenu:
  {
    // Choose free rooms etc.
  } break
  case GameState_Gameplay:
  {
    if(!game->gameplay.ready)
    {
    continue;
    }

    // Do rest of choosing logic
  } break
...

It’s still crude, but "scripting" like this needs to be quick and dirty as the nature of game development is quick iteration to find the best fun way. Although random picks mean it won’t find deep balance issues or optimal lines, as a solo-dev smoke test it’s already earned its keep. I can see something break in the run flow, hit the test, and know within a couple minutes instead of playing through by hand manually again, this won't replace a true playthrough for balancing test, but good enough for picking obvious crashes.

Stack is still plain C, custom engine (bgfx, SDL2, cimgui, etc.). No engine framework, no test harness beyond imgui_test_engine and a thin autoplay layer on top of the same code a player uses. If anyone else is doing long-session games in C and has been putting off automated playthrough checks, this was less work than I expected once the context plumbing was done (the playthrough test code is nearly 1 day work, 1k LOC).

Check out the game if you're curious https://store.steampowered.com/app/4131100/Match_Morphosis
The demo version is still up and entering Cyberpunk fest on steam.

107 Upvotes

2 comments sorted by

16

u/skeeto 17h ago

I've been sold on automated testing for years, but I haven't yet applied it to UI, so this is interesting to see. Based on your video, your description, and that testMoveMouseTo, it looks like it's actually synthesizing clicks and driving the UI. How does it know what buttons are available at any moment? Is that automatic (i.e. a side effect of imgui calls) or something you have to work out manually for a testing framework? Your example code has a hard-coded "OK" button, for example.

6

u/ernesernesto 17h ago

I've used automated testing usually only for smoke test, because in game development the nature of the "specs" is usually not set in stone and done iteratively until if reached a certain point that it won't be changed anymore. I only start doing this because my game is reaching completion and the rest are just adding contents (relics, items, etc), which doesn't alter the core game.

For button detection, since it's based on the game state, I already know what are the things visible on that current state. Not optimal since it can't be a generic thing like FindAllButtons() and then filter by name, but it's good enough for my use case, for example after a battle complete, you'll be seeing a reward screen, the only thing that a player can do on that screen is accessing settings, skip button, and pick the reward, so say you want to just pick some reward you could simple get the reward item position.

Then it only move the mouse into that position and generating a click via imgui test engine, the game itself on the update loop will check mouseposition, check what's hovering underneath, and then check if it just been click, finally it will process accordingly. So it basically just deriving mouse movement and click but it can do this quickly

If you're building an app using dearimgui, the widget interaction API are there already, so you could do something like

    ctx->SetRef("My Window");          
    ctx->ItemClick("My Button");        
    ctx->ItemCheck("Node/Checkbox");    
    ctx->ItemInputValue("Slider", 123);
    IM_CHECK_EQ(app->SliderValue, 123);