Tuesday, 21 April 2026

Automated Screenshot Testing for a Python Terminal Game

I've been building a terminal-based survival game in Python using the Textual framework. It runs as a TUI (Terminal User Interface) with Rich markup, threaded game logic, and SQLite for persistence. The game is called Moon Traveler Terminal.

The problem: I needed automated screenshots for documentation, GitHub Pages, and regression testing. Taking them manually every release was not sustainable. So I built a pilot script that plays the entire game, captures 27 screenshots at key moments, and validates the output.

Here's what I learned and the problems I had to solve.

The Architecture Challenge

The game has a split-thread design:

  • Worker thread - game logic, LLM inference, time.sleep() for animations
  • Main thread - Textual UI rendering, input handling

Every print() call in the game routes through a thread-safe bridge to Textual's RichLog widget:

class UIBridge:
    def print(self, *args, **kwargs):
        # Route to Textual's RichLog via call_from_thread
        self._app.call_from_thread(self._log.write, args[0])

Each print is immediately visible in the UI. No buffering. This is great for the player but tricky for automated testing - you never know exactly when output finishes rendering.

Step 1: Textual's Auto-Pilot

Textual has a built-in test harness. You pass an auto_pilot coroutine to app.run() and it drives the UI programmatically. No real terminal needed.

from src.tui_app import MoonTravelerApp

async def screenshot_pilot(pilot):
    app = pilot.app

    async def take(name, desc):
        await pilot.pause(0.5)
        app.refresh()
        await pilot.pause(0.5)
        app.save_screenshot(f"assets/{name}.svg")

    async def send(text, wait=4.0):
        app.command_queue.put(text)
        await pilot.pause(wait)

    # Take title screenshot
    await pilot.pause(3.0)
    await take("tui-title", "Title screen")

    # Play the game
    await send("look", wait=3.0)
    await take("tui-look", "Look at crash site")

    await send("scan", wait=3.0)
    await take("tui-scan", "Scan results")

app = MoonTravelerApp()
app.run(auto_pilot=screenshot_pilot)

Three ways to talk to the game:

MethodWhat it does
command_queue.put(text)Injects a command (like typing + Enter)
bridge.push_response(text)Answers interactive prompts (y/n, menus)
wait_for_ask_mode()Polls until the game blocks on a prompt

Step 2: Handling Branching Game Flows

The game has branching prompts - new game vs load, difficulty selection, player name. You cannot just hardcode sleep timers. You have to wait for the game to actually ask a question.

async def wait_for_ask_mode(timeout=10.0):
    """Wait until the game blocks on a prompt."""
    elapsed = 0.0
    while elapsed < timeout:
        if app._ask_mode:
            return True
        await pilot.pause(0.3)
        elapsed += 0.3
    return False

# Navigate: New Game -> Easy -> Player name
if await wait_for_ask_mode(timeout=5.0):
    await respond("1", wait=2.0)      # "New Game"
if await wait_for_ask_mode(timeout=5.0):
    await respond("1", wait=2.0)      # "Easy" difficulty
if await wait_for_ask_mode(timeout=5.0):
    await respond("Screenshot", wait=3.0)  # Player name

This pattern made the script reliable across different game states - fresh install with no saves, existing saves, different model loading times.

Problem 1: Screenshots Only Capture the Viewport

This was the first real surprise. Textual's save_screenshot() exports what's currently visible in the viewport - about 24 lines. Content that scrolled off the top is gone from the SVG.

I built a narrative intro that's 18 lines long. By the time the boot sequence finishes, the heading at the top has scrolled away. My first validation checked for "FLIGHT RECORDER" in the screenshot - it failed because the heading was above the viewport.

The fix: Always validate against text near the bottom of each screen, not the top.

import re

def _svg_text(path):
    """Extract visible text from an SVG screenshot."""
    with open(path) as f:
        return " ".join(
            t.replace("&#160;", " ").strip()
            for t in re.findall(r">([^<]+)<", f.read())
            if t.strip() and len(t.strip()) > 2
        )

# Validate bottom-of-screen content, not headers
validations = [
    ("tui-intro", "rescue", "Intro narrative visible"),
    ("tui-help", "drone", "Help shows commands"),
    ("tui-victory", "Grade", "Victory shows score"),
    ("tui-scores", "Ripley", "Leaderboard has entries"),
]

for name, expected, desc in validations:
    text = _svg_text(f"assets/{name}.svg")
    status = "PASS" if expected.lower() in text.lower() else "FAIL"
    print(f"  {status}: {desc}")

