Thursday, 23 April 2026

How Bad Animations Made Me Rethink Terminal UX — Moon Traveler v0.5.2

When I shipped v0.5.1 of Moon Traveler Terminal, I thought the narrative intro was enough to make the game feel alive. A flight recorder story. A drone scan report with live stats. Text appearing line by line.

It wasn't enough.

The game still felt like a wall of text. You'd type scan, get a dump of locations. Type travel, watch a progress bar crawl. Type talk, and a creature's response would just appear. No weight. No presence. The terminal was functional, but it was dead.

The Problem Wasn't Missing Animations — It Was Missing Breath

I didn't realize this until I watched someone else play. They were rushing through commands, barely reading the output. Scan, travel, talk, scan, travel. The game had no rhythm. Every action completed instantly and blurred into the next.

Terminal games have a unique problem. You don't have visual cues like camera movement or screen transitions to tell the player "something just happened." Without those cues, every command feels the same. A hazard event that should be tense gets the same visual treatment as checking your inventory.

The First Attempt Was Terrible

My first animation attempt was appending frames to the RichLog — the scrollable game output. Each frame was a new line. The scan animation looked like this scrolling down the screen:

((.))  ((.))
((.))  ((.))
((.))  ((.))
((.))  ((.))

Four identical lines cluttering the game log. You couldn't scroll back to read previous output without wading through animation debris. It was worse than no animation at all.

The fundamental issue: RichLog is append-only. You can't update a line in place. Every frame permanently adds to the scroll history.

The Fix: A Dedicated 2-Line Widget

The breakthrough was adding a Static widget between the game log and the status bar. Just two lines. That's it.

┌── RichLog (scrollable game output) ──┐
│                                       │
├── #animation-bar (2 lines, in-place) ─┤
├── Status Bar (vitals) ────────────────┤
│  Crash Site >  [input]                │
└───────────────────────────────────────┘

The #animation-bar uses height: auto; max-height: 2 in the Textual CSS. When empty, it collapses to zero height — invisible. When an animation plays, it expands to show the sprite. When done, it collapses again. The game log never gets polluted.

The CSS is dead simple:

#animation-bar {
    height: auto;
    max-height: 2;
}

Every animation function follows the same pattern:

  1. Check if animations are enabled (config + runtime flag)
  2. Check if the animation widget exists (TUI bridge present)
  3. Play frames in place, each replacing the previous
  4. Hold the last frame so the player can read it
  5. Clear the widget

If animations are disabled, fall back to a simple text message. No crash, no empty space.

Why Timing Is Everything

Here's the thing nobody tells you about terminal animations: the frames don't matter nearly as much as the pauses between them.

My first scan animation used a 0.15-second delay between frames. It was a blur. You couldn't even see the radar spinning. Bumped it to 0.35 seconds and suddenly it felt like the drone was actually scanning something. The player's eye had time to register each frame.

But timing isn't just about animation speed. It's about the gaps between actions.

I added a beat() function — a simple time.sleep(0.8) after every valid command. No visual output. Just a pause. That tiny breath between "you typed something" and "the game responds to the next thing" changed the entire feel. It gave each action weight.

def beat(duration: float = 0.8):
    """Pause to let the player absorb output."""
    if not _enabled():
        return
    time.sleep(duration)

Here's what the game flow looks like with proper timing:

Crash Site > scan
  [scan animation — 0.35s per frame]
  [results appear]
  [beat — 0.8s pause]
Crash Site > travel Frost Ridge
  [drone flies across the field]
  Arrived at Frost Ridge.
  [beat]
Frost Ridge > look
  [eyes scan left to right — 0.35s]
  [location description]
  [beat]

Without those beats, the output runs together. With them, each command feels like a distinct moment. The player naturally slows down and reads what's on screen.

The Drone That Grows With You

The most satisfying animation to build was the travel drone. It's a 2-line ASCII sprite that moves across the animation bar as you travel between locations.

The drone evolves with upgrades. Here's the full progression:

Base drone (no upgrades):
  [ ]--(+)--[ ]
  \___________/

After 1 upgrade (range module):
  [O]--(+)--[O]
  \[]_________/

