<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Denis Kurchikov</title>
    <description>The latest articles on DEV Community by Denis Kurchikov (@voljulan).</description>
    <link>https://dev.to/voljulan</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4202828%2Fb2996e1d-359d-4237-8a43-ca84a377b908.jpg</url>
      <title>DEV Community: Denis Kurchikov</title>
      <link>https://dev.to/voljulan</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/voljulan"/>
    <language>en</language>
    <item>
      <title>CI/CD for a Solo Project: From the First Test to Release</title>
      <dc:creator>Denis Kurchikov</dc:creator>
      <pubDate>Wed, 11 Nov 2026 09:38:24 +0000</pubDate>
      <link>https://dev.to/voljulan/cicd-for-a-solo-project-from-the-first-test-to-release-eo1</link>
      <guid>https://dev.to/voljulan/cicd-for-a-solo-project-from-the-first-test-to-release-eo1</guid>
      <description>&lt;p&gt;Today almost every software project, from a small utility to a big game, lives in a version control system, most often on GitHub - a service that stores code together with its full history of changes. And in almost every team, each code change is automatically checked and built on a server, and the finished version reaches users just as automatically. This approach is called CI/CD. From the outside it looks like a tool for big companies, but in fact you can set it up for free even for a one-person project.&lt;/p&gt;

&lt;p&gt;By profession I am a QA engineer (QA - quality assurance), but I am more and more drawn to development: I want to create projects of my own. The first such project is Taveronen: The Beginning - a small idle game about a tavern where gold piles up by itself. I developed it myself in Godot, a free open-source engine, and released it on itch.io, a platform for indie games, in two versions: for the browser and for Windows. The game logic is covered by automated tests written with the GUT framework (Godot Unit Test), and I have a separate article about them. Some details here are specific to Godot, but the approach itself works for any project.&lt;/p&gt;

&lt;p&gt;Two things pushed me to set up CI/CD. The first was manual work. I ran the tests myself: open the editor, press Run All, wait for the result. I released versions myself too: build the game for the browser and for Windows, pack the builds into archives, replace the archives on the game page, then download the game and check that it starts. About fifteen minutes of repetitive actions where it is easy to miss something. The second reason was my career: CI/CD is mentioned in almost every test automation job posting, and I wanted to see what is behind it on a project of my own.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9pnd54zvrzrmzz26976g.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F9pnd54zvrzrmzz26976g.png" alt="GitHub Actions tab with a list of green pipeline runs for Taveronen releases" width="800" height="428"&gt;&lt;/a&gt;&lt;/p&gt;
The Actions tab on GitHub: the history of pipeline runs. Every release of the game goes the same way, from the tests to publishing on itch.io



&lt;p&gt;Now the tests run by themselves after every change, and a new version comes out with a single action: I mark the version in Git, and GitHub runs the tests, builds the game, checks it in a real browser and on real Windows, and uploads it to the game page. In this article I will walk through how I set it all up step by step, how I made sure the checks really catch errors, and which mistakes I made along the way. At the end I will honestly sort out what a small project actually needs and what I did just to learn. The whole project is in an open repository, the link is at the end.&lt;/p&gt;

&lt;h2&gt;
  
  
  What CI/CD is, in plain words
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;CI&lt;/strong&gt; (continuous integration) means that every code change is checked automatically: the server runs the tests, and the change gets a mark showing whether it passed. &lt;strong&gt;CD&lt;/strong&gt; (continuous delivery) means that after successful checks the server builds the project and takes it all the way to release, including uploading it to the platform. The decision of when to release still stays with a human.&lt;/p&gt;

&lt;p&gt;The chain of steps a change goes through is called a pipeline. Picture a factory assembly line with a quality control department: if the inspector finds a defect, the part does not move on. Automated tests work exactly like that inspector, and in this role they are called a quality gate: a red test stops the release, and a broken version never reaches the players. CI/CD is not testing itself but the automation of the path from code to player, yet it is the tests that make it possible to trust a machine with that path. So the pipeline is a joint effort of development and quality assurance: its value depends on the checks.&lt;/p&gt;

&lt;p&gt;My pipeline lives in GitHub Actions. It is an automation service built into GitHub: it runs the commands you define on GitHub servers in response to events in the repository, such as a push, a new tag or a button click. Projects use it to run tests, build programs and publish them. I chose it because my project is already on GitHub, and for public repositories the service is free.&lt;/p&gt;

&lt;p&gt;It is configured with text files in YAML (a markup language where nesting is set by indentation) in the .github/workflows folder. Each file is called a workflow, it consists of jobs, and jobs consist of steps. Jobs run on a clean computer that GitHub provides for the duration of the run: usually with Linux, but you can ask for Windows too. There is no Godot and none of my settings there, so the pipeline installs everything it needs by itself.&lt;/p&gt;

&lt;p&gt;In the end the pipeline consists of two files: tests.yml checks every change, and release.yml releases a new version. I built it one step at a time and will describe it in the same order, and the overall diagram comes at the end.&lt;/p&gt;

&lt;h2&gt;
  
  
  Git comes first
&lt;/h2&gt;

&lt;p&gt;Every pipeline starts with a repository. Previously, the game's source code lived only on my computer: if the disk had died, all the work would have been lost. So a repository on GitHub is also a backup with the full history of changes. I learned to work with Git from the command line in courses, but for everyday work I prefer GitHub Desktop, where everything is done with buttons. To follow the rest of the article, four terms are enough:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Commit&lt;/strong&gt; - a saved snapshot of changes with a short description.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Push&lt;/strong&gt; - sending commits to GitHub. This is what starts the pipeline.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Branch&lt;/strong&gt; - a separate line of changes. The main branch is called main, and it holds the working version.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Pull request&lt;/strong&gt; (PR) - a proposal to merge changes from a branch into main, showing the difference in files and the results of the checks.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;With these words we can move on to the first pipeline file.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 1. Tests run by themselves
&lt;/h2&gt;

&lt;p&gt;The first pipeline file, tests.yml, tells GitHub: after every push to any branch, take a clean computer, install Godot on it and run all the tests. Here is its essence (simplified):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Tests&lt;/span&gt;

&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;branches&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"**"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="c1"&gt;# run on every push to any branch
&lt;/span&gt;
&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;runs-on&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;ubuntu-24.04&lt;/span&gt; &lt;span class="c1"&gt;# a clean Linux computer from GitHub
&lt;/span&gt;    &lt;span class="na"&gt;steps&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
      &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Check out the repository&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;actions/checkout@v7&lt;/span&gt; &lt;span class="c1"&gt;# copy the project to that computer
&lt;/span&gt;      &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Set up Godot&lt;/span&gt;
        &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./.github/actions/setup-godot&lt;/span&gt; &lt;span class="c1"&gt;# download Godot and import the project
&lt;/span&gt;      &lt;span class="p"&gt;-&lt;/span&gt; &lt;span class="na"&gt;name&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;Run GUT tests&lt;/span&gt;
        &lt;span class="na"&gt;run&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;godot --headless -s addons/gut/gut_cmdln.gd -gexit&lt;/span&gt; &lt;span class="c1"&gt;# run all tests without a window
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The "on" block says when to run: on every push to any branch. This means unfinished work in separate branches is checked too, before it gets into main. Then come three steps: copy the project, install Godot and run the tests.&lt;/p&gt;

&lt;p&gt;GUT tests run inside Godot itself, so the engine has to be started for a test run. A window is not needed on the server, and the --headless flag starts Godot without one. The -gexit flag tells GUT to close Godot after the run: without it the program would stay open, and the step would hang until the timeout. GUT reads where to look for tests from the .gutconfig.json settings file in the project root: it says that tests live in the test folder and its subfolders, and that file names start with test_. So it is enough to put a new test into that folder, and the pipeline will pick it up by itself.&lt;/p&gt;

&lt;p&gt;How does GitHub know whether the tests passed? By the exit code, and this simple mechanism is what all of CI rests on. Every program reports a number when it finishes: 0 means everything is fine, anything else means something went wrong. If at least one test fails, GUT exits with code 1, the step turns red, and the commit gets a red cross instead of a green check mark. No magic.&lt;/p&gt;

&lt;p&gt;The file is ready. All that is left is to put it into the .github/workflows folder, commit and push: GitHub finds the file by itself and starts the pipeline. The result appears in the Actions tab of the repository, and the next question is whether that result can be trusted.&lt;/p&gt;

&lt;h3&gt;
  
  
  Don't trust the green check mark: make it red yourself
&lt;/h3&gt;

&lt;p&gt;The first run took 22 seconds and was green right away. That made me suspicious: the check mark only says that the command did not report an error, but it does not tell you whether the tests actually ran. So I opened the step log - the record of everything the command printed (the Actions tab, the run in question, the test job, the Run GUT tests step). The GUT summary showed the same numbers as in the editor, and only then could the check mark be trusted.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5oz6ccyljc1nc2mnq8p6.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F5oz6ccyljc1nc2mnq8p6.png" alt="GUT run summary in the GitHub Actions log: scripts, tests, passing tests and asserts, all tests passed" width="732" height="596"&gt;&lt;/a&gt;&lt;/p&gt;
The GUT summary in the log of the first run: the same numbers as in the editor, so the tests really ran



&lt;p&gt;But even a green run does not prove that the pipeline can catch errors: a test that has never been red may simply be unable to turn red. In QA this is a familiar idea: a check has to be checked. So in a separate branch I changed the expected number of items in the shop test from 7 to 8. The run turned red, and the log had everything needed to figure out why:&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fat3rmlvenxca4xidghkj.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fat3rmlvenxca4xidghkj.png" alt="GUT log of a deliberately broken shop test failing with expected 7 to equal 8 and exit code 1" width="800" height="601"&gt;&lt;/a&gt;&lt;/p&gt;
The log of a deliberately broken test: which test failed, what was expected, on which line, and exit code 1 at the very end



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;test_shop_has_seven_items&lt;/strong&gt;, &lt;strong&gt;at line 21&lt;/strong&gt; - which test failed and on which line.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;[Failed]: [7] expected to equal [8]&lt;/strong&gt; - the essence of the error: there are 7 items, but the test expected 8.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Shop should have 7 items&lt;/strong&gt; - the message I wrote myself in the check, so that a failure immediately shows what it is about.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Process completed with exit code 1&lt;/strong&gt; - the exit code that turned the step red.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;I deleted the branch afterwards, and nothing got into main: this kind of experiment is exactly what branches are for. Since then I have a rule: every new check has to fail at least once before I trust it.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 2. A report everyone can see
&lt;/h2&gt;

&lt;p&gt;A log is good for investigating, but it is inconvenient to show to anyone, and even I struggle to spot what I need in it. So I added an Allure report - a tool that turns test results into a neat website. GUT writes the results into a standard file that many tools understand (JUnit XML), Allure builds a report from it, and the pipeline publishes it to GitHub Pages - free website hosting straight from a repository. The report is built even when tests fail, because that is when it is needed most, and it is published only from the main branch.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fetls9nq18wrup000tp6t.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fetls9nq18wrup000tp6t.png" alt="Allure report on GitHub Pages with all tests passed and test files grouped by folder" width="800" height="752"&gt;&lt;/a&gt;&lt;/p&gt;
The Allure report on GitHub Pages: tests are grouped by file, and everything is visible without opening the logs



&lt;p&gt;The main advantage of the report is that it opens with an ordinary link, without a GitHub account and without knowing where to look for logs. Allure is widely used by testing teams, so such a report also presents the work in a format familiar to the industry.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 3. A smoke test: what GUT cannot check
&lt;/h2&gt;