Problem 2: SQLite Data Pollution

The screenshot script seeds fake leaderboard entries so the scores screenshot is not empty:

from src.save_load import record_score

record_score(820, "A", True, "short", 18, 1200, 3, 12345,
             player_name="Ripley")
record_score(650, "B", True, "medium", 35, 2400, 2, 67890,
             player_name="Dallas")
record_score(410, "C", False, "long", 12, 900, 1, 11111,
             player_name="Lambert")

The problem: these entries persisted across runs. After running the script 5 times, I had 15 fake entries in my real database mixed with actual play data.

The fix: Clean up test data on exit, keyed by player name:

import sqlite3

# Always clean up, even if the script crashes elsewhere
with sqlite3.connect(str(db_path)) as conn:
    conn.execute(
        "DELETE FROM leaderboard WHERE player_name "
        "IN ('Ripley', 'Dallas', 'Lambert', 'Screenshot')"
    )

I also added state logging at every screenshot checkpoint - player location, inventory, repair progress, and full DB row counts. When a screenshot looks wrong, the debug log tells you exactly what the game state was at capture time:

def log_game_state(ctx, label=""):
    p = ctx.player
    log(f"[{label}] Loc={p.location_name}")
    log(f"[{label}] Food={p.food:.0f}% Water={p.water:.0f}%")
    log(f"[{label}] Inventory={dict(p.inventory)}")

def log_db_state(label=""):
    with sqlite3.connect(str(db_path)) as conn:
        for table in ["saves", "chat_history", "leaderboard"]:
            n = conn.execute(f"SELECT COUNT(*) FROM [{table}]").fetchone()[0]
            log(f"[DB {label}] {table}: {n} rows")

Problem 3: Animations Break Script Timing

I added ASCII frame animations to the game - scan sweeps, travel progress bars, hazard flashes. Each one adds 0.3 to 1.0 seconds of time.sleep() in the worker thread. The screenshot script's fixed await pilot.pause(3.0) durations were suddenly too tight.

The wrong fix would be to disable animations in the config file - that persists and would turn off animations for the user's next real play session.

The right fix: A runtime kill switch that only lasts for the current process:

# src/animations.py
_force_disabled = False

def force_disable():
    """Session-only. Does NOT persist to config."""
    global _force_disabled
    _force_disabled = True

def _enabled():
    if _force_disabled:
        return False
    from src.config import get_animations_enabled
    return get_animations_enabled()

The game's --super mode (used by test scripts) calls force_disable() at startup. Real players still get animations. Test scripts get deterministic timing.

Problem 4: Capturing the Game Context

The screenshot script needs access to the live game state (player location, creatures, inventory) to make smart decisions - like finding a creature to talk to. But the game context only exists inside the worker thread.

Solution: monkey-patch the game loop to capture the context object:

import threading
from src import game

_game_ctx = None
_ctx_ready = threading.Event()
_original_game_loop = game.game_loop

def _patched_game_loop(ctx):
    global _game_ctx
    _game_ctx = ctx
    _ctx_ready.set()        # Signal that context is ready
    return _original_game_loop(ctx)

game.game_loop = _patched_game_loop

# Later in the pilot:
_ctx_ready.wait(timeout=30)
ctx = _game_ctx

# Now we can query live game state
creature_loc = None
for c in ctx.creatures:
    if c.location_name in ctx.player.known_locations:
        creature_loc = c.location_name
        break

The End Result

The full script plays an entire game: new game, explore, scan, travel to creatures, have LLM-powered conversations, escort allies back to the ship, repair and win. 27 screenshots, 10 validated, all in about 3 minutes.

$ uv run python scripts/tui_screenshots.py

Taking TUI screenshots...
  Saved: assets/tui-title.svg — Title screen
  Saved: assets/tui-intro.svg — Flight recorder narrative
  Saved: assets/tui-crash-site.svg — Crash site after boot
  Saved: assets/tui-look.svg — Look at crash site
  ...
  Saved: assets/tui-victory.svg — Victory screen
Validation: 10 passed, 0 failed
  Cleaned up seeded leaderboard entries
Done! Screenshots saved to assets/

Lessons Learned

  1. Textual's auto-pilot is powerful but you need polling patterns like wait_for_ask_mode() for branching flows. Fixed sleeps alone will not work.
  2. Viewport screenshots miss scrollback. Validate against content near the bottom of the screen, never the top.
  3. Clean up test data. If your script seeds a database, delete those rows on exit. Key by a known player name so you can always find them.
  4. Animations need a runtime kill switch for automated scripts. Never persist test-only config changes.
  5. Log game state at capture time. When a screenshot fails validation, you need the context - not just a failed assertion.
  6. Monkey-patching the game loop to capture the context object is ugly but effective. It lets the pilot script make decisions based on live game state.