After 2 upgrades:
  [O]--(+)--[O]
  \[]_______[]/

After 3 upgrades:
  [O]--(+)--[O]
  \[][]_____[]/

After 4 upgrades:
  [O]--(+)--[O]
  \[][]___[][]/

FULLY UPGRADED (5 modules):
  [O]--(+)--[O]
  \[][][]_[][]/

The eyes change from empty to O with any upgrade — the drone "wakes up." The belly fills with [] pairs as you install modules. Each pair fills from the edges inward, creating a visual symmetry.

The Debugging Adventures

This was tricky to get right. Two bugs that cost me hours:

Rich eating the eyes. The terminal rendering library, Rich, interprets [o] as a markup style tag. The drone's eyes would vanish. Every [ in the sprite needs escaping as \[. But ] does NOT need escaping — adding \] makes a literal backslash appear. I learned this the hard way.

The drunk drone. The top line was 13 characters. The bottom was 12. Off by one. As the drone moved across the screen, the bottom line would drift to the left, making it look like it was flying sideways. I expanded the belly from 9 to 11 characters to match — both lines exactly 13 visible characters.

# The alignment that took an entire session to debug:
[O]--(+)--[O]   ← 13 chars
\___________/   ← 13 chars (\ + 11 belly + /)

Late-Game Tension Through Animation

After 24 in-game hours, the animations shift. The scan radar picks up interference frames. Hazard flashes use triple exclamation marks. The travel animation occasionally glitches with interference patterns.

Normal scan:      ((*))  ((*))
Late-game scan:   ((!))  ((!))   ← interference

Normal hazard:    /!\
                  — HAZARD —

Late-game hazard: /!\
                  !!! HAZARD !!!

It's a small touch, but it reinforces the narrative: the environment is deteriorating. The longer you survive, the more hostile Enceladus becomes. The animations tell that story without a single word of dialogue.

The Animation System Architecture

The whole system is one file: animations.py, 250 lines. Here's the gate pattern every function uses:

def _can_animate() -> bool:
    """Check if the animation widget is available."""
    return _enabled() and hasattr(ui.console, "animate_frame")

def scan_sweep(hours_elapsed=0):
    if not _can_animate():
        ui.console.print("  Scanning surroundings...")
        time.sleep(0.5)
        return
    # ... play animated version ...

The _can_animate() gate checks two things: is the animation system enabled (config setting + runtime flag), and does the TUI bridge exist. This means animations work in the full TUI, gracefully degrade in headless mode, and can be toggled with --disable-animation.

Thread safety comes from Textual's call_from_thread. The game logic runs in a worker thread, but widget updates happen on the main thread. The bridge handles the crossing:

def animate_frame(self, text):
    bar = getattr(self._app, "_animation_bar", None)
    if bar:
        self._app.call_from_thread(bar.update, text)

What I Learned

  1. Two lines is enough. You don't need elaborate terminal graphics. A 2-line widget that appears and disappears adds more life than any amount of scrolling text art.
  2. Timing matters more than frames. A well-timed pause does more than a complex sprite. The beat() function is literally just time.sleep(0.8) and it transformed the game's pacing.
  3. In-place updates beat scrolling. Animations that append to the scroll history are worse than no animations. Dedicated widgets that update in place are the way.
  4. Always hold the last frame. 0.6 seconds before clearing. If the animation vanishes instantly, the player's brain never registers it happened.
  5. Fallback gracefully. Every animation checks if the widget exists before playing. If it doesn't, print a simple message. Never crash, never leave blank space.
  6. Let the drone grow. Cosmetic progression that reflects real gameplay changes — the belly filling with modules — gives the player visible proof their effort matters. Even in ASCII.

The game went from a wall of text to something that breathes. Every scan feels like a scan. Every trip feels like a journey. Every hazard feels dangerous. All it took was two lines of screen space and some carefully tuned time.sleep() calls.


Moon Traveler Terminal v0.5.2 is out now — ASCII animations, drone sprite evolution, in-place upgrades, and LLM performance diagnostics.

GitHub | Release Notes | All Releases