&lt;p&gt;A Godot game is made of scenes: the menu, the intro, the tavern. The New Game button unloads the current scene and loads a new one. But GUT tests themselves run inside the game, in their own scene, and a real scene change would unload them in the middle of a run. That is why in the GUT tests the transition is replaced with a stub, and nothing checked that the buttons actually open the right screens.&lt;/p&gt;

&lt;p&gt;This gap was closed by a smoke test - a short check of the most important things that shows whether the main functions of the program work at all. In testing it is usually the first check of a new build: if the smoke test fails, there is no point in testing further. My smoke test is a separate script that is itself the scene tree (SceneTree): scenes change inside it while it stays in place. It presses the buttons that change the screen one by one and after each press checks which scene has opened:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="n"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;SceneTree&lt;/span&gt; &lt;span class="c1"&gt;# this script IS the scene tree, so a real scene change cannot destroy it&lt;/span&gt;

&lt;span class="n"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;_run&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;void&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="c1"&gt;# the whole smoke run (shortened here)&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;_open_scene&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;MENU_SCENE&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# open the main menu and wait until it is ready&lt;/span&gt;
    &lt;span class="nf"&gt;_press_button&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"NewGameButton"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# press the button, the real scene change starts&lt;/span&gt;
    &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;_wait_frames&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# the scene change finishes at the end of the frame, so we wait a little&lt;/span&gt;
    &lt;span class="nf"&gt;_check_scene&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;INTRO_SCENE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"New Game (no save) opens the intro"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# the intro must be open now&lt;/span&gt;
    &lt;span class="c1"&gt;# ... the other checks go the same way ...&lt;/span&gt;
    &lt;span class="nf"&gt;quit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;failures&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# at the end: exit code 1 if any check failed, otherwise 0&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The pipeline reads the exit code the same way as with GUT. In tests.yml the smoke test runs after the GUT tests with the condition if: always(): it runs even if the GUT tests failed, so one red step does not hide another. I saw the smoke test fail too, by deliberately breaking one of its checks. The player's real save is not touched during the run: the path to the save file is swapped for a test one.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe91mnp4kzenybbbfkdym.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fe91mnp4kzenybbbfkdym.png" alt="Smoke test log in GitHub Actions: six SMOKE ok lines, one SMOKE FAIL and the red failed step" width="799" height="256"&gt;&lt;/a&gt;&lt;/p&gt;
The smoke test in CI: six transitions passed, one is deliberately broken, and the whole step turned red



&lt;p&gt;Important: the smoke test does not run the finished build that a player downloads, but the source files of the project, like the run button in the editor, only without a window and on Linux. It knows nothing about how the finished build behaves on a player's computer, and we will come back to that.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 4. The game builds itself
&lt;/h2&gt;

&lt;p&gt;This is where CD begins, and its first part is the build: exporting the project into finished files for the player. For this Godot needs export templates - prebuilt "engines" that it puts inside the build. The template archive weighs about 1.3 GB, but I need only four files from it: the templates for Windows (regular and console), the template for the browser, and the file with the template version number. So on every run the pipeline extracts only those. The export itself is two windowless Godot commands, for Windows and for the browser. Godot does not create the output folders by itself, so they have to be created in advance. That was the first small trap.&lt;/p&gt;

&lt;p&gt;The build is a separate job and starts only after the tests are green: in YAML this is the line needs: test. Finished builds are saved as artifacts - files you can download from the run page. To check, I downloaded the Windows build from CI and compared it with one built by hand in the editor: the game starts and works the same, the file sizes are equal, and a content comparison showed they match byte for byte. So the server builds exactly the same thing I do.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3e67iuzxq9mu9t2yu40v.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3e67iuzxq9mu9t2yu40v.png" alt="GitHub Actions run page with the test and build jobs and the list of build artifacts" width="799" height="328"&gt;&lt;/a&gt;&lt;/p&gt;
The run page: the test and build jobs on the left (the build waits for the tests), the artifacts below - finished builds and screenshots from the browser test



&lt;p&gt;Artifacts are kept for a week: that is enough to download and check a build, and the storage does not fill up with old files. But building the game is only half the job. Now we need to make sure the built game works.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 5. Checking the web version in a real browser
&lt;/h2&gt;

&lt;p&gt;The web version has its own risks: files may fail to load, and the game may fail to start. So the next addition was an e2e test (end-to-end: a check through the real interface, the way a person plays).&lt;/p&gt;

&lt;p&gt;For it I took Playwright - a library from Microsoft that controls a browser from code: it opens a page, clicks, takes screenshots and reads the browser console, where errors end up. The browser is Chromium, the open-source Google project that Chrome, Edge and many others are built on. Playwright downloads its own Chromium build of a strictly defined version, so the test always runs in the same browser. The test itself is written in Python. It is run by pytest, the most widely used testing framework for Python: it finds functions whose names start with test_ and reports which passed and which failed. The pytest-playwright plugin gives each test a clean browser tab, and Pillow, an image library, compares the screenshots.&lt;/p&gt;

&lt;p&gt;All of this happens not on my computer but on a GitHub server, in the same build job, right after the export. The pipeline installs the Python packages and Chromium there, and the test starts a small web server from the Python standard library: a web build cannot be opened with a double click, the browser runs it only from a server. The browser on the server has no window, but it renders the page completely. This way the test checks exactly the build that will go to the players, and if it is red, the pipeline goes no further.&lt;/p&gt;

&lt;p&gt;The test follows the path of a new player: the game loads without errors, New Game opens the intro, Continue opens the tavern. This is where two quirks of games came up that articles about web testing usually do not mention.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The game has no buttons a browser could find.&lt;/strong&gt; On a website a button is a page element with text, and a test finds it almost the way a person does. Godot, however, draws the whole game on a single canvas, and to the browser it is just a picture. So the test clicks by coordinates: New Game is the point (945, 178), and the browser window is always exactly 1280 by 720 pixels so the button does not shift. Simple, but fragile: if the buttons in the menu are rearranged, the coordinates will have to change.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How do you tell that the screen has changed?&lt;/strong&gt; The test compares screenshots before and after the click. But the menu is animated (fire in the fireplace, stars), and two screenshots never match completely. So the test converts both screenshots to grayscale (each pixel becomes one brightness number from 0 to 255), subtracts them pixel by pixel and counts the share of pixels that changed by more than 30. Weaker differences are just animation flicker. I measured it: the idle menu changes by about 0.2%, and a new screen by more than 40%. I set the threshold at 20%:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;before&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;take_screenshot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"02_before_new_game.png"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# a picture of the main menu before the click&lt;/span&gt;
&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;mouse&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;click&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="no"&gt;NEW_GAME_BUTTON&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# click the New Game button by its coordinates&lt;/span&gt;
&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;wait_for_timeout&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="no"&gt;CLICK_WAIT_MS&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# wait until the intro is drawn&lt;/span&gt;
&lt;span class="n"&gt;after&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;take_screenshot&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"03_intro.png"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# a picture of the screen after the click&lt;/span&gt;
&lt;span class="n"&gt;share&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;changed_share&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;before&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;after&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# the share of changed pixels, from 0.0 to 1.0&lt;/span&gt;
&lt;span class="k"&gt;assert&lt;/span&gt; &lt;span class="n"&gt;share&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="no"&gt;MIN_CHANGED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;f&lt;/span&gt;&lt;span class="s2"&gt;"New Game did not change the screen: only {share:.1%} of pixels changed"&lt;/span&gt; &lt;span class="c1"&gt;# MIN_CHANGED = 20%&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Step by step: the test takes a screenshot of the menu, clicks the New Game button, waits until the game draws the new screen, and takes a second screenshot. The changed_share function calculates the share of pixels that noticeably changed between them. The last line is the check (assert): if less than 20% of the screen changed, the test fails with a clear message saying how many percent changed. The check for the Continue button works the same way.&lt;/p&gt;

&lt;p&gt;Once this test turned red on the same code where it had just been green: only 0.2% of the screen changed, so the game did not react to the click. Most likely the server took longer than usual to load the game, and the click came before the menu was ready. Such a test is called flaky. You cannot just rerun it until it is green: if you get used to random red, one day a real failure will be dismissed as random too. The failure happened once, so I keep an eye on the test, and if it happens again, I will give the game more time to load. Screenshots help to investigate such cases: the pipeline saves them as an artifact on every run, even when the test fails.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 6. A real .exe on real Windows
&lt;/h2&gt;

&lt;p&gt;The web build was now checked by the browser, but the pipeline only built the Windows version, and whether it started had to be checked by hand. The smoke test does not replace this: it runs the project on Linux, not the finished .exe.&lt;/p&gt;

&lt;p&gt;A finished build is not the same as the project in the editor: the game is packed into a .pck file next to the .exe, and the engine runs in release mode. An export error shows up only when the build itself is started. In studios a finished build is usually launched first by QA engineers, and this is known as a build verification test. I decided to automate this check as well. The plan was:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;On a GitHub server with Windows, take the artifact with the Windows build.&lt;/li&gt;
&lt;li&gt;Run the game without a window for 300 frames and let it close by itself (the --headless and --quit-after 300 flags). A frame is one step of the game loop in which the game updates its logic and picture. 300 frames are more than enough to load the main menu.&lt;/li&gt;
&lt;li&gt;Check that the game started, loaded the main menu and wrote no errors to the log.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The hardest part turned out to be reading what the game prints at startup: the release .exe is built as a program without a console, and its output cannot be captured the usual way. Godot itself came to the rescue: the --log-file flag writes the log to a file, and with the --verbose flag the entire loading process ends up there. The sign of success became the line about loading the main menu:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Completed load for: 'res://scenes/main_menu.tscn'
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;I wrote the check as a PowerShell script (the Windows command shell), test/ci/check_windows_build.ps1. It starts the game, waits up to 60 seconds for it to finish and then reads the log. The time limit is needed in case the game hangs: for example, without the .pck file it shows an error window and waits for someone to press OK, and on a server there is nobody to press it. The essence of the script (simplified):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight powershell"&gt;&lt;code&gt;&lt;span class="nv"&gt;$MainSceneMarker&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;"Completed load for: 'res://scenes/main_menu.tscn'"&lt;/span&gt; &lt;span class="c1"&gt;# this log line appears when the main menu is loaded&lt;/span&gt;