The game and all the testing scripts are open source:

https://github.com/elephantatech/moon_traveler

https://elephantatech.github.io/moon_traveler/

Wednesday, 29 June 2016

5 Reasons for not Customizing Appliance Devices

5 Reasons for not Customizing Appliance Devices

As an IT Support Specialist I have seen many things that other support professionals do things on their environments that is not advised or suggested from support perspective. Appliance servers are servers however with a key different they are supposed to run only one software. Example would be like the google appliance or networking devices like the router switches from Cisco. This devices run on an OS that is customized for this purpose only so they do not run standard settings. Some System Administrator might want to run other software to save cost or play around or simply need to for what ever legitimate or illegitimate reason.

Here are 5 reason for not customizing appliance servers

Reason 1 : Break something that is critical for appliance Software

Appliance software is created for a purpose to be hosted on that appliance. So the software and hardware is designed for that in mind. So before trying to installing something or configuring something always check with vendors Support. Do not go to consultants only. There are some fantastic consultants out there but you want to make sure you know your options. Examples are like upgrading java to the latest version on an appliance that requires to have particular version or the appliance software will not work.

Reason 2 : unable to fix problems/upgrade the appliance software due to custom configuration

Sometimes the upgrade path breaks on the appliance because someone decided that they needed to customize the configuration to allow other things to run. Well you might just block the upgrade path to the latest version. Sometimes it is the vendors fault here but most of the time when you customize the software too much you will not able to upgrade you will have to migrate instead so you will have to spend more money to get a new appliance with newer software. So you will not get the same support from vendors for technical issues.

Reason 3 : Utilizing resources that are needed for appliance software

As previously stated the hardware and software on an appliance are created for a specific task and configuration. If you add more services to run outside of the box you run into the problem like lack of hard-drive or over utilizing RAM or over utilizing the network card. You are dealing with finite resources and the device is tested with thousands and sometimes even millions of dollars to do just what it was designed to do. Hacking it out to run multiple services will slow everything down including what the appliance was designed for. Performance is one reason you want a separate appliance if you start hacking to add more services that that was not intended for the device you just threw out an advantage that you had with the appliance.

Reason 4 : Warranty issues with vendors

Most vendors will only give best effort in some cases will void the warranty outright so when you need help when you call the vendor, they will not give the full support that you need. You have hacked and customized things that they are not trained or even experienced in so now good luck trying to get that quick fix you were hoping for since the Support tech first needs to learn what you did then learn how he can do it then see if that is supported. and If it is out of the warranty or paid support agreement well you are out of luck and you might even have to pay more for assistance now.

Reason 5 : Security loophole

What happens when you install software it can open ports that now increase the risk for a security breach. If you know someone in security they will explain this with more services running means more chances of security breach as you increase risk. It is simple if the device what security tested, only allows some services to use those don't add more. for example don't install ftp server on a network device because ftp is weak. You need SFTP or SCP instead however if the device does not support it, don't add any of that since you will be opening ports that are a risk to the network now, and not just the device.



Don't get me wrong sometimes the default configuration can have a security risk or have something that does not work for your enterprise network so It is a good idea to customize and hack out the appliance. Just know what you are getting into talk to your consultants and your vendor Support. You want to talk to Support specifically because they know what can go wrong with a custom configuration or can get the information what the risks are by adding a customization.

Friday, 17 June 2016

Search Large files on Linux



Ever wonder how to find large files in Linux but you have well there is the find command you can use here some examples I found really helpful.

To find files larger than 100MB:

find . -type f -size +100M

If you want the current dir only:

find . -maxdepth 1 -type f -size +100M

If you wish to see all files over 100M and to see where they are and what is their size try this:

find . -type f -size +100M -exec ls -lh {} \;

If you wish to check all the files in the system then run the command from the system root (/) directory with sudo.

cd \
sudo find . -type f -size +100M -exec ls -lh {} \;

Monday, 26 January 2015

Becel Heart&Stroke Ride for Heart 2015

Every 7 minutes someone dies from heart disease and stroke in Canada. That's why I am fundraising for the Becel Heart&Stroke Ride for Heart on Sunday, May 31, 2015 to support heart and stroke foundation. However I am also doing this for 2 other reasons. I want to ride my bicycle on the Don Valley Parkway and I want to get into shape.

First I would like to talk about the Heart and Stroke foundation which has contributed a lot for Canadians to support treatment and assist in prevention of heart attacks and strokes. If you check the impact page you will read that they prevent deaths of more than 69,000 Canadians a year. If you think that the number is quite a big, It would be bigger if not for the prevention program that they run. Another thing I found out was that they contributed to the first ever heart transplant in Canada and were instrumental in many prevention programs to reduce heart related diseases by 75% in Canada. They even tell you where the money goes that you donate on the your donation at work page that outlines the details of the programs that they have succeeded. They have some interesting free eTools available for assistance with prevention of heart related diseases at their etools page. Check it out.

The Ride for heart is great event as there are professionals and amateur cyclists who participate in the race. they have 3 races 25km, 50km and 75km. I will doing the 50km and I am excited to bike on Don Valley Parkway. Those who are not familiar with Don Valley Parkway. It is a main highway in Toronto that connects to the Toronto downtown to rest of GTA and as per law you cannot cycle or walk on it. This is the only time you can cycle on it and I am all for it. I do need to train as 50KM bike ride is tough for me at the moment.

When I was a kid I used to cycle almost everyday in Nairobi, Kenya. It would be my sister, my neighborhood kids and myself we would cycle through valleys, hills, tarmac roads, rough roads and pathways. While doing that without realizing I ended training my body with a great amount stamina and very strong lower body strength. I want to get that back.

Today I work more the 8 hours a day in front of a computer talking to customers over a phone doing IT Technical Support. Without doing the same workout I grew unhealthy and out of shape. I want to get into shape so the easiest way is to take on a regular physical activity that will allow some cardio and some muscular workout. So I started walking every lunch hour and also changed my diet by reducing fatty, salty and carb filled food items and adding more healthy balanced meals. I am a vegetarian but that mean nothing in terms of health if all you eat is chips and drink pop. Now I want to go to next level health with a regular cycling workout so I will train for this ride but also continue it after the event.

So in all this I need your encouragement and assistance in support Heart and Stroke foundation please donate at my Personal Donation page at http://support.heartandstroke.ca/goto/vivekmistry

If the donations match or exceed $1000/- I will give a one of a kind handmade art piece to the top contributor and if there is more than one top contributor then I will run a lottery give the winner the art piece. I will announce the winner on the 1st June 2015 after the event as that is the day when they close the donations as well for the event. This is open to everyone even if you not in Canada I will ship this to you wherever you live. 

You will have to wait for further Updates as I am making this art piece and I will reveal it in the next few weeks. Details to follow.

Wednesday, 14 January 2015

7 Smartphone Photography Tips & Tricks

This is a really cool quick hacks/diy tips and tricks that can enhance your photos with your smartphone. Even if you have an old smartphone. check it out.



Monday, 12 January 2015

Je suis Charlie

Je suis Charlie
I am Charlie

Je suis Vivek
I am Vivek

Je suis Humain premier
I am Human first

#JeSuisCharlie

Monday, 5 January 2015

150th anniversary of Confederation of Canada

In July 1st, 1867 Canada was born with confederation with the 4 provinces Ontario, Quebec, Nova Scotia and New Brunswick all of which were British Colonies. Now in 2017 is approaching and Bank of Canada in celebration of this will be releasing a new note to commemorate that 150 years. However they are not going to just release them they want public feedback. so if you are a Canadian you should put your comments it is a quick survey on what symbols and themes should be on the new note. So below are links for the press release and the survey Check it out and go for it tell that what matters to you as a Canadian.

Check out the links
Link to press release: http://www.bankofcanada.ca/banknotes/new-bank-note-canadas-150th/

Link to survey: http://www.ipsosresearch.com/c150/

Thursday, 18 December 2014

first post on linkedin pulse

I just posted my first ever post on Linkedin pulse. 5 reasons not to customize appliance devices.

here is the link check it out let me know what you think about it.

https://www.linkedin.com/pulse/5-reasons-customizing-appliance-vivek-mistry

Sunday, 30 November 2014

Cool custom pc builders you might not have heard

Came across a nice list of custom pc builders that I want to try check it.

Five Best Custom PC Builders - http://pulse.me/s/3a2DOt

Friday, 17 October 2014

Sting at Ted

I started watching one Ted.com video daily. Today I saw a video of Sting. He was rocking with the music. As an artist he talked how he got a writers block and could not come up with any new songs. Later on when working in shipping yard he started writing about other people and while doing that he found more about him self. Check out the video below or just go the ted talk.

http://www.ted.com/talks/sting_how_i_started_writing_songs_again