&lt;span class="c1"&gt;# Start the game: no window, close after 300 frames, write a detailed log to our file&lt;/span&gt;
&lt;span class="nv"&gt;$GameArguments&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; @&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"--headless"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"--quit-after"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"$QuitAfterFrames"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"--verbose"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"--log-file"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"`"&lt;/span&gt;&lt;span class="nv"&gt;$LogFile&lt;/span&gt;&lt;span class="sb"&gt;`"")
$GameProcess = Start-Process -FilePath $GameExe.FullName -ArgumentList $GameArguments -PassThru # start the game
$Finished = $GameProcess.WaitForExit($MaxWaitSeconds * 1000) # wait up to 60 seconds, the time is in milliseconds
# ... if the game hangs, stop it and remember the failure; read the log into $LogLines ...

# Check 1: the main menu scene was loaded
if (-not ($LogLines | Where-Object { $_.Contains($MainSceneMarker) })) { # no log line contains the marker text
    $FailureLines += "The main menu was not loaded (no line: $MainSceneMarker)"
}

# Check 2: no error lines, except the harmless one printed when the game is closed early
$ErrorLines = $LogLines | Where-Object {
    ($_ -match "^(SCRIPT ERROR|USER ERROR|ERROR|Parse Error)") -and ($_ -notmatch "still in use at exit")
}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If the exit code is not 0, the menu line is missing or the log contains errors, the script lists the reasons, prints the whole game log and exits with code 1. Even a healthy game prints one ERROR line when it is closed early; that line is harmless, and the script skips it.&lt;/p&gt;

&lt;p&gt;As usual, I checked the script both ways, and it paid off. Its first version reported FAIL on a working build: the menu line was searched for with an operator that compares the whole line, while in the log the needed text is followed by a technical tail. And I made the check fail on purpose by putting a nonexistent main menu name into the script.&lt;/p&gt;

&lt;p&gt;The pipeline got a new windows-check job. It runs after the build, but on a Windows computer (windows-2025): it downloads the artifact with the .exe and runs the script.&lt;/p&gt;

&lt;p&gt;The check has its limits too: the game runs without a window, so the check does not see the picture, the buttons or the sound. But it will catch a build that does not start at all. In the end, each check covers its own area:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;GUT tests&lt;/strong&gt; - the game logic;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;the smoke test&lt;/strong&gt; - starting the project and moving between screens;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;the browser test&lt;/strong&gt; - the finished web build;&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;the Windows check&lt;/strong&gt; - the finished .exe.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Now that both builds are checked, they can be handed to the players.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 7. Uploading to itch.io
&lt;/h2&gt;

&lt;p&gt;Uploading is done by the official itch.io tool, butler. It is a console program: it has no window and is controlled by commands. I did not have to install it myself: on every run the pipeline downloads it to the server, just like Godot. butler uploads builds into channels (mine are windows and web) and sends only the changed parts of files. It needs an API key (API - application programming interface) from my itch.io account. The key must not be stored in a public repository, so it lives in Secrets - encrypted storage in the repository settings, and in the logs it is replaced with asterisks.&lt;/p&gt;

&lt;p&gt;Uploading is a separate publish job. It starts only when both the build and the Windows check are green, so a build that does not start will not reach the players:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;publish&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s"&gt;build&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;windows-check&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="c1"&gt;# start only when the build AND the Windows check are green
&lt;/span&gt;  &lt;span class="na"&gt;if&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/v')&lt;/span&gt; &lt;span class="c1"&gt;# only main or a version tag can upload
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;To avoid uploading something wrong by accident, you can do a dry run of the upload first. For that I added a manual run (in YAML this is workflow_dispatch): in the Actions tab you press Run workflow, choose a branch and decide whether to tick the publish box. Three cases are possible:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The box is unticked&lt;/strong&gt; (the default) - a trial run. butler runs with the --dry-run flag: it lists the files it would upload and changes nothing on itch.io.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The box is ticked, the main branch is chosen&lt;/strong&gt; - a real upload.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Any other branch is chosen&lt;/strong&gt; - the publish job is skipped, even with the box ticked. This way test branches never reach itch.io.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsh6eyzxpps1fxarr9mwq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fsh6eyzxpps1fxarr9mwq.png" alt="Run workflow dialog in GitHub Actions with the main branch selected and the publish checkbox unticked" width="800" height="502"&gt;&lt;/a&gt;&lt;/p&gt;
A manual run: the publish box is unticked by default, which makes it a safe trial run



&lt;p&gt;Besides the manual run, a real upload happens when a new version is released by tag, covered in the next step.&lt;/p&gt;

&lt;p&gt;I made the first real upload only after a trial one. Before it, I deleted the old, manually uploaded archives from the game page and enabled in-browser play for the web channel once. After that butler puts new builds into the same entries, and the page settings are preserved.&lt;/p&gt;

&lt;h2&gt;
  
  
  Step 8. Release by tag
&lt;/h2&gt;

&lt;p&gt;I wanted a new version to come out with a single action, and Git has tags for that. A &lt;strong&gt;tag&lt;/strong&gt; is a label on a specific commit, usually with a version number, for example v1.0.7. Unlike a branch, a tag does not move, so you can always see which code a version was built from.&lt;/p&gt;

&lt;p&gt;Releases are handled by the second file, release.yml. It starts when a version tag arrives on GitHub, and the first thing it does is call tests.yml as a whole, so the tests are described once and every release starts with them:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight yaml"&gt;&lt;code&gt;&lt;span class="na"&gt;on&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;push&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;tags&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="s2"&gt;"v*"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="c1"&gt;# a version tag like v1.0.7 starts a release
&lt;/span&gt;
&lt;span class="na"&gt;jobs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
  &lt;span class="na"&gt;test&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;uses&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;./.github/workflows/tests.yml&lt;/span&gt; &lt;span class="c1"&gt;# the same tests as on every push
&lt;/span&gt;  &lt;span class="na"&gt;build&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="na"&gt;needs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s"&gt;test&lt;/span&gt; &lt;span class="c1"&gt;# start only when the tests are green
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The version number is stored in three places in the project: in the settings (the label in the main menu takes it from there) and in two property fields of the Windows build. It is easy to set a tag and forget to bump the version in the project. So the first step of the build compares the number from the tag with all three places and stops if they do not match.&lt;/p&gt;

&lt;p&gt;I deliberately chose not to bump the number automatically. A tag is my decision that "this goes out to the players", and the number shows what kind of release it is: a fix (1.0.7 → 1.0.8), a new feature (1.0 → 1.1) or a big update (1.x → 2.0). That decision is better left to a human. Besides, automation had its downsides: a separate script is easy to forget when releases are rare, a number written only on the server would not get into the repository and my local builds, and branch protection would not let the pipeline write it back to main (more on that in the "Safety net" section).&lt;/p&gt;

&lt;p&gt;I also pinned the versions of all the tools: Godot, butler, the Python packages, the job computer (ubuntu-24.04 rather than ubuntu-latest) and the GitHub actions (ready-made building blocks for steps, for example actions/checkout@v7) are all written as exact numbers. For example, butler is downloaded by a number from a variable in release.yml rather than by the LATEST link, and the Python packages for the browser test are listed in requirements.txt with "==", meaning exactly that version. The Playwright version also determines which Chromium it downloads. Without pinning, one day a new version of some program would come out, and the pipeline would break by itself even though I had changed nothing. The flip side is that I have to update the versions myself.&lt;/p&gt;

&lt;p&gt;The first release by tag was 1.0.5, a tiny version with almost no changes, so that there was nothing to break. One tag, and a little over three minutes later both builds were on itch.io. And I tested the whole chain, including the Windows check, with the technical release 1.0.8, in which the game itself did not change: test, build, windows-check and publish took less than three minutes, and butler sent a patch of less than 80 KB for a Windows build of more than 150 MB.&lt;/p&gt;

&lt;h2&gt;
  
  
  Safety net: protecting main, Dependabot and rollback
&lt;/h2&gt;

&lt;p&gt;These three techniques are overkill for a one-person project, but this is how teams work, and I added them to learn.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A protected main.&lt;/strong&gt; Nothing stopped me from pushing broken code straight into main, and the red cross would only appear after the error was already in the working version. GitHub lets you protect a branch with a set of rules (a ruleset): Settings → Rules → Rulesets → New branch ruleset. My "Protect main" set has four rules:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Restrict deletions&lt;/strong&gt; - the main branch cannot be deleted.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Block force pushes&lt;/strong&gt; - the history of main cannot be forcibly rewritten.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Require a pull request before merging&lt;/strong&gt; - changes get into main only through a pull request. The number of required approvals is zero: there is nobody but me to approve.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Require status checks to pass&lt;/strong&gt; - a pull request can be merged only when the test check from tests.yml is green.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;There are no exceptions even for the repository owner, and GitHub Desktop now warns right away if you try to commit directly to main. I tested the protection with a red check too: I opened a pull request with a deliberately broken test, and the Merge pull request button became inactive.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Dependabot.&lt;/strong&gt; Pinned versions get outdated. Dependabot is a bot built into GitHub: once a month it checks whether new versions of GitHub actions have come out and opens a pull request with the update by itself. To the bot, Godot and butler are just numbers in my files, so I update them myself.&lt;/p&gt;

&lt;p&gt;The catch is that the bot's branch is checked only by tests.yml, while an updated action may also be used in release.yml. So before merging I run release.yml manually on the bot's branch with the publish box unticked. This could be automated, but I deliberately kept the manual run: updates come once a month, and automation would require extra conditions in release.yml and spend Windows server minutes every time. For such a rare case, checking by hand is cheaper.&lt;/p&gt;

&lt;p&gt;For the same reason I did not trust the bot with the Python packages, even though it can track them. They are needed by the browser test, which runs only in release.yml: a pull request with a new version of Playwright (and with it a new Chromium) would turn green without ever starting a browser. So I update the Python packages myself and run the browser test locally first.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rollback.&lt;/strong&gt; If a released version is broken, it is enough to run the release manually from the tag of the last working version. The pipeline will take the project and the pipeline files themselves as they were at the moment of the tag, and upload the game again. I tested it: after releasing 1.0.6, I brought version 1.0.5 back to itch.io, and then 1.0.6 again. Both times butler sent only the difference between the builds, so the rollback took a couple of minutes.&lt;/p&gt;

&lt;p&gt;This is one more reason to release versions by tag: each version is tied to its commit forever, so any of them can be brought back with the same pipeline, without hunting for old archives on your computer.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a release looks like now
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Create a branch for the release (for example, release-1.0.8) and bump the version number in three places.&lt;/li&gt;
&lt;li&gt;Commit, push the branch and open a pull request.&lt;/li&gt;
&lt;li&gt;Wait for the green check and merge the changes into main.&lt;/li&gt;
&lt;li&gt;Put the v1.0.8 tag on the latest commit of main and push it.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;After that the pipeline checks the version, runs the tests, builds the game, checks it in the browser and on Windows, and only then uploads it to itch.io. All that is left to do by hand is the devlog text on the game page.&lt;/p&gt;

&lt;h2&gt;
  
  
  Mistakes I made
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A tag on the wrong commit.&lt;/strong&gt; I put the v1.0.6 tag on the second-to-last commit of main. Nothing broke, but since then I check the tag before pushing it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A red cross that was not my fault.&lt;/strong&gt; Once only the report publishing step failed because of a GitHub outage, and the next run passed. Lesson: first check exactly which step is red, and only then look for an error in the code.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A server error when pushing.&lt;/strong&gt; A couple of times GitHub Desktop returned "Internal Server Error". This is a temporary service failure: commits and tags are not lost, just try again in a minute.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A workaround for a problem that did not exist.&lt;/strong&gt; The game icon is stored in the export settings not as a file path but as a hidden identifier. I decided that on a clean server Godot would not find this identifier and would put the default icon into the .exe, and I even added a workaround step to the pipeline. Comparing the real files showed that the server finds the icon by itself, and I removed the workaround. Lesson: check a guess against the real files first.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The job name is part of the protection.&lt;/strong&gt; The required check in the rules is referenced by the job name. Rename the job, and every pull request will hang waiting.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The README fell behind the pipeline.&lt;/strong&gt; After the Windows check appeared, the project description still described the old chain. The pipeline checks code, not text, so now I finish every pipeline change by updating the README.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Does a small project need all this?
&lt;/h2&gt;

&lt;p&gt;I split everything I did into three groups.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A must, even if you work alone:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Git and a remote repository.&lt;/strong&gt; It takes time to figure out, but it pays off: the project gets a full history of changes and a backup.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Running tests automatically on every push.&lt;/strong&gt; If you already have tests, it is one small file, and errors start showing up right after a change, not before a release.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The habit of seeing every check fail, including in the pipeline.&lt;/strong&gt; A test that fails in the editor does not guarantee that the pipeline will notice: the pipeline may not find the tests or may not get the exit code, and the run will stay green. So every new check is worth breaking on purpose at least once to see the red cross on GitHub. Without that, the automation runs on faith.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Useful if you release often or have a web version:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Building on a server and uploading automatically&lt;/strong&gt; save time with every update.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Checks of finished builds in the browser and on Windows&lt;/strong&gt; are probably the most valuable part: they look at what the player will get.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Version checks and pinned tool versions&lt;/strong&gt; cost almost nothing but save you from unpleasant surprises.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Overkill for a solo project, but useful for learning:&lt;/strong&gt; protecting main, Dependabot, the Allure report and rollback through the pipeline. A solo developer can get by with reading logs, manual version updates and uploading an old archive, but practicing team techniques on my own project was worth it.&lt;/p&gt;

&lt;p&gt;Some things I deliberately did not add, for example a linter that checks code style: it produced hundreds of remarks about long lines, and I would have had to rewrite working code just for formatting. Deciding not to do something is also a decision, as long as it is a conscious one. CI/CD pays off when a project is alive and keeps getting updates. In big studios everything is on a larger scale, but the ideas are the same: changes are checked automatically, a red check stops the release, and the release itself is the same every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Summary
&lt;/h2&gt;

&lt;p&gt;Checking used to start with the Run All button, and now it starts with an ordinary push. Releasing has come down to a tag: after that the game goes through the tests, gets built, is checked in the browser and on Windows, and only then lands on itch.io. The same every time, with no "forgot to tick the box" or "built the wrong version". The next level would be continuous deployment, where every change in main goes to the players right away without a separate release command, but I want to decide myself when a new version comes out.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4gmmja107puodyag86pq.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4gmmja107puodyag86pq.png" alt="Pipeline diagram: tests.yml runs on every push, release.yml goes from a version tag through tests, build, browser and Windows checks to butler" width="799" height="314"&gt;&lt;/a&gt;&lt;/p&gt;
Two paths through the pipeline: a regular push checks changes, and a version tag takes the game from the tests to the itch.io page



&lt;p&gt;Making my own game, I saw CI/CD from two sides: as a developer who wants to release new versions quickly and without errors, and as a QA engineer who is responsible for making the checks along the way trustworthy. My main takeaway is this: quality assurance in this chain is not on the side but at its very foundation. A machine can be trusted with the pipeline only because there are checks at every step.&lt;/p&gt;

&lt;p&gt;That is why working on quality does not end with writing automated tests. You need to check finished builds where people will play them, investigate failed runs, keep an eye on flaky tests and know exactly what each check catches and what remains unprotected, and close those gaps. No wonder experience with CI/CD is so valued in test automation.&lt;/p&gt;

&lt;p&gt;If you want to try it yourself, start with one file that runs the tests on every push, and make it fail once right away. Everything else can be added one step at a time. My whole pipeline is in an open repository: if you make a copy of it (a fork) and enable Actions, the tests will start running for you too (only the upload to itch.io will not work without your own key).&lt;/p&gt;

&lt;h3&gt;
  
  
  Further reading
&lt;/h3&gt;

&lt;p&gt;I tested everything described here on my own project, and for details it is best to go to the official documentation:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://martinfowler.com/articles/continuousIntegration.html" rel="noopener noreferrer"&gt;Martin Fowler: "Continuous Integration", the classic article on continuous integration&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.github.com/en/actions/get-started/understand-github-actions" rel="noopener noreferrer"&gt;GitHub Actions: understanding GitHub Actions&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.github.com/en/actions/how-tos/manage-workflow-runs/manually-run-a-workflow" rel="noopener noreferrer"&gt;GitHub Actions: manually running a workflow&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/about-rulesets" rel="noopener noreferrer"&gt;GitHub: about rulesets&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.github.com/en/code-security/how-tos/secure-your-supply-chain/secure-your-dependencies/auto-update-actions" rel="noopener noreferrer"&gt;GitHub: keeping your actions up to date with Dependabot&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://docs.godotengine.org/en/stable/tutorials/editor/command_line_tutorial.html" rel="noopener noreferrer"&gt;Godot: command line tutorial&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://gut.readthedocs.io/en/latest/Command-Line.html" rel="noopener noreferrer"&gt;GUT: running tests from the command line&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://playwright.dev/python/docs/test-runners" rel="noopener noreferrer"&gt;Playwright for Python: the pytest plugin&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://allurereport.org/docs/" rel="noopener noreferrer"&gt;Allure Report: documentation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://itch.io/docs/butler/pushing" rel="noopener noreferrer"&gt;itch.io: pushing builds with butler&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  Project and contacts
&lt;/h3&gt;

&lt;p&gt;Links to the project, the game and my contacts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/KurchikovDA/taveronen-the-beginning" rel="noopener noreferrer"&gt;The project (game, automated tests and pipeline) on GitHub&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://voljulan.itch.io/taveronen-the-beginning" rel="noopener noreferrer"&gt;The game Taveronen: The Beginning on itch.io&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://kurchikovda.github.io/taveronen-the-beginning/" rel="noopener noreferrer"&gt;Allure report&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/voljulan/automated-tests-for-a-godot-game-where-to-start-and-how-to-write-your-first-test-3i71"&gt;Article: Automated Tests for a Godot Game: Where to Start and How to Write Your First Test&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/voljulan/release-at-any-cost-how-an-ai-mentor-helped-me-ship-my-first-game-1dl1"&gt;Article: How I shipped my first game&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.linkedin.com/in/denis-kurchikov" rel="noopener noreferrer"&gt;LinkedIn: Denis Kurchikov&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://t.me/Voljulan" rel="noopener noreferrer"&gt;Telegram: @Voljulan&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Originally published in Russian on &lt;a href="https://dtf.ru/id3551651/5405325-ci-cd-dlya-odinochnogo-proekta" rel="noopener noreferrer"&gt;DTF&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>cicd</category>
      <category>git</category>
      <category>gamedev</category>
      <category>testing</category>
    </item>
    <item>
      <title>Automated Tests for a Godot Game: Where to Start and How to Write Your First Test</title>
      <dc:creator>Denis Kurchikov</dc:creator>
      <pubDate>Tue, 03 Nov 2026 14:40:52 +0000</pubDate>
      <link>https://dev.to/voljulan/automated-tests-for-a-godot-game-where-to-start-and-how-to-write-your-first-test-3i71</link>
      <guid>https://dev.to/voljulan/automated-tests-for-a-godot-game-where-to-start-and-how-to-write-your-first-test-3i71</guid>
      <description>&lt;p&gt;Test automation has long been business as usual in web and mobile development, but automated testing in games gets written about far less often. So a tester who wants to learn it can be completely unsure where to even begin. In my case, the thing under test was my own pet project, Taveronen: The Beginning, a small cozy idle game (a genre where resources pile up on their own) about a tavern, made with the Godot engine. That's where I learned to write automated tests from scratch, and my own game turned out to be a perfect training ground: I know every system in it, I can read any script, and if a test finds a bug, I can fix it myself.&lt;/p&gt;

&lt;p&gt;A few words about the engine for those who haven't met it yet. Godot is a free, open-source engine for both 2D and 3D games. It's lightweight and especially popular with indie developers. A Godot game is built from scenes, and scenes are built from nodes: a button, an image, a sound or a timer is each a separate node, and you can attach a script with logic to it. It's a great engine to start making games with.&lt;/p&gt;

&lt;p&gt;In this article, I'll explain in plain words what an automated test is, where a beginner can find answers, and how to go through the full cycle of writing a test, even if you've never done it before. I'll also show the plan my tests followed from there: from data checks to real scenes and mouse clicks. All of it on a working example, with every pitfall I ran into along the way. This article is only about tests inside the engine; running them automatically and shipping the game are a story for another time.&lt;/p&gt;

&lt;p&gt;If you don't have a project of your own to practice on, no problem: Taveronen's code is open to everyone. The link is at the end of the article, and you can repeat everything described here on the same game.&lt;/p&gt;

&lt;p&gt;And let me admit it right away: writing automated tests turned out to be genuinely fun. There's a special pleasure in watching the work get done by itself, but getting there takes a fair bit of sweat.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpf48ed135j72oqon88lb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpf48ed135j72oqon88lb.png" alt="Godot editor with a test file open at the top and the GUT panel at the bottom showing a green result: 63 tests passed, 0 failed" width="800" height="615"&gt;&lt;/a&gt;&lt;/p&gt;
The Godot editor: a test file at the top, the GUT panel with a green run summary at the bottom



&lt;h2&gt;
  
  
  What is an automated test?
&lt;/h2&gt;

&lt;p&gt;Let's start with the main question: why would a tester automate checks at all? Manual testing shines where you need a living person: to feel the pacing, to notice a janky animation or an awkward line of text. But every project also has checks of a different kind: repetitive, boring and still mandatory. Did the economy break after a price change? Does an old save still load? Does every line have a translation? These are exactly the checks that get handed over to automated tests.&lt;/p&gt;

&lt;p&gt;Every tester knows what a test case is: preconditions, steps and an expected result. An automated test is the same test case, only a program runs it. It sets up the preconditions itself, performs the steps itself, and instead of eyeballing the result, it compares it with an expected value we wrote into the test in advance. If they match, the test is green; if not, it's red, and the report shows exactly what went wrong.&lt;/p&gt;

&lt;p&gt;It's easy to get lost in the terms here: automated tests, unit tests, integration tests. In fact, they answer two different questions. "Automated" says who does the checking: a program rather than a person. "Unit" and "integration" are about scale, that is, how big a piece of the game is being checked.&lt;/p&gt;

&lt;p&gt;A unit test checks one small piece of code in isolation from everything else, for example the function that spends gold. An integration test checks how the pieces work together: it opens the real tavern scene, presses a shop button and checks that the gold was deducted, the item appeared in the tavern hall and the purchase was written to the save file. To put it in bicycle terms: unit tests check each part separately, while an integration test is taking the bike for a ride around the yard.&lt;/p&gt;

&lt;p&gt;So a unit test is simply the smallest kind of automated test. And it can do something a manual check can't: a tester only sees the screen, while a unit test looks right into the code, calls a function and checks what it returned.&lt;/p&gt;

&lt;p&gt;It's also important to understand the limits of automated tests from the start: they check exactly what you told them to check, and nothing more. I have a test that proves the font contains every letter of every language in the game. It's green, and yet the Ukrainian letter Ї was drawn too small in that font, and that was only caught by eye. The test knows the letter is there, but not what it looks like on screen.&lt;/p&gt;

&lt;p&gt;For a program to run our test cases, it needs a tool, and in Godot you have to install that tool yourself.&lt;/p&gt;

&lt;h2&gt;
  
  
  The tool and where to find answers
&lt;/h2&gt;

&lt;p&gt;This section is about preparation. First we'll install the tool that will run the tests, and then we'll figure out where to find answers to the questions that are bound to come up along the way.&lt;/p&gt;

&lt;h3&gt;
  
  
  Installing GUT
&lt;/h3&gt;

&lt;p&gt;Godot has no built-in testing system; you install one as a separate plugin. The most popular ones are GUT (Godot Unit Test) and gdUnit4. I chose GUT 9.7.1 for a very practical reason: at the time, official gdUnit4 support only went up to Godot 4.7.1, and my project runs on 4.7.2.&lt;/p&gt;

&lt;p&gt;GUT tests are written in the same language as the game itself: GDScript. It's Godot's own language, with Python-like syntax: code blocks are marked by indentation rather than curly braces, and simple code reads almost like plain text. If you know even a little Python, you'll feel at home very quickly.&lt;/p&gt;

&lt;p&gt;Installation takes a few minutes:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;In the AssetLib tab (the built-in add-on catalog), find GUT, download and install it.&lt;/li&gt;
&lt;li&gt;Enable the plugin: Project → Project Settings → Plugins, tick the box next to Gut. The GUT panel will appear at the bottom of the editor.&lt;/li&gt;
&lt;li&gt;Create a folder for tests, for example &lt;code&gt;res://test/unit&lt;/code&gt; (&lt;code&gt;res://&lt;/code&gt; is the project root, short for resources).&lt;/li&gt;
&lt;li&gt;In the GUT panel, add this folder to Test Directories and turn on Include Subdirs so that tests in subfolders are found too.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;If you don't specify a folder, GUT will honestly tell you "Cannot run tests". That's the first error almost everyone runs into.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3hbxjq0t6qy9ag03hedt.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F3hbxjq0t6qy9ag03hedt.png" alt="GUT panel settings: the test folders listed under Test Directories and the Include Subdirs option switched on" width="493" height="217"&gt;&lt;/a&gt;&lt;/p&gt;
GUT panel settings: test folders in Test Directories and the Include Subdirs box ticked



&lt;p&gt;Before the first test, there's one thing worth doing that's easy to forget: excluding the tests from the game build. In the export settings (Project → Export, Resources tab), the exclusion field gets &lt;code&gt;test/*, addons/gut/*&lt;/code&gt;. In my project, that field turned out to be empty, and the next build would have shipped the tests, along with GUT, straight to players.&lt;/p&gt;

&lt;h3&gt;
  
  
  Where to find answers
&lt;/h3&gt;

&lt;p&gt;As soon as you sit down to write tests, questions start pouring in: what's the right check called, how do I wait for a scene to load, how do I read a file? The good news is that almost any question like that is answered by one of two sources (though, of course, you can always ask an AI). As for the game itself, its code tells you the most, and we'll come back to it when we get to the first test.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The first source is the GUT documentation&lt;/strong&gt; at gut.readthedocs.io. It covers everything about the tests themselves. Three pages are enough to get started: Quick Start explains how to write and run your first test, Awaiting covers how to wait for a scene to load, and GutTest in the Class Reference section has the full list of &lt;code&gt;assert_*&lt;/code&gt; checks with descriptions. That last page is the one I kept coming back to the most. The other sections will come in handy as your tests grow.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc7r7kltyvzrl6707coen.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fc7r7kltyvzrl6707coen.png" alt="A table from the GutTest documentation page listing assert methods such as assert_engine_error, assert_eq and assert_false" width="798" height="239"&gt;&lt;/a&gt;&lt;/p&gt;
The GutTest page in the GUT documentation: the list of assert_* checks



&lt;p&gt;GUT itself also gives hints. When I used the deprecated check &lt;code&gt;assert_call_count&lt;/code&gt;, the test passed, but the report showed a [DEPRECATED] note with the name of the new function. And when the documentation falls short, you can open the source code: it sits right in the project, in the &lt;code&gt;addons/gut&lt;/code&gt; folder, and all the checks are collected in &lt;code&gt;test.gd&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The second source is the Godot documentation&lt;/strong&gt; at docs.godotengine.org. This covers everything about the engine itself: how to read a file (the &lt;code&gt;FileAccess&lt;/code&gt; class), walk through a folder (&lt;code&gt;DirAccess&lt;/code&gt;), parse JSON (JavaScript Object Notation, a text data format), find text by a pattern (RegEx, regular expressions), get the game's language (&lt;code&gt;TranslationServer&lt;/code&gt;) or the sound volume (&lt;code&gt;AudioServer&lt;/code&gt;). The same documentation is built into the editor: F1 opens a search through the help, and no internet is needed for that.&lt;/p&gt;

&lt;p&gt;Here's how it works in practice. I wanted to check that the game's font contains every character from the translation table. In the Godot docs, the &lt;code&gt;Font&lt;/code&gt; class has a &lt;code&gt;has_char()&lt;/code&gt; function that tells you whether the font can draw a character with a given code, and strings have &lt;code&gt;unicode_at()&lt;/code&gt;, which returns that code. Those two functions plus a loop over the table were all it took to build a test that checks every character in every language of the game.&lt;/p&gt;

&lt;p&gt;That covers the sources. One last question remains before the code: where to start.&lt;/p&gt;

&lt;h2&gt;
  
  
  The plan: where to start
&lt;/h2&gt;

&lt;p&gt;Before writing tests, it's worth deciding what to check first. Without a plan, it's easy to spend your effort on trifles and miss what matters. So I first worked out how the game's main systems are built and drew up a coverage plan. Coverage is how much of the code or the game the tests check. The plan rested on three principles.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Risk-based coverage, not percentages.&lt;/strong&gt; In Java, there are tools like JaCoCo that measure coverage as a percentage: they count what share of the code lines the tests executed. GDScript had no mature tool of that kind at the time, but for a small project something else matters more: first cover what would hurt the most if it broke. For Taveronen, that's the economy, saves and translations.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;From simple to complex.&lt;/strong&gt; Data checks came first, and I left integration tests with real scenes for last. In the project, tests are sorted into folders by level: unit, integration, smoke (a quick check of the game's main paths) and e2e (end-to-end, checking the whole game in a browser).&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One new technique per stage.&lt;/strong&gt; Each stage added a little something new, so the new knowledge never piled up all at once. In the end, the plan looked like this:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Shop and translation data:&lt;/strong&gt; the number of items and their prices, translation keys (the game uses them to find the right text), fonts, the order of languages. What we learn: reading tables, dictionaries and files right from a test.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gold, its signals and click bonuses&lt;/strong&gt;. What we learn: preparing fresh data with &lt;code&gt;before_each&lt;/code&gt;, a function GUT calls before every test, and catching signals, which the game uses to announce that something has happened: &lt;code&gt;watch_signals&lt;/code&gt; records them, and &lt;code&gt;assert_signal_emitted&lt;/code&gt; checks that the right signal arrived.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Saves&lt;/strong&gt;. Tests run from the same editor as the game, so they can easily wipe out real progress. What we learn: working with a test file without touching the real one: the save path is swapped before each test, and &lt;code&gt;after_each&lt;/code&gt;, a function GUT calls after every test, cleans up after it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Timed income&lt;/strong&gt;. Income depends on the tavern scene, which doesn't exist in a test. What we learn: replacing parts of the game with test doubles, that is, stand-ins for real objects. There are three kinds: a stub returns a canned answer, a spy remembers how it was called, and a mock knows in advance which calls to expect and checks them itself. For a game this small, spies and mocks may be overkill, but I deliberately tried them for the experience.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Real scenes: a purchase, the game's ending, a mouse click&lt;/strong&gt;. What we learn: launching scenes, waiting for them to load with &lt;code&gt;await&lt;/code&gt;, and pressing buttons, including with the mouse, using &lt;code&gt;GutInputSender&lt;/code&gt;, GUT's helper for clicks.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Music, audio buses and volume sliders&lt;/strong&gt;. What we learn: testing sound without sound: volume and audio buses, that is, separate channels for music and effects, are read as ordinary data.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The plan paid off before the first line of test code: a bug turned up while I was going through the code. "New Game" didn't reset the flag that marks a playthrough as started, so if you quit the game right during the intro, the "Continue" button in the menu stayed active even though there was nothing to continue. Reading code carefully is testing too.&lt;/p&gt;

&lt;p&gt;I've already completed every item of this plan, and the tests are in a public repository: they're organized into folders, and each one has comments explaining what it checks and why. So you can open any technique from the list and take it apart on working code. In this article, though, we'll go through one test from the second item in detail, the one about gold and its signals: it shows nicely how a test grows out of the game's code.&lt;/p&gt;

&lt;h2&gt;
  
  
  The first test: from idea to a green run
&lt;/h2&gt;

&lt;p&gt;There are two parts here. First we'll read the game function we're going to test, and then we'll write a test for it from scratch and go all the way to a green run, step by step.&lt;/p&gt;

&lt;h3&gt;
  
  
  Reading the game code
&lt;/h3&gt;

&lt;p&gt;Every test starts with understanding the code you're about to test. It's convenient to read it right in the Godot editor: Ctrl-clicking a function name jumps to where it's declared, and the top of a script usually gathers signals, constants and variables, that is, everything the game remembers and uses to announce changes. Let's take spending gold in Taveronen as an example (I added the code comments for this article):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="n"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;spend_gold&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="c1"&gt;# takes an amount (whole number), answers yes or no&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;gold&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="c1"&gt;# is there enough gold?&lt;/span&gt;
        &lt;span class="n"&gt;gold&lt;/span&gt; &lt;span class="o"&gt;-=&lt;/span&gt; &lt;span class="n"&gt;amount&lt;/span&gt; &lt;span class="c1"&gt;# take the price away&lt;/span&gt;
        &lt;span class="n"&gt;gold_changed&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;emit&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;gold&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# tell the rest of the game: gold changed, here is the new balance&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;true&lt;/span&gt; &lt;span class="c1"&gt;# answer "yes, spent"&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;false&lt;/span&gt; &lt;span class="c1"&gt;# not enough gold: answer "no" and change nothing&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;These lines tell us almost everything a test needs. The function takes an amount (&lt;code&gt;amount: int&lt;/code&gt;, a whole number) and answers yes or no (&lt;code&gt;-&amp;gt; bool&lt;/code&gt;). If there's enough gold, it subtracts the amount, emits the &lt;code&gt;gold_changed&lt;/code&gt; signal with the new balance and returns &lt;code&gt;true&lt;/code&gt;. If not, it returns &lt;code&gt;false&lt;/code&gt; right away and changes nothing. A signal in Godot is a way to tell other parts of the game that something has happened: an object emits a signal, and everything connected to it finds out and reacts. Three tests follow naturally from this: there's enough gold, there isn't enough gold, and the balance-change signal arrives only when gold was actually spent.&lt;/p&gt;

&lt;h3&gt;
  
  
  Writing our own test
&lt;/h3&gt;

&lt;p&gt;Let's take the last one: we'll check that when there isn't enough gold, the refused purchase doesn't emit &lt;code&gt;gold_changed&lt;/code&gt;. You can repeat this test yourself on the same project.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 1. Pick a risk.&lt;/strong&gt; As in the plan, start not with what's easiest to write, but with what would hurt the most if it broke. It helps to think of the &lt;code&gt;gold_changed&lt;/code&gt; signal as a phone call: GameManager (the game's main script, which holds gold, purchases and saves) "calls" whenever gold changes, and the shop listens for these calls and turns the buy buttons on or off after each one. If a call goes missing or rings at the wrong moment, the gold stays correct, but the shop buttons show an outdated state. That's a silent failure: the numbers are right, no gold test will notice it, but the player will.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 2. Read the code.&lt;/strong&gt; We've already gone through the function itself: the signal is emitted only inside the "enough gold" branch, so when gold is short, there should be no call. What's left is to find out who listens to that call. The place where the shop connects to the signal is easy to find with a project-wide search. Connecting is done with the &lt;code&gt;connect&lt;/code&gt; function (essentially a subscription to the signal), and the shop panel script has the line &lt;code&gt;GameManager.gold_changed.connect(...)&lt;/code&gt;: from that moment on, the shop hears about every change in gold.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 3. Write down the test case.&lt;/strong&gt; Precondition: the player has 50 gold. Action: spend 60. Expected result: &lt;code&gt;gold_changed&lt;/code&gt; never arrives.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 4. Find the tools.&lt;/strong&gt; The GutTest page has everything you need for signals: &lt;code&gt;watch_signals(object)&lt;/code&gt; starts recording an object's signals, and &lt;code&gt;assert_signal_not_emitted&lt;/code&gt; checks that a given signal never happened. The same page has an important detail: watching must be turned on before the action, because GUT only records what happens after that line.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 5. Write the test.&lt;/strong&gt; Gold tests live in their own file: one file covers one topic. Here's the beginning of that file and the new test in it. I comment almost every line, but here the comments are trimmed a little:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight gdscript"&gt;&lt;code&gt;&lt;span class="n"&gt;extends&lt;/span&gt; &lt;span class="nc"&gt;GutTest&lt;/span&gt; &lt;span class="c1"&gt;# "I am a test file" - GUT can now find and run me&lt;/span&gt;

&lt;span class="kd"&gt;var&lt;/span&gt; &lt;span class="n"&gt;game_manager&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nc"&gt;Node&lt;/span&gt; &lt;span class="c1"&gt;# Declared once, so before_each and every test can see it&lt;/span&gt;

&lt;span class="n"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;before_each&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;void&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="c1"&gt;# GUT calls it before every test in this file&lt;/span&gt;
    &lt;span class="n"&gt;game_manager&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nf"&gt;autofree&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nf"&gt;load&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;"res://scripts/autoload/game_manager.gd"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;new&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# A fresh GameManager in memory&lt;/span&gt;

&lt;span class="c1"&gt;# No signal when spending is refused&lt;/span&gt;
&lt;span class="c1"&gt;## WHAT: gives the player only 50 gold by hand, starts watching the signals, then tries to spend 60.&lt;/span&gt;
&lt;span class="c1"&gt;## WHY: a refused purchase changes nothing, so the game must not be told that gold changed.&lt;/span&gt;
&lt;span class="c1"&gt;## PASSES when: gold_changed was NOT sent at all. FAILS when the signal comes even once.&lt;/span&gt;
&lt;span class="n"&gt;func&lt;/span&gt; &lt;span class="nf"&gt;test_refused_spend_does_not_emit_gold_changed&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&lt;/span&gt; &lt;span class="n"&gt;void&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="c1"&gt;# Name starts with test_ so GUT runs it&lt;/span&gt;
    &lt;span class="n"&gt;game_manager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;gold&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="mi"&gt;50&lt;/span&gt; &lt;span class="c1"&gt;# Put only 50 gold in by hand&lt;/span&gt;

    &lt;span class="nf"&gt;watch_signals&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;game_manager&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# Start watching signals BEFORE the action&lt;/span&gt;

    &lt;span class="n"&gt;game_manager&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;spend_gold&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;60&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="c1"&gt;# Try to spend more than the player has&lt;/span&gt;

    &lt;span class="c1"&gt;# Check 1: a refused spend does not send the gold_changed signal at all&lt;/span&gt;
    &lt;span class="nf"&gt;assert_signal_not_emitted&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;game_manager&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"gold_changed"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;"A refused spend should not send gold_changed"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;strong&gt;&lt;code&gt;extends GutTest&lt;/code&gt;&lt;/strong&gt; tells GUT "I'm a test file" and also gives the file all the &lt;code&gt;assert_*&lt;/code&gt; checks, so you can call them just by name. GUT calls the &lt;code&gt;before_each&lt;/code&gt; function itself before every test in the file. Here it creates a fresh GameManager, the game's main script we've already met. The &lt;code&gt;load(...).new()&lt;/code&gt; line creates it from the script file, and &lt;code&gt;autofree&lt;/code&gt; asks GUT to delete the object after the test. An object like this lives only in memory: its &lt;code&gt;_ready()&lt;/code&gt; function (Godot calls it when an object enters the game) doesn't run, so there are no timers, no music, no reading of the real save, and it has exactly zero gold. That way every test starts with a clean slate.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;func test_...&lt;/code&gt;&lt;/strong&gt; is the test itself: GUT only runs functions whose names start with &lt;code&gt;test_&lt;/code&gt;. I write the name as a sentence, even if it's long: when a test fails, its name appears in the report, and it should tell you what broke. Inside the test there are three parts separated by blank lines: setup (put in 50 gold and start watching signals), action (try to spend 60) and the check. They're commonly called Arrange, Act, Assert, and for a tester they're the same old precondition, step and expected result.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;assert_signal_not_emitted&lt;/code&gt;&lt;/strong&gt; is the check itself. The line reads almost like a sentence: "I assert that the gold_changed signal was not emitted." The last argument is the message that appears in the report if the check fails. And the most common check is &lt;code&gt;assert_eq&lt;/code&gt;: it compares a value from the game with the expected one, and the actual value always comes first, the expected one second.&lt;/p&gt;

&lt;p&gt;Above the test there are three comment lines: WHAT (what we do), WHY (why it's worth checking) and PASSES when (when the test is green), and above the check there's a Check label describing the game's behavior. I write them not for GUT but for myself six months from now: the label should answer the question "what broke if this check is red?" That's why "a refused spend does not send the gold_changed signal" is far more useful than a faceless "check the signal".&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Step 6. Run it.&lt;/strong&gt; First save the file (Ctrl+S), otherwise GUT will run the old version, and then press Run All in the GUT panel. A green run shows "All tests passed!" and the counters: how many files, tests and checks were run.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Foykjd976v2gxmxkejriy.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Foykjd976v2gxmxkejriy.gif" alt="Running all tests in the GUT panel: game scenes open one after another and the run ends with &amp;quot;All tests passed!&amp;quot;" width="600" height="359"&gt;&lt;/a&gt;&lt;/p&gt;
Running all the tests: the game's scenes open one after another, and at the end the summary says "All tests passed!"



&lt;p&gt;&lt;strong&gt;Step 7. Break the test on purpose.&lt;/strong&gt; A green test proves nothing on its own: maybe it simply can't turn red. So I deliberately break every new test, make sure it really fails, and then put everything back.&lt;/p&gt;

&lt;p&gt;You need to break exactly the value being checked. Once I added an extra letter only to the message text, and of course the test stayed green: the message doesn't affect anything. And when a test checks that something did not happen, it's easier to break the input data. Here I changed 50 gold to 100: the purchase went through, the signal arrived, and the test turned red. Then I set it back to 50, and everything was green again. One more rule learned the hard way: replace the line for the red check instead of adding a new one next to it, otherwise the report will show an extra check, and the extra line is easy to forget to delete.&lt;/p&gt;

&lt;p&gt;Here's what GUT showed in the report when the test broke (copied from the GUT panel):&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;res://test/unit/game_manager/test_game_manager_gold.gd
- test_refused_spend_does_not_emit_gold_changed
    [Failed]:  Expected object &amp;lt;Node#155659012458&amp;gt;(game_manager.gd) to NOT emit signal [gold_changed]:  A refused spend should not send gold_changed
          at line 108
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The first two lines&lt;/strong&gt; are the file and the name of the failed test.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Failed&lt;/strong&gt; means the check didn't pass.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Expected object ... to NOT emit signal [gold_changed]&lt;/strong&gt; is what was expected: &lt;code&gt;game_manager.gd&lt;/code&gt; should not have emitted &lt;code&gt;gold_changed&lt;/code&gt;, but it did.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A refused spend should not send gold_changed&lt;/strong&gt; is the message we wrote in the check ourselves.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;at line 108&lt;/strong&gt; is the line in the test file where the failed check sits. In the real file there are other gold tests above this one, so the line number is higher than in the snippet in this article.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;Step 8. Run everything and save.&lt;/strong&gt; After putting the value back, I run all the tests, not just the new one: the change might have affected its neighbors. Then the changes go to the repository on GitHub, where the tests run once more on their own, but that's a separate story.&lt;/p&gt;

&lt;p&gt;While writing your first tests, you'll almost certainly hit the same pitfalls I did. Here are the most common ones we haven't covered yet:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A test crashes on a dictionary&lt;/strong&gt; (a set of key-value pairs): the value is read with &lt;code&gt;["key"]&lt;/code&gt;, but the key isn't there. With &lt;code&gt;.get("key", -1)&lt;/code&gt;, the test fails with a clear message instead of an error and doesn't hide the checks that follow.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Function "not found"&lt;/strong&gt;: a typo in the name, like &lt;code&gt;Timer&lt;/code&gt; instead of &lt;code&gt;Time&lt;/code&gt; or &lt;code&gt;dublicate&lt;/code&gt; instead of &lt;code&gt;duplicate&lt;/code&gt;. GDScript cares about every letter, so check names against the code or the documentation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The counters suddenly doubled&lt;/strong&gt;: a copy of a file like &lt;code&gt;test_save_load-1.gd&lt;/code&gt; ended up in the tests folder, and GUT dutifully ran it as one more set of tests.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Lines "fell out" of a function after pasting code&lt;/strong&gt;: the indentation got messed up. In GDScript, indentation is part of the syntax.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An expected engine error fails the test&lt;/strong&gt;: if a test deliberately feeds the game a corrupted file, the engine logs an error, and GUT counts it as a failure. An expected error has to be declared with &lt;code&gt;assert_engine_error&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That's the whole cycle: pick a risk, read the code, write down the test case, find the tools in the documentation, write, run, break, restore and save. Writing one test isn't hard; after that, it's a matter of repeating this cycle for every item of the plan.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the tests found and how the game changed
&lt;/h2&gt;

&lt;p&gt;The main job of tests is to confirm that the game works as intended and doesn't break after changes. But along the way they also find things nobody expected, and I had a few finds like that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A real bug in the game.&lt;/strong&gt; A test fed the game a save file with language number 99. The game took the number from the file without checking it, and there was no such number in the language list, so a save like that could crash the settings screen. The test turned red, and in update 1.0.2 I fixed the loading: an invalid number is now replaced with English. Then came a small watchdog test that makes sure the number of languages in the save code matches the actual language list. If you add a new language and forget about that number, the test turns red.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A bug in the test itself.&lt;/strong&gt; The first run of the font test produced 12 red checks on the characters in the name of the Chinese language. The culprit wasn't the game but a rule in the test: a language's name is drawn in the font of the language it names, while the test looked for those characters in the font of a different column of the translation table. Hence the rule: if a test unexpectedly turns red, either the game or the test itself may be at fault, so check both.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Tests need maintenance.&lt;/strong&gt; For example, the localization checks take the languages straight from the translation table, so when four new languages were added to the game, the tests handled them with almost no changes. That's convenient but deceptive: the new languages also brought new risks (a font missing the right letters, broken formatting tags) that the old tests simply can't see. So when the game changes, keeping the old tests green isn't enough: new risks need new tests.&lt;/p&gt;

&lt;p&gt;Besides the bugs that got fixed, the tests also made me change the project itself. Players won't notice these changes, but the game became more reliable:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Saving through a temporary file.&lt;/strong&gt; While studying how saves work, I spotted a weak point: if the game gets cut off right in the middle of writing, the file can end up empty or truncated, and the player loses their progress. Now the game first writes the progress to a temporary file and only after the write is complete replaces the real save with it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Scene changes in separate functions.&lt;/strong&gt; Buttons like "Return to Main Menu" used to switch the screen right away, and in a test such a switch also destroys GUT's own results window. Now the scene change lives in a separate function: the test replaces it with a stub and calmly checks the rest of the button's logic, while the player notices no difference.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Tests don't reach players.&lt;/strong&gt; The export exclusions mentioned at the beginning make sure nothing extra gets into the build.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;This is probably the most unexpected conclusion for me. The value of automated tests isn't only in the bugs they find: to make a game easy to test, you have to make its code clearer and tidier, and the game itself benefits from that.&lt;/p&gt;

&lt;h2&gt;
  
  
  What about Unity and Unreal?
&lt;/h2&gt;

&lt;p&gt;Many games, especially at big studios, are made with Unity and Unreal, so a fair question is: will this experience be useful if tomorrow I have to test a game on another engine? Almost entirely, yes: the language and the names change, but the ideas stay the same.&lt;/p&gt;

&lt;p&gt;In Godot, tests are written in GDScript, in Unity in C# (the framework is built on NUnit), and in Unreal in C++. In Unity, a test without a scene is called an Edit Mode test, and a test with a real scene is a Play Mode test; in Unreal, these are simple and functional tests. Setup and cleanup exist everywhere: &lt;code&gt;before_each&lt;/code&gt; in GUT, &lt;code&gt;[SetUp]&lt;/code&gt; in Unity, &lt;code&gt;BeforeEach&lt;/code&gt; in Unreal.&lt;/p&gt;

&lt;p&gt;Oddly enough, the most noticeable difference when switching is a small one: the arguments of the equality check come in a different order. In GUT you write &lt;code&gt;assert_eq(actual, expected)&lt;/code&gt;, and in Unity &lt;code&gt;Assert.AreEqual(expected, actual)&lt;/code&gt;, the other way around.&lt;/p&gt;

&lt;p&gt;All the problems I had to solve carry over too: protecting real saves, waiting for a scene to load, and making sure a test can turn red. These are problems of the game, not the engine, so they'll come up again in another project, only solved with different tools.&lt;/p&gt;

&lt;p&gt;What's more, I think learning was actually easier on Godot:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Short loop.&lt;/strong&gt; Unit tests finish in seconds, and when there's so little time between a change and its result, you can make dozens of attempts in one evening. On top of that, Godot is distributed under the free MIT license, so you can simply download it onto a server that runs the tests automatically and launch it there, with no licenses or keys.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Everything in one box.&lt;/strong&gt; Test doubles are built right into GUT, while in Unity and Unreal people reach for third-party libraries such as Moq and GoogleMock. So I was learning the ideas themselves rather than someone else's tool on top of them.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Everything in plain sight.&lt;/strong&gt; Godot stores scenes in a text format you can read with your own eyes, the engine is open source, and GUT's source code sits right in the project. When the documentation wasn't enough, I could open the framework code and see how everything works.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;You'll still have to learn each engine's language and editor separately. But that's syntax and tooling, while understanding what to check and why carries over from engine to engine without any loss.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wrapping up
&lt;/h2&gt;

&lt;p&gt;In short, the tests have covered the most painful spots in the game, and it has become noticeably more reliable: every change is now checked right away for whether it broke something that used to work, and bugs are caught before players see them. All of this happens on its own after every change.&lt;/p&gt;

&lt;p&gt;The pleasure I mentioned at the beginning looks like this: you press Run All and just watch. Scenes open one after another, buttons get pressed without your help, the check counters climb, and a few seconds later the green "All tests passed!" appears. Everything you'd otherwise have to play through by hand after every change now fits into a single click, and the feeling that your back is covered is hard to beat.&lt;/p&gt;

&lt;p&gt;The main thing I've learned: an automated test is a precise question to the game, and the hardest part isn't the syntax but the question itself. What exactly can break, how do you notice it, and how do you make sure the test can turn red at all? The GUT and Godot docs will help with the syntax, but you have to come up with the question yourself, and that's where manual testing experience helps the most.&lt;/p&gt;

&lt;p&gt;That said, automation doesn't replace a living tester; it frees up their time. How the game looks, how it feels and the quality of its translations are still checked by people.&lt;/p&gt;

&lt;p&gt;If you've never written automated tests, here's my advice: take the project you know best (your own game is ideal, and if you don't have one, Taveronen will do), start with the simplest data test, and add one new technique at a time. And keep a log: what you did, what broke and why. I recorded every step in a devlog, and that's what eventually grew into this article.&lt;/p&gt;

&lt;p&gt;That's exactly why Taveronen is open: it's a small, finished game with everything worth practicing on, and the ready-made tests sit right next to it, showing what this can look like. Download the project from GitHub and open it in Godot 4.7.2. GUT 9.7.1 is already inside: just enable the plugin and press Run All. From there, it's up to you: write your own tests, break the game and see what they catch. When I was starting out, I really missed a working example I could open and take apart piece by piece, and I'll be glad if Taveronen becomes that example for someone else.&lt;/p&gt;

&lt;p&gt;How these tests became part of a pipeline that builds the game, checks it in a real browser and on Windows, and then publishes a release with a single tag, I told in the follow-up: &lt;a href="https://dev.to/voljulan/cicd-for-a-solo-project-from-the-first-test-to-release-eo1"&gt;CI/CD for a Solo Project: From the First Test to Release&lt;/a&gt; (&lt;a href="https://dtf.ru/id3551651/5405325-ci-cd-dlya-odinochnogo-proekta" rel="noopener noreferrer"&gt;in Russian on DTF&lt;/a&gt;).&lt;/p&gt;

&lt;p&gt;Links to the project, the game and my contacts:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://github.com/KurchikovDA/taveronen-the-beginning" rel="noopener noreferrer"&gt;The practice project (game and automated tests) on GitHub&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://voljulan.itch.io/taveronen-the-beginning" rel="noopener noreferrer"&gt;Taveronen: The Beginning on itch.io&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://dev.to/voljulan/release-at-any-cost-how-an-ai-mentor-helped-me-ship-my-first-game-1dl1"&gt;The article about making the game&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;LinkedIn: &lt;a href="https://www.linkedin.com/in/denis-kurchikov/" rel="noopener noreferrer"&gt;Denis Kurchikov&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Telegram: &lt;a href="https://t.me/Voljulan" rel="noopener noreferrer"&gt;@Voljulan&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Originally published in Russian on &lt;a href="https://dtf.ru/id3551651/5390533-avtotesty-dlya-igry-na-godot-s-chego-nachat-i-kak-napisat-pervyi-test" rel="noopener noreferrer"&gt;DTF&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>gamedev</category>
      <category>godot</category>
      <category>testing</category>
      <category>beginners</category>
    </item>
    <item>
      <title>Release at Any Cost: How an AI Mentor Helped Me Ship My First Game</title>
      <dc:creator>Denis Kurchikov</dc:creator>
      <pubDate>Fri, 23 Oct 2026 14:54:47 +0000</pubDate>
      <link>https://dev.to/voljulan/release-at-any-cost-how-an-ai-mentor-helped-me-ship-my-first-game-1dl1</link>
      <guid>https://dev.to/voljulan/release-at-any-cost-how-an-ai-mentor-helped-me-ship-my-first-game-1dl1</guid>
      <description>&lt;p&gt;For two years I tested games at a large studio. Then, while looking for my next job, I spent a month making and releasing my own pet project, Taveronen: The Beginning, a small, cozy idle game about a tavern, built in Godot. I made it together with an AI that became my mentor along the way.&lt;/p&gt;

&lt;p&gt;In this post I'll explain why I believe finishing a game of your own is the best school for anyone who works in game development or is planning to get into it. For a beginner it lays a foundation, and for an experienced professional it offers a view of the project as a whole. I'll also share the pitfalls I ran into and explain why even a tiny solo release hurts in the same places as a big studio launch.&lt;/p&gt;

&lt;h2&gt;
  
  
  A Shelf of Unfinished Games
&lt;/h2&gt;

&lt;p&gt;It all started at a local game dev meetup at the end of this summer. I'd been to meetups like that before, but that evening something else caught my attention: how many of the developers around me had never released a single game of their own.&lt;/p&gt;

&lt;p&gt;I talked to people who had been working on their own projects for five years or more without ever finishing them, so their games stayed on the shelf for good. The reason was almost always the same. A new idea comes along that seems brighter at the time than the one already in progress, everything starts over, then the next idea arrives, and the cycle repeats. Of course, even an unfinished project brings experience and knowledge, but without a release a lot of that work seems to vanish into thin air.&lt;/p&gt;

&lt;p&gt;A few months before the meetup, a friend and colleague of mine told me he had made a game on the principle of "release at any cost": not for anything material, but simply for the sake of releasing it. I didn't think much of it at the time, because I had no idea how many games never make it to release. After the meetup I saw it with my own eyes, and I got fired up about it myself.&lt;/p&gt;

&lt;p&gt;Yes, "releasing for the sake of releasing" isn't the noblest goal. From the outside it can look like an urge to add one more throwaway game to an already huge stream of forgettable content. But I wanted to break through that wall of never shipping and find out first-hand where the real difficulties hide. There turned out to be plenty of them, and they're what the rest of this post is about.&lt;/p&gt;

&lt;h2&gt;
  
  
  Seeing a Game vs. Making One
&lt;/h2&gt;

&lt;p&gt;As a tester, I worked on both large and small projects. QA experience really does teach you to see a game as a whole: how its systems connect, where they break, and what the player will feel when they do. But seeing a game and making one are completely different things.&lt;/p&gt;

&lt;p&gt;I had tried to start my own game many times, and every attempt ended the same way: the engine was downloaded, installed, opened... and closed. I think a lot of people know that feeling.&lt;/p&gt;

&lt;p&gt;This time I decided to approach it from a different angle and brought in an AI, Claude, as a partner. Not as a magic "make me a game" button, but as a mentor and a teammate who supplied content. It explained things and gave me code, while I put everything together in the engine myself: every scene, every script, every setting. The result was a tiny studio where I was the game designer and the AI handled the execution.&lt;/p&gt;

&lt;p&gt;I picked a simple engine, Godot. It's lightweight, and several of my friends had already worked with it, so there was always someone to ask for advice. Next I'd like to try my hand at Unity or Unreal, whether for a sequel to Taveronen or a completely new game.&lt;/p&gt;

&lt;h2&gt;
  
  
  Taveronen: An Idea I Could Actually Finish
&lt;/h2&gt;

&lt;p&gt;For my first project I deliberately chose the simplest idea possible and decided not to stray from it until release. That came easily to me, although I know how hard this kind of discipline is for others. My lack of experience became my safety net: I knew perfectly well that I couldn't handle complex systems yet, so I was never tempted to wander off into them.&lt;/p&gt;

&lt;p&gt;Taveronen is a game about an abandoned tavern that you bring back to life. You start in an empty, cold hall where you earn one gold coin every second, and you spend that money at the shop on furnishings: first a fireplace, then a wall torch, chairs, a table, and so on.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flzp6ce6kh20654hxwl34.gif" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Flzp6ce6kh20654hxwl34.gif" alt="Tavern timelapse" width="576" height="324"&gt;&lt;/a&gt;&lt;/p&gt;
The tavern coming to life over the course of the game (sped up)



&lt;p&gt;Each purchase appears in the hall with a small animation, and every new item makes the tavern a little warmer. From time to time, purchased items start to pulse, and if you click them, you get a couple of extra coins. Once you've bought everything, you've beaten the game.&lt;/p&gt;

&lt;p&gt;You can't lose, and there's no rush. The game runs at its own pace, so you can keep it open on a second monitor while you work, watch videos or play something else.&lt;/p&gt;

&lt;p&gt;That's exactly what I love about the idle genre. Someone who rarely plays games, or stopped playing long ago, can still play one and feel part of the gaming community, and I think that's very valuable.&lt;/p&gt;

&lt;p&gt;One special feature of Taveronen is its music. Instead of a short two-minute loop, the tavern plays a single calm track about half an hour long. It does loop, of course, but a full playthrough takes about that long, so the melody never has time to repeat and get on your nerves.&lt;/p&gt;

&lt;p&gt;In the end, the game has seven items, three languages (English, Russian and Spanish) and two versions: one for the browser and one for Windows.&lt;/p&gt;

&lt;h2&gt;
  
  
  Under the Hood
&lt;/h2&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa3nmfspkmxbs7ntsa4oy.jpg" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fa3nmfspkmxbs7ntsa4oy.jpg" alt="Godot editor" width="800" height="430"&gt;&lt;/a&gt;&lt;/p&gt;
Taveronen's main menu in the Godot editor



&lt;p&gt;Technically, Taveronen is simple, but in a single month I had to touch almost every part of development, so here's a quick tour of all of them.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Architecture.&lt;/strong&gt; The entire game state (gold, purchased items and bonus timers) lives in one global singleton script, and the scenes simply ask it what to display. Thanks to this, the menu, the tavern and the victory screen barely depend on one another. The code is written in GDScript with static typing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The tavern and its furniture.&lt;/strong&gt; Every item has its own spot marked out in the hall in advance, and its sprite stays hidden until it's purchased. When the player buys something, it appears in that spot with a small animation: it pops up a little and fades in.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Graphics and atmosphere.&lt;/strong&gt; The fire in the fireplace and the torch, as well as the blinking dragon, are frame-by-frame animations, with the warm glow painted right into the frames. The starry sky in the main menu is brought to life by one shader, with no extra images at all, and a particle system makes dust swirl in the beams of light coming through the tavern windows.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;UI and fonts.&lt;/strong&gt; The whole game uses one pixel font, and finding it turned out to be surprisingly hard, because many pixel fonts simply don't support Cyrillic. To keep long Russian and Spanish strings from overflowing the buttons, I had to add automatic text resizing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sound.&lt;/strong&gt; Music and sound effects are routed to two separate audio buses, so their volume can be adjusted independently in the settings.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Localization.&lt;/strong&gt; From the very start, I never wrote UI text directly. Instead, the game uses keys such as MENU_TITLE or MENU_NEW_GAME, which you can see in the screenshot above, and the translations for all three languages live in a single table.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Saves.&lt;/strong&gt; Progress is saved automatically to a JSON file every 10 seconds and after every purchase, so you can close the game at any moment without losing anything.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Builds.&lt;/strong&gt; The game ships in two versions, for the browser and for Windows, and each has its own quirks. For example, I had to hide the Quit button in the browser version, because the game can't close the browser tab by itself.&lt;/p&gt;

&lt;h2&gt;
  
  
  What My Own Game Taught Me
&lt;/h2&gt;

&lt;p&gt;Every day of development, I wrote down in a dev diary what I'd done, what had broken and what decisions I'd made. Reading it now, I can see things I had long known in theory but only truly understood in practice.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A game is everything at once.&lt;/strong&gt; Graphics, music, sound, gameplay, game feel, narrative and localization, followed by marketing, PR and talking to players. I don't have many players yet, and all my PR fit into a couple of posts on LinkedIn and Telegram, but even that turned out to be a job of its own. In a studio you see your own area, but when you work solo, every area is yours, and only then do you realize how many there really are.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Any change can blow up the project.&lt;/strong&gt; Now I find it even harder to understand commenters who write things like "Come on, it's a five-second balance tweak." My balance changed three times. At first, a playthrough was designed to last three hours, then I sent friends a one-hour demo, and based on their feedback the final game shrank to 30 minutes. Each of these changes sets off a whole chain reaction: shop prices, the pace of purchases and a complete retest of everything. Changes are normal, but every one of them carries risk, and now I've seen that for myself.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The hardest part is knowing when to stop.&lt;/strong&gt; New ideas come every day, and holding back is very hard, but if you keep adding features forever, there will be no release. That's exactly how you end up with the kind of projects people work on for five years and never ship, so it's important to be honest about what you can handle and to keep your goal in sight at all times.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;You need a tester, whatever anyone says.&lt;/strong&gt; This discovery surprised even me, and there's some irony in it, since I'm a tester by profession. But when you add a feature yourself, you really don't want to spend long testing it: you make sure it works and rush on to the next thing. That's why a separate person who will calmly and thoroughly check everything isn't a luxury but a necessity.&lt;/p&gt;

&lt;h2&gt;
  
  
  Release: Small, but with the Same Pains
&lt;/h2&gt;

&lt;p&gt;The most interesting part began on release day. The project was tiny, but everything went just like at a big studio: bugs popped up at the last minute, and compatibility problems appeared after launch. For example, gold kept ticking up right in the main menu, and one player with older hardware saw a mess of colored dots instead of the game, because their graphics card didn't get along with the renderer I'd chosen. The scale is different, but the feelings are the same, and that's exactly why a small solo project is such good preparation for working in a big team.&lt;/p&gt;

&lt;p&gt;The release itself is a separate job, too. The itch.io page needs a cover, screenshots, a description and a trailer. I put the trailer together with Claude: I recorded gameplay, described what I wanted to see, and it edited a 32-second video.&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/woRWkePzKOY" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;"Not perfect, but good enough" is actually a pretty good motto for this kind of development. And after release, like it or not, you start watching the stats and picking up new details. For example, a game from a new itch.io account doesn't show up in search right away, and in my case it took five days. Real experience is made of small things like that.&lt;/p&gt;

&lt;p&gt;And there was something I didn't expect at all: on release day, a strange silence set in. For a whole month my life had revolved around the game, and then you press "Publish" and an almost eerie calm settles over you. A strange feeling, but an interesting one in its own way.&lt;/p&gt;

&lt;h2&gt;
  
  
  Being Honest About AI
&lt;/h2&gt;

&lt;p&gt;The graphics and the basis for the music were generated by AI. I turned the generated MIDI files into finished WAV tracks, and I assembled and set up everything else in the engine myself.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm6g8iax549tzdr2l9g4m.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm6g8iax549tzdr2l9g4m.png" alt="AI-generated sprites" width="800" height="534"&gt;&lt;/a&gt;&lt;/p&gt;
In-game sprites generated by AI



&lt;p&gt;At the same time, I feel rather negative about AI-generated content, because, to me, it lacks soul. A contradiction? Partly. But for me this was a learning project: the goal was to go all the way from idea to release, not to learn pixel art. Maybe my opinion will change over time, but for now that's how I feel, and my conscience is somewhat eased by the fact that the assets turned out exactly the way I'd imagined them: simple and cozy.&lt;/p&gt;

&lt;p&gt;There's also an unexpected side effect: an experience like this makes you want to draw on your own. Once you understand what a pixel sprite is made of, you want to open Aseprite and make one yourself.&lt;/p&gt;

&lt;p&gt;AI has greatly lowered the barrier to entry into game development, and I'm living proof of that. There will be even more throwaway games; that's inevitable. But solo developers get more time for what matters most, the creative work itself, which means there's a better chance that something truly beautiful will emerge from that stream.&lt;/p&gt;

&lt;p&gt;Of course, on its own, a game like this is a small thing that brings nothing new to the industry. But my point is a different one: AI can and should support creativity, not replace it.&lt;/p&gt;

&lt;h2&gt;
  
  
  The Takeaway: Make Your Own Game
&lt;/h2&gt;

&lt;p&gt;I do have one concern. I'm encouraging everyone in game dev to make their own game, but I'd really hate for this to become yet another line in job requirements, which are long enough already. Your own game is a tool for growth, not a box to tick on your resume.&lt;/p&gt;

&lt;p&gt;For me, Taveronen didn't end with the release either. I'm now starting to write automated tests for it with GUT (Godot Unit Test) to level up my test automation skills. A game you know down to the last line of code is the easiest one to test, so I couldn't ask for a better training ground.&lt;/p&gt;

&lt;p&gt;Still, let me repeat the main point: making your own game may be the best thing you can do to grow and to see game development as it really is. Not through articles and YouTube videos, but when it's just you, the engine and your only teammate, an AI.&lt;/p&gt;

&lt;p&gt;It won't be easy. Things will break at the worst possible moment, and the project will eat up a huge amount of time, energy and nerves. But today anyone can give it a try, and perhaps, thanks to new tools, we'll see games that would otherwise have been left to rot on their authors' hard drives. After all, in creative work, what matters in the end is the idea and the taste, not the tools it was made with.&lt;/p&gt;

&lt;p&gt;If you'd like to see what I ended up with, you can play Taveronen for free right in your browser. It takes about half an hour.&lt;/p&gt;

&lt;h2&gt;
  
  
  Acknowledgments
&lt;/h2&gt;

&lt;p&gt;Thanks to Roman Urazovskiy, the very friend who released a game "at any cost" and helped me with advice on visual effects, and to Andrey Dolgarev, who supported me in word and deed throughout the whole project.&lt;/p&gt;

&lt;p&gt;And of course, thanks to my family: my wife Maria, who found the compatibility bug on older hardware, and my daughter Kira, my young tester, who gave me the sweetest feedback in the world.&lt;/p&gt;

&lt;h2&gt;
  
  
  What Came Next
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;a href="https://dev.to/voljulan/automated-tests-for-a-godot-game-where-to-start-and-how-to-write-your-first-test-3i71"&gt;Automated Tests for a Godot Game: Where to Start and How to Write Your First Test&lt;/a&gt; (&lt;a href="https://dtf.ru/id3551651/5390533-avtotesty-dlya-godot-kak-napisat-pervyy-test" rel="noopener noreferrer"&gt;in Russian on DTF&lt;/a&gt;) - how I started writing automated tests for Taveronen.&lt;/li&gt;
&lt;li&gt;
&lt;a href="https://dev.to/voljulan/cicd-for-a-solo-project-from-the-first-test-to-release-eo1"&gt;CI/CD for a Solo Project: From the First Test to Release&lt;/a&gt; (&lt;a href="https://dtf.ru/id3551651/5405325-ci-cd-dlya-odinochnogo-proekta" rel="noopener noreferrer"&gt;in Russian on DTF&lt;/a&gt;) - how those tests became part of a pipeline that builds the game, checks it in a real browser and on Windows, and then publishes a release with a single tag.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Links
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Game: &lt;a href="https://voljulan.itch.io/taveronen-the-beginning" rel="noopener noreferrer"&gt;Taveronen: The Beginning on itch.io&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Trailer: &lt;a href="https://youtu.be/woRWkePzKOY" rel="noopener noreferrer"&gt;YouTube&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;LinkedIn: &lt;a href="https://www.linkedin.com/in/denis-kurchikov/" rel="noopener noreferrer"&gt;denis-kurchikov&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;Telegram: &lt;a href="https://t.me/Voljulan" rel="noopener noreferrer"&gt;Voljulan&lt;/a&gt;
&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;em&gt;Originally published in Russian on &lt;a href="https://dtf.ru/id3551651/5355115-kak-ii-nastavnik-pomog-vypustit-pervuyu-igru-taveronen" rel="noopener noreferrer"&gt;DTF&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>gamedev</category>
      <category>godot</category>
      <category>testing</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
