openmods
mods / shouryamaanjain / space-invaders

shouryamaanjain/space-invaders

Adds /invaders: Space Invaders while the agent keeps working, in the right half of OpenCode's session screen or in Codex's bottom pane.

$openmods install shouryamaanjain/space-invaders

Supports Codex CLI and OpenCode. Without a harness flag, openmods asks which one.

Adds a /invaders slash command (also /space-invaders) that opens Space Invaders while the agent keeps working.

  • OpenCode: the game takes the right half of the session screen, in place of the sidebar, panel or terminal on the right. The conversation keeps the left half, and its messages rewrap to fit.
  • Codex CLI: the game opens in the bottom pane, in place of the input box. The conversation keeps going above it, and the status line that shows what Codex is doing stays just above the game. Codex prints the conversation into your terminal's scrollback, so the game cannot sit beside it.

How to play

key while the game is open
← → move
space fire (hold it to keep firing), or play again after game over
p pause and resume
esc close the game (ctrl-c too, in Codex); it pauses, and /invaders brings it back where it left off

Five rows of invaders march across and down, faster as they thin out, and drop bombs. You can have three shots in the air at once. A mystery ship crosses the top now and then for bonus points. You start with three lives and earn another every 1500 points, up to five; clear a wave and the next starts lower, as far as the field has room above your ship. The field fits the space it gets, and starts the current wave again if you resize the terminal.

While the game is open it has the keyboard, so playing never types into your prompt. In OpenCode the prompt stays in view; in Codex the input box comes back when you close the game.

Permissions

  • Network: none
  • Files: none
  • Commands: none
  • Agent instructions: unchanged

What it changes

OpenCode:

file change
packages/tui/src/component/invaders.tsx New. The game and its panel.
packages/tui/src/app.tsx Registers the /invaders command.
packages/tui/src/component/session-frame.tsx Gives the right pane, half the width, to the game while it is open, and the keyboard with it.

Codex CLI:

file change
codex-rs/tui/src/bottom_pane/invaders_game.rs New. The game.
codex-rs/tui/src/bottom_pane/invaders_view.rs New. Draws the game in the bottom pane and takes its keys.
codex-rs/tui/src/bottom_pane/invaders_tests.rs and two snapshots/…invaders… files New. Tests.
codex-rs/tui/src/bottom_pane/bottom_pane_view.rs, bottom_pane/mod.rs Lets a view keep the status line above it.
codex-rs/tui/src/slash_command.rs, chatwidget/slash_dispatch.rs, bottom_pane/chat_composer.rs Registers /invaders, available while Codex works.
codex-rs/tui/src/bottom_pane/snapshots/…command_popup_default_items.snap The command list now includes /invaders.

Install

openmods install shouryamaanjain/space-invaders --opencode
openmods install shouryamaanjain/space-invaders --codex

Codex CLI 0.160.1 · behind

$openmods install shouryamaanjain/space-invaders --codex
0.160.1Codex CLI release
11files touched
+987 −1lines
1patch
1update

Has a version for Codex CLI 0.160.1 (update 1), 0.160.0 (update 1), 0.159.3 (update 1), 0.159.2 (update 1), 0.159.1 (update 1), 0.159.0 (update 1), 0.158.0 (update 1), 0.157.1 (update 1), 0.157.0 (update 1), 0.156.1 (update 1). openmods builds the release you are on when all your mods have a version for it, else the newest they share; the files and diff below are for 0.160.1.

On Codex CLI this mod is for 0.160.1. The latest release is 0.164.0. Installing it builds 0.160.1, which still works. Open issues · How to rebase it
codex-rs/tui/src/bottom_pane/bottom_pane_view.rs+6 −0
codex-rs/tui/src/bottom_pane/chat_composer.rs+1 −0
codex-rs/tui/src/bottom_pane/invaders_game.rs new+460 −0
codex-rs/tui/src/bottom_pane/invaders_tests.rs new+183 −0
codex-rs/tui/src/bottom_pane/invaders_view.rs new+266 −0
codex-rs/tui/src/bottom_pane/mod.rs+16 −0
codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__command_popup__tests__command_popup_default_items.snap+1 −0
codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__invaders_view__tests__a_narrow_pane_keeps_the_header_and_help_short.snap new+22 −0
codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__invaders_view__tests__opens_with_the_formation_the_ship_and_the_keys.snap new+22 −0
codex-rs/tui/src/chatwidget/slash_dispatch.rs+6 −1
codex-rs/tui/src/slash_command.rs+4 −0
Full diff for Codex CLI
diff --git a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs
index 84e373830e961387f2a2b12d2954baadb4953470..9b44dc9dfdb965e6b9223fb829fcfa3d001eef65 100644
--- a/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs
+++ b/codex-rs/tui/src/bottom_pane/bottom_pane_view.rs
@@ -71,6 +71,12 @@ pub(crate) trait BottomPaneView: Renderable {
         None
     }
 
+    /// Return true when the status line should stay visible above this view,
+    /// for a view that sits beside the agent's work rather than asking for input.
+    fn keeps_status_visible(&self) -> bool {
+        false
+    }
+
     /// Handle Ctrl-C while this view is active.
     fn on_ctrl_c(&mut self) -> CancellationEvent {
         CancellationEvent::NotHandled
diff --git a/codex-rs/tui/src/bottom_pane/chat_composer.rs b/codex-rs/tui/src/bottom_pane/chat_composer.rs
index 72722559fcac5be336b474ae71a48bfa7df34964..109a617e382530e8206f900b97f6bcc5c3bf86a5 100644
--- a/codex-rs/tui/src/bottom_pane/chat_composer.rs
+++ b/codex-rs/tui/src/bottom_pane/chat_composer.rs
@@ -525,6 +525,7 @@ fn parent_owned_command_is_allowed(command: SlashCommand, args: &str) -> bool {
                 | SlashCommand::Statusline
                 | SlashCommand::Theme
                 | SlashCommand::Pets
+                | SlashCommand::Invaders
                 | SlashCommand::Ps
                 | SlashCommand::Stop
                 | SlashCommand::MemoryDrop
diff --git a/codex-rs/tui/src/bottom_pane/invaders_game.rs b/codex-rs/tui/src/bottom_pane/invaders_game.rs
new file mode 100644
index 0000000000000000000000000000000000000000..578fe4fff90ecbc0a52bdc997364c9862367cafe
--- /dev/null
+++ b/codex-rs/tui/src/bottom_pane/invaders_game.rs
@@ -0,0 +1,460 @@
+//! Space Invaders, the game itself: a field of `w` by `h` cells that steps
+//! once per tick. `InvadersView` draws it in the bottom pane and feeds it keys.
+//!
+//! A formation of aliens marches sideways and drops a row at each edge,
+//! faster as it thins out, while the lowest alien of a column drops bombs.
+//! Up to three shots can be in the air; a mystery ship crosses the top now and
+//! then; every 1500 points is an extra life. Clearing a wave starts the next,
+//! a little lower and faster.
+
+use std::time::SystemTime;
+use std::time::UNIX_EPOCH;
+
+pub(crate) const SPRITES: [[&str; 2]; 3] = [["/o\\", "\\o/"], ["{@}", "}@{"], ["<=>", ">=<"]];
+pub(crate) const SHIP: &str = "▟█▙";
+pub(crate) const SHIP_HIT: &str = "*▒*";
+pub(crate) const UFO: &str = "<o=o>";
+const POINTS: [u32; 3] = [30, 20, 10];
+const UFO_POINTS: [u32; 4] = [50, 100, 150, 300];
+const ALIEN_W: i32 = 3;
+const ALIEN_STEP: i32 = 5; // an alien and the gap after it
+const ROWS: i32 = 5;
+const MAX_SHOTS: usize = 3; // in the air at once
+const SHOT_SPEED: i32 = 2; // rows per tick
+const COOLDOWN: u32 = 3; // ticks between shots
+const EXTRA_LIFE: u32 = 1500;
+const MAX_LIVES: u32 = 5;
+/// Rows from a new wave's lowest row down to the ship's, however low waves start.
+const START_ROOM: i32 = 4;
+pub(crate) const MIN_W: i32 = 24;
+pub(crate) const MIN_H: i32 = 10;
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub(crate) struct Point {
+    pub(crate) x: i32,
+    pub(crate) y: i32,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub(crate) struct Alien {
+    pub(crate) x: i32,
+    pub(crate) y: i32,
+    pub(crate) kind: usize,
+    pub(crate) alive: bool,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub(crate) struct Boom {
+    pub(crate) x: i32,
+    pub(crate) y: i32,
+    pub(crate) text: String,
+    ttl: u32,
+}
+
+#[derive(Clone, Debug, PartialEq, Eq)]
+pub(crate) struct Banner {
+    pub(crate) text: String,
+    ttl: u32,
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub(crate) struct Ufo {
+    pub(crate) x: i32,
+    dir: i32,
+}
+
+/// xorshift, seeded from the clock: enough randomness for a game.
+#[derive(Clone, Debug)]
+pub(crate) struct Rng(u64);
+
+impl Rng {
+    pub(crate) fn from_clock() -> Self {
+        let seed = SystemTime::now()
+            .duration_since(UNIX_EPOCH)
+            .map_or(0x9E37_79B9_7F4A_7C15, |d| d.as_nanos() as u64);
+        Self::new(seed)
+    }
+
+    pub(crate) fn new(seed: u64) -> Self {
+        Self(seed | 1)
+    }
+
+    fn next(&mut self) -> u64 {
+        self.0 ^= self.0 << 13;
+        self.0 ^= self.0 >> 7;
+        self.0 ^= self.0 << 17;
+        self.0
+    }
+
+    /// True with probability `p`.
+    fn chance(&mut self, p: f64) -> bool {
+        (self.next() % 1_000_000) as f64 / 1_000_000.0 < p
+    }
+
+    fn below(&mut self, n: usize) -> usize {
+        (self.next() % n as u64) as usize
+    }
+}
+
+#[derive(Clone, Debug)]
+pub(crate) struct Game {
+    pub(crate) w: i32,
+    pub(crate) h: i32,
+    pub(crate) ship: i32,
+    pub(crate) shots: Vec<Point>,
+    pub(crate) bombs: Vec<Point>,
+    pub(crate) aliens: Vec<Alien>,
+    pub(crate) ufo: Option<Ufo>,
+    pub(crate) booms: Vec<Boom>,
+    pub(crate) banner: Option<Banner>,
+    pub(crate) frame: usize,
+    pub(crate) score: u32,
+    pub(crate) best: u32,
+    pub(crate) lives: u32,
+    pub(crate) wave: u32,
+    /// Ticks left of the ship's explosion; nothing moves meanwhile.
+    pub(crate) hit: u32,
+    pub(crate) over: bool,
+    pub(crate) paused: bool,
+    total: usize,
+    dir: i32,
+    wait: i32,
+    cool: u32,
+    next_life: u32,
+    ticks: u64,
+    rng: Rng,
+}
+
+impl Game {
+    pub(crate) fn new(w: i32, h: i32, rng: Rng) -> Self {
+        let mut game = Self {
+            w: 0,
+            h: 0,
+            ship: 0,
+            shots: Vec::new(),
+            bombs: Vec::new(),
+            aliens: Vec::new(),
+            ufo: None,
+            booms: Vec::new(),
+            banner: None,
+            frame: 0,
+            score: 0,
+            best: 0,
+            lives: 3,
+            wave: 1,
+            hit: 0,
+            over: false,
+            paused: false,
+            total: 0,
+            dir: 1,
+            wait: 0,
+            cool: 0,
+            next_life: EXTRA_LIFE,
+            ticks: 0,
+            rng,
+        };
+        game.start_wave(w, h, 1);
+        game
+    }
+
+    /// Sets up a wave at the given size, keeping the score, lives and best.
+    fn start_wave(&mut self, w: i32, h: i32, wave: u32) {
+        let (w, h) = (w.max(MIN_W), h.max(MIN_H));
+        let cols = ((w - 4) / ALIEN_STEP).clamp(4, 11);
+        let row_gap = if h >= 24 { 2 } else { 1 };
+        let left = (w - (cols * ALIEN_STEP - 2)) / 2;
+        // Later waves start lower, but always with room above the ship.
+        let lowest_top = h - 1 - START_ROOM - (ROWS - 1) * row_gap;
+        let top = (2 + (wave as i32 - 1).min(4)).min(lowest_top).max(1);
+        self.aliens = (0..ROWS)
+            .flat_map(|r| {
+                (0..cols).map(move |c| Alien {
+                    x: left + c * ALIEN_STEP,
+                    y: top + r * row_gap,
+                    kind: match r {
+                        0 => 0,
+                        1 | 2 => 1,
+                        _ => 2,
+                    },
+                    alive: true,
+                })
+            })
+            .collect();
+        self.total = self.aliens.len();
+        self.w = w;
+        self.h = h;
+        self.ship = w / 2 - 1;
+        self.shots.clear();
+        self.bombs.clear();
+        self.ufo = None;
+        self.booms.clear();
+        self.banner = (wave > 1).then(|| Banner {
+            text: format!("WAVE {wave}"),
+            ttl: 30,
+        });
+        self.frame = 0;
+        self.wave = wave;
+        self.hit = 0;
+        self.dir = 1;
+        self.wait = 0;
+        self.cool = 0;
+        self.ticks = 0;
+    }
+
+    /// Fits the field to a new size. A game in progress restarts its current
+    /// wave at that size, keeping the score, lives and a game over; a game
+    /// with nothing to lose starts fresh.
+    pub(crate) fn resize(&mut self, w: i32, h: i32) {
+        let (w, h) = (w.max(MIN_W), h.max(MIN_H));
+        if (w, h) == (self.w, self.h) {
+            return;
+        }
+        let over = self.over;
+        let played = self.ticks > 0 || self.score > 0 || self.wave > 1 || over;
+        self.start_wave(w, h, if played { self.wave } else { 1 });
+        self.over = over;
+    }
+
+    pub(crate) fn restart(&mut self) {
+        let best = self.best.max(self.score);
+        *self = Self::new(self.w, self.h, self.rng.clone());
+        self.best = best;
+    }
+
+    pub(crate) fn toggle_pause(&mut self) {
+        if !self.over {
+            self.paused = !self.paused;
+        }
+    }
+
+    /// Moves the ship a step left (`-1`) or right (`1`).
+    pub(crate) fn steer(&mut self, dx: i32) {
+        if self.paused || self.over || self.hit > 0 {
+            return;
+        }
+        let step = (self.w / 30).max(2);
+        self.ship = (self.ship + dx * step).clamp(0, self.w - SHIP.chars().count() as i32);
+    }
+
+    /// Space: fires, or resumes a paused game, or starts a finished one again.
+    pub(crate) fn fire(&mut self) {
+        if self.over {
+            return self.restart();
+        }
+        if self.paused {
+            self.paused = false;
+            return;
+        }
+        if self.hit > 0 || self.cool > 0 || self.shots.len() >= MAX_SHOTS {
+            return;
+        }
+        self.shots.push(Point {
+            x: self.ship + 1,
+            y: self.h - 1,
+        });
+        self.cool = COOLDOWN;
+    }
+
+    pub(crate) fn running(&self) -> bool {
+        !self.paused && !self.over
+    }
+
+    pub(crate) fn tick(&mut self) {
+        if !self.running() {
+            return;
+        }
+        self.ticks += 1;
+        self.cool = self.cool.saturating_sub(1);
+        self.booms.retain_mut(|b| {
+            b.ttl -= 1;
+            b.ttl > 0
+        });
+        if let Some(banner) = &mut self.banner {
+            banner.ttl -= 1;
+            if banner.ttl == 0 {
+                self.banner = None;
+            }
+        }
+        if self.hit > 0 {
+            self.hit -= 1;
+            return;
+        }
+
+        // Shots climb fast, checked a row at a time so nothing is skipped.
+        let mut shots = std::mem::take(&mut self.shots);
+        shots.retain_mut(|s| (0..SHOT_SPEED).all(|_| !self.advance(s)));
+        self.shots = shots;
+
+        if self.aliens.iter().all(|a| !a.alive) {
+            self.best = self.best.max(self.score);
+            self.start_wave(self.w, self.h, self.wave + 1);
+            return;
+        }
+        self.march();
+        self.drop_bombs();
+        self.fly_ufo();
+        if self.over {
+            self.best = self.best.max(self.score);
+        }
+    }
+
+    /// Moves one shot a row; true when it hit something and is spent.
+    fn advance(&mut self, s: &mut Point) -> bool {
+        s.y -= 1;
+        if s.y < 0 {
+            return true;
+        }
+        if let Some(alien) = self
+            .aliens
+            .iter_mut()
+            .find(|a| a.alive && a.y == s.y && s.x >= a.x && s.x < a.x + ALIEN_W)
+        {
+            alien.alive = false;
+            let (x, y, points) = (alien.x, alien.y, POINTS[alien.kind]);
+            self.award(points);
+            self.boom(x, y, "*#*".to_string());
+            return true;
+        }
+        if let Some(ufo) = self.ufo
+            && s.y == 0
+            && s.x >= ufo.x
+            && s.x < ufo.x + UFO.len() as i32
+        {
+            let bonus = UFO_POINTS[self.rng.below(UFO_POINTS.len())];
+            self.award(bonus);
+            self.boom(ufo.x, 0, bonus.to_string());
+            self.ufo = None;
+            return true;
+        }
+        if let Some(i) = self
+            .bombs
+            .iter()
+            .position(|b| b.x == s.x && (b.y == s.y || b.y == s.y + 1))
+        {
+            self.bombs.remove(i);
+            return true;
+        }
+        false
+    }
+
+    /// The formation steps sideways, faster as it thins out, and drops a row
+    /// at each edge.
+    fn march(&mut self) {
+        self.wait -= 1;
+        if self.wait > 0 {
+            return;
+        }
+        let alive = self.aliens.iter().filter(|a| a.alive).count() as f64;
+        let pace = (2.0 + 14.0 * alive / self.total as f64).round() as i32;
+        self.wait = (pace - (self.wave as i32 - 1).min(3)).max(1);
+        self.frame = 1 - self.frame;
+        let (min_x, max_x) = self
+            .aliens
+            .iter()
+            .filter(|a| a.alive)
+            .fold((i32::MAX, i32::MIN), |(lo, hi), a| {
+                (lo.min(a.x), hi.max(a.x + ALIEN_W))
+            });
+        let at_edge = (self.dir == 1 && max_x + 1 > self.w) || (self.dir == -1 && min_x - 1 < 0);
+        for a in self.aliens.iter_mut().filter(|a| a.alive) {
+            if at_edge {
+                a.y += 1;
+            } else {
+                a.x += self.dir;
+            }
+        }
+        if at_edge {
+            self.dir = -self.dir;
+        }
+        if self.aliens.iter().any(|a| a.alive && a.y >= self.h - 1) {
+            self.over = true;
+        }
+    }
+
+    /// The lowest alien of a random column drops a bomb now and then; bombs
+    /// fall a row every other tick.
+    fn drop_bombs(&mut self) {
+        let limit = (1 + self.wave as usize).min(5);
+        let odds = (0.02 + 0.008 * f64::from(self.wave)).min(0.08);
+        if self.bombs.len() < limit && self.rng.chance(odds) {
+            let mut lowest: Vec<Alien> = Vec::new();
+            for a in self.aliens.iter().filter(|a| a.alive) {
+                match lowest.iter_mut().find(|l| l.x == a.x) {
+                    Some(l) if l.y < a.y => *l = *a,
+                    Some(_) => {}
+                    None => lowest.push(*a),
+                }
+            }
+            if !lowest.is_empty() {
+                let a = lowest[self.rng.below(lowest.len())];
+                self.bombs.push(Point {
+                    x: a.x + 1,
+                    y: a.y + 1,
+                });
+            }
+        }
+        if !self.ticks.is_multiple_of(2) {
+            return;
+        }
+        let ship_w = SHIP.chars().count() as i32;
+        let mut hit = false;
+        self.bombs.retain_mut(|b| {
+            b.y += 1;
+            if b.y == self.h - 1 && b.x >= self.ship && b.x < self.ship + ship_w {
+                hit = true;
+                return false;
+            }
+            b.y < self.h
+        });
+        if hit {
+            self.lives -= 1;
+            self.hit = 30;
+            self.bombs.clear();
+            self.shots.clear();
+            self.boom(self.ship, self.h - 1, SHIP_HIT.to_string());
+            if self.lives == 0 {
+                self.over = true;
+            }
+        }
+    }
+
+    /// Now and then a mystery ship crosses the top.
+    fn fly_ufo(&mut self) {
+        let top = self.aliens.iter().filter(|a| a.alive).map(|a| a.y).min();
+        if self.ufo.is_none() && top.is_some_and(|y| y > 1) && self.rng.chance(0.003) {
+            let dir = if self.rng.chance(0.5) { 1 } else { -1 };
+            let x = if dir == 1 {
+                -(UFO.len() as i32)
+            } else {
+                self.w
+            };
+            self.ufo = Some(Ufo { x, dir });
+        }
+        if self.ticks.is_multiple_of(2)
+            && let Some(ufo) = &mut self.ufo
+        {
+            ufo.x += ufo.dir;
+            if ufo.x < -(UFO.len() as i32) || ufo.x > self.w {
+                self.ufo = None;
+            }
+        }
+    }
+
+    fn award(&mut self, points: u32) {
+        self.score += points;
+        if self.score >= self.next_life {
+            self.next_life += EXTRA_LIFE;
+            if self.lives < MAX_LIVES {
+                self.lives += 1;
+                self.banner = Some(Banner {
+                    text: "EXTRA LIFE".to_string(),
+                    ttl: 25,
+                });
+            }
+        }
+    }
+
+    fn boom(&mut self, x: i32, y: i32, text: String) {
+        self.booms.push(Boom { x, y, text, ttl: 6 });
+    }
+}
diff --git a/codex-rs/tui/src/bottom_pane/invaders_tests.rs b/codex-rs/tui/src/bottom_pane/invaders_tests.rs
new file mode 100644
index 0000000000000000000000000000000000000000..ba78ff26e9fc12c205fe9240a2765edb52259da0
--- /dev/null
+++ b/codex-rs/tui/src/bottom_pane/invaders_tests.rs
@@ -0,0 +1,183 @@
+use super::*;
+use crate::app_event::AppEvent;
+use crate::app_event_sender::AppEventSender;
+use crate::bottom_pane::invaders_game::Point;
+use pretty_assertions::assert_eq;
+
+fn game(w: i32, h: i32) -> Game {
+    Game::new(w, h, Rng::new(/*seed*/ 7))
+}
+
+fn alive(game: &Game) -> usize {
+    game.aliens.iter().filter(|a| a.alive).count()
+}
+
+fn render(view: &InvadersView, width: u16) -> String {
+    let area = Rect::new(0, 0, width, view.desired_height(width));
+    let mut buf = Buffer::empty(area);
+    view.render(area, &mut buf);
+    (0..area.height)
+        .map(|y| {
+            (0..area.width)
+                .map(|x| buf[(x, y)].symbol())
+                .collect::<String>()
+                .trim_end()
+                .to_string()
+        })
+        .collect::<Vec<_>>()
+        .join("\n")
+}
+
+fn key(code: KeyCode) -> KeyEvent {
+    KeyEvent::from(code)
+}
+
+#[test]
+fn opens_with_the_formation_the_ship_and_the_keys() {
+    let view = InvadersView::with_game(game(MIN_W, MIN_H));
+    insta::assert_snapshot!(render(&view, 80));
+}
+
+#[test]
+fn a_narrow_pane_keeps_the_header_and_help_short() {
+    let view = InvadersView::with_game(game(MIN_W, MIN_H));
+    insta::assert_snapshot!(render(&view, 44));
+}
+
+#[test]
+fn a_shot_climbs_and_destroys_the_alien_above_the_ship() {
+    let mut g = game(40, 14);
+    let before = alive(&g);
+    g.fire();
+    for _ in 0..10 {
+        g.tick();
+    }
+    assert_eq!((g.score, alive(&g)), (10, before - 1));
+}
+
+#[test]
+fn at_most_three_shots_fly_and_each_waits_for_the_cooldown() {
+    let mut g = game(40, 14);
+    g.fire();
+    g.fire();
+    assert_eq!(g.shots.len(), 1);
+    for _ in 0..3 {
+        g.tick();
+        g.fire();
+    }
+    assert_eq!(g.shots.len(), 2);
+    for _ in 0..3 {
+        g.tick();
+    }
+    g.fire();
+    g.fire();
+    assert!(g.shots.len() <= 3);
+}
+
+#[test]
+fn a_bomb_on_the_ship_costs_a_life_and_the_last_one_ends_the_game() {
+    let mut g = game(40, 14);
+    let hit_ship = |g: &mut Game| {
+        g.bombs = vec![Point {
+            x: g.ship + 1,
+            y: g.h - 2,
+        }];
+        g.hit = 0;
+        g.tick();
+        g.tick();
+    };
+    hit_ship(&mut g);
+    assert_eq!((g.lives, g.hit > 0, g.over), (2, true, false));
+    g.lives = 1;
+    g.score = 420;
+    hit_ship(&mut g);
+    assert_eq!((g.lives, g.over, g.best), (0, true, 420));
+}
+
+#[test]
+fn clearing_a_wave_starts_the_next_and_keeps_the_score() {
+    let mut g = game(40, 14);
+    g.score = 990;
+    for a in &mut g.aliens {
+        a.alive = false;
+    }
+    g.tick();
+    let banner = g.banner.as_ref().map(|b| b.text.as_str());
+    assert_eq!(
+        (g.wave, g.score, banner, alive(&g)),
+        (2, 990, Some("WAVE 2"), 35)
+    );
+}
+
+#[test]
+fn later_waves_start_with_room_to_play_on_the_smallest_field() {
+    let mut g = game(MIN_W, MIN_H);
+    for _ in 0..5 {
+        for a in &mut g.aliens {
+            a.alive = false;
+        }
+        g.tick();
+    }
+    let lowest = g.aliens.iter().map(|a| a.y).max();
+    for _ in 0..40 {
+        g.tick();
+    }
+    assert_eq!((g.wave, lowest, g.over), (6, Some(MIN_H - 5), false));
+}
+
+#[test]
+fn a_resize_keeps_the_score_lives_wave_and_a_game_over() {
+    let mut g = game(40, 14);
+    g.tick();
+    (g.score, g.lives, g.wave, g.over) = (120, 2, 3, true);
+    g.resize(60, 14);
+    assert_eq!(
+        (g.w, g.score, g.lives, g.wave, g.over),
+        (60, 120, 2, 3, true)
+    );
+}
+
+#[test]
+fn p_pauses_space_resumes_and_the_arrows_move_the_ship() {
+    let mut view = InvadersView::with_game(game(60, 14));
+    let start = view.game.borrow().ship;
+    view.handle_key_event(key(KeyCode::Right));
+    assert_eq!(view.game.borrow().ship, start + 2);
+    view.handle_key_event(key(KeyCode::Char('p')));
+    assert!(view.game.borrow().paused);
+    view.handle_key_event(key(KeyCode::Left));
+    assert_eq!(view.game.borrow().ship, start + 2);
+    view.handle_key_event(key(KeyCode::Char(' ')));
+    assert!(view.game.borrow().running());
+}
+
+#[test]
+fn closing_keeps_the_game_and_reopening_resumes_it_paused() {
+    let mut g = game(40, 14);
+    g.score = 50;
+    let mut view = InvadersView::with_game(g);
+    assert!(matches!(view.on_ctrl_c(), CancellationEvent::Handled));
+    assert!(view.is_complete());
+    let reopened = InvadersView::new();
+    let g = reopened.game.borrow();
+    assert_eq!((g.score, g.paused), (50, true));
+}
+
+#[test]
+fn the_status_line_stays_above_the_game_while_codex_works() {
+    let (tx, _rx) = tokio::sync::mpsc::unbounded_channel::<AppEvent>();
+    let mut pane = crate::bottom_pane::tests::test_pane(AppEventSender::new(tx));
+    pane.set_task_running(/*running*/ true);
+    pane.show_view(Box::new(InvadersView::with_game(game(MIN_W, MIN_H))));
+    let area = Rect::new(0, 0, 80, pane.desired_height(80));
+    let mut buf = Buffer::empty(area);
+    pane.render(area, &mut buf);
+    let row = |y: u16| {
+        (0..area.width)
+            .map(|x| buf[(x, y)].symbol())
+            .collect::<String>()
+    };
+    let working = (0..area.height).position(|y| row(y).contains("Working"));
+    let game = (0..area.height).position(|y| row(y).contains("Space Invaders"));
+    assert!(matches!((working, game), (Some(w), Some(g)) if w < g));
+}
diff --git a/codex-rs/tui/src/bottom_pane/invaders_view.rs b/codex-rs/tui/src/bottom_pane/invaders_view.rs
new file mode 100644
index 0000000000000000000000000000000000000000..f7c6b7e8e8a3dcd453ca8cebe074b0ee1ee8d064
--- /dev/null
+++ b/codex-rs/tui/src/bottom_pane/invaders_view.rs
@@ -0,0 +1,266 @@
+//! `/invaders`: Space Invaders in the bottom pane, in place of the composer,
+//! while Codex keeps working above it with its status line still in view.
+//! The arrow keys, space and p belong to the game; Esc or Ctrl-C closes it.
+//! The game is kept for as long as Codex runs, so `/invaders` reopens it
+//! where it left off, paused, in this conversation or a new one.
+
+use std::cell::RefCell;
+use std::sync::Mutex;
+use std::time::Duration;
+use std::time::Instant;
+
+use crossterm::event::KeyCode;
+use crossterm::event::KeyEvent;
+use ratatui::buffer::Buffer;
+use ratatui::layout::Rect;
+use ratatui::style::Style;
+use ratatui::style::Stylize;
+use ratatui::text::Line;
+use ratatui::text::Span;
+use ratatui::widgets::Block;
+use ratatui::widgets::BorderType;
+use ratatui::widgets::Paragraph;
+use ratatui::widgets::Widget;
+
+use super::CancellationEvent;
+use super::bottom_pane_view::BottomPaneView;
+use super::invaders_game::Game;
+use super::invaders_game::MIN_H;
+use super::invaders_game::MIN_W;
+use super::invaders_game::Rng;
+use super::invaders_game::SHIP;
+use super::invaders_game::SHIP_HIT;
+use super::invaders_game::SPRITES;
+use super::invaders_game::UFO;
+use crate::render::renderable::Renderable;
+
+const TICK: Duration = Duration::from_millis(50);
+/// Ticks run at most per frame, so a stalled frame does not fast-forward.
+const MAX_CATCH_UP: u32 = 4;
+const MAX_WIDTH: u16 = 80;
+/// Border, header, a field of 14 rows, help line, border.
+const HEIGHT: u16 = 18;
+
+/// The game between `/invaders` openings: kept for as long as Codex runs,
+/// across conversations, like a game left paused on the side.
+static SAVED: Mutex<Option<Game>> = Mutex::new(None);
+
+pub(crate) struct InvadersView {
+    /// Fitted to the area it is drawn in, which `render` learns.
+    game: RefCell<Game>,
+    last_tick: Instant,
+    complete: bool,
+}
+
+impl InvadersView {
+    /// Reopens the session's game, paused, or starts one.
+    pub(crate) fn new() -> Self {
+        let saved = SAVED.lock().ok().and_then(|mut saved| saved.take());
+        Self::with_game(saved.unwrap_or_else(|| Game::new(MIN_W, MIN_H, Rng::from_clock())))
+    }
+
+    pub(crate) fn with_game(game: Game) -> Self {
+        Self {
+            game: RefCell::new(game),
+            last_tick: Instant::now(),
+            complete: false,
+        }
+    }
+
+    fn close(&mut self) {
+        let mut game = self.game.borrow().clone();
+        game.paused = !game.over;
+        if let Ok(mut saved) = SAVED.lock() {
+            *saved = Some(game);
+        }
+        self.complete = true;
+    }
+
+    fn header(game: &Game, width: u16) -> Line<'static> {
+        let narrow = width < 60;
+        let mut stats: Vec<Span<'static>> = vec![
+            (if narrow { "" } else { "score " }).dim(),
+            format!("{:05}", game.score).into(),
+        ];
+        if !narrow {
+            stats.extend([
+                " · best ".dim(),
+                format!("{:05}", game.best.max(game.score)).into(),
+            ]);
+        }
+        stats.extend([
+            (if narrow { " · w" } else { " · wave " }).dim(),
+            game.wave.to_string().into(),
+            " · ".dim(),
+            "♥".repeat(game.lives as usize).red(),
+        ]);
+        let title = if narrow { "Invaders" } else { "Space Invaders" };
+        let used = title.len() + stats.iter().map(Span::width).sum::<usize>();
+        let gap = usize::from(width).saturating_sub(used).max(1);
+        let mut spans = vec![title.bold(), " ".repeat(gap).into()];
+        spans.extend(stats);
+        Line::from(spans)
+    }
+
+    fn help(game: &Game, width: u16) -> Line<'static> {
+        let narrow = width < 60;
+        if game.over {
+            return (if narrow {
+                "game over · space again"
+            } else {
+                "game over · space plays again · esc closes"
+            })
+            .red()
+            .into();
+        }
+        let text = match (game.paused, narrow) {
+            (true, true) => "paused · p resumes · esc",
+            (true, false) => "paused · p or space resumes · esc closes",
+            (false, true) => "←→ · space fire · p · esc",
+            (false, false) => "←→ move · space fire (hold to keep firing) · p pause · esc closes",
+        };
+        text.dim().into()
+    }
+
+    /// Draws the field's contents at `(x, y)`, clipped to `w` by `h`.
+    fn draw_field(game: &Game, x: u16, y: u16, buf: &mut Buffer) {
+        let mut put = |cx: i32, cy: i32, text: &str, style: Style| {
+            if cy < 0 || cy >= game.h {
+                return;
+            }
+            for (i, ch) in text.chars().enumerate() {
+                let col = cx + i as i32;
+                if (0..game.w).contains(&col)
+                    && let Some(cell) = buf.cell_mut((x + col as u16, y + cy as u16))
+                {
+                    cell.set_char(ch).set_style(style);
+                }
+            }
+        };
+        let alien_styles = [
+            Style::new().magenta(),
+            Style::new().cyan(),
+            Style::new().green(),
+        ];
+        for a in game.aliens.iter().filter(|a| a.alive) {
+            put(a.x, a.y, SPRITES[a.kind][game.frame], alien_styles[a.kind]);
+        }
+        if let Some(ufo) = game.ufo {
+            put(ufo.x, 0, UFO, Style::new().red().bold());
+        }
+        for b in &game.bombs {
+            put(b.x, b.y, "¦", Style::new().red());
+        }
+        for s in &game.shots {
+            put(s.x, s.y, "│", Style::new());
+        }
+        if game.hit == 0 {
+            put(game.ship, game.h - 1, SHIP, Style::new().bold());
+        } else if (game.hit / 3).is_multiple_of(2) {
+            put(game.ship, game.h - 1, SHIP_HIT, Style::new().red());
+        }
+        for b in &game.booms {
+            put(b.x, b.y, &b.text, Style::new().bold());
+        }
+        if let Some(banner) = &game.banner {
+            let bx = (game.w - banner.text.len() as i32) / 2;
+            put(bx, game.h / 2, &banner.text, Style::new().magenta().bold());
+        }
+    }
+}
+
+impl BottomPaneView for InvadersView {
+    fn handle_key_event(&mut self, key_event: KeyEvent) {
+        let game = self.game.get_mut();
+        let was_running = game.running();
+        match key_event.code {
+            KeyCode::Left => game.steer(-1),
+            KeyCode::Right => game.steer(1),
+            KeyCode::Char(' ') => game.fire(),
+            KeyCode::Char('p') => game.toggle_pause(),
+            _ => {}
+        }
+        // Resuming starts the clock from now, not from when it stopped.
+        if !was_running && game.running() {
+            self.last_tick = Instant::now();
+        }
+    }
+
+    fn on_ctrl_c(&mut self) -> CancellationEvent {
+        self.close();
+        CancellationEvent::Handled
+    }
+
+    fn is_complete(&self) -> bool {
+        self.complete
+    }
+
+    fn keeps_status_visible(&self) -> bool {
+        true
+    }
+
+    fn pre_draw_tick(&mut self, now: Instant) -> bool {
+        let game = self.game.get_mut();
+        if !game.running() {
+            self.last_tick = now;
+            return false;
+        }
+        let mut ticks = 0;
+        while now.saturating_duration_since(self.last_tick) >= TICK && ticks < MAX_CATCH_UP {
+            game.tick();
+            self.last_tick += TICK;
+            ticks += 1;
+        }
+        if ticks == MAX_CATCH_UP {
+            self.last_tick = now;
+        }
+        ticks > 0
+    }
+
+    fn next_frame_delay(&self) -> Option<Duration> {
+        self.game.borrow().running().then(|| {
+            TICK.saturating_sub(self.last_tick.elapsed())
+                .max(Duration::from_millis(1))
+        })
+    }
+}
+
+impl Renderable for InvadersView {
+    fn desired_height(&self, _width: u16) -> u16 {
+        HEIGHT
+    }
+
+    fn render(&self, area: Rect, buf: &mut Buffer) {
+        let panel = Rect {
+            width: area.width.min(MAX_WIDTH),
+            height: area.height.min(HEIGHT),
+            ..area
+        };
+        let block = Block::bordered().border_type(BorderType::Rounded).dim();
+        let inner = block.inner(panel);
+        // One column of padding each side, and the header and help rows.
+        let (w, h) = (i32::from(inner.width) - 2, i32::from(inner.height) - 2);
+        if w < MIN_W || h < MIN_H {
+            Paragraph::new("Space Invaders needs a bigger terminal. Esc closes.")
+                .dim()
+                .render(area, buf);
+            return;
+        }
+        block.render(panel, buf);
+        let mut game = self.game.borrow_mut();
+        game.resize(w, h);
+        let row = |dy: u16| Rect {
+            x: inner.x + 1,
+            y: inner.y + dy,
+            width: inner.width - 2,
+            height: 1,
+        };
+        Self::header(&game, inner.width - 2).render(row(0), buf);
+        Self::draw_field(&game, inner.x + 1, inner.y + 1, buf);
+        Self::help(&game, inner.width - 2).render(row(inner.height - 1), buf);
+    }
+}
+
+#[cfg(test)]
+#[path = "invaders_tests.rs"]
+mod tests;
diff --git a/codex-rs/tui/src/bottom_pane/mod.rs b/codex-rs/tui/src/bottom_pane/mod.rs
index 912880eed106549f64243f09a5059a0ea7f5e02e..6591adacc79c8b611797c4efcca9673ac9c3f1eb 100644
--- a/codex-rs/tui/src/bottom_pane/mod.rs
+++ b/codex-rs/tui/src/bottom_pane/mod.rs
@@ -72,6 +72,9 @@ mod actionable_banner;
 mod app_link_view;
 mod apply_patch_header;
 mod approval_overlay;
+mod invaders_game;
+mod invaders_view;
+pub(crate) use invaders_view::InvadersView;
 mod async_questions;
 mod empty_state_policy;
 mod hook_status;
@@ -2176,6 +2179,19 @@ impl BottomPane {
             banner.visible.set(false);
         }
         if let Some(view) = self.active_view() {
+            // A view beside the agent's work, such as a game, keeps the status
+            // line above it.
+            if view.keeps_status_visible()
+                && let Some(status) = self.status_widget()
+            {
+                let mut flex = FlexRenderable::new();
+                flex.push(
+                    /*flex*/ 0,
+                    RenderableItem::Owned(Box::new(status.with_timer(&self.status_timer))),
+                );
+                flex.push(/*flex*/ 1, RenderableItem::Borrowed(view));
+                return RenderableItem::Owned(flex.into());
+            }
             RenderableItem::Borrowed(view)
         } else {
             let mut flex = FlexRenderable::new();
diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__command_popup__tests__command_popup_default_items.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__command_popup__tests__command_popup_default_items.snap
index a46aed6b7c6d0ac942f62521b46d34616f04905e..689bf764c9bb02ec6a226c67877f8ead348c0357 100644
--- a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__command_popup__tests__command_popup_default_items.snap
+++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__command_popup__tests__command_popup_default_items.snap
@@ -41,6 +41,7 @@ expression: commands
 /statusline - configure which items appear in the status line
 /theme - choose a syntax highlighting theme
 /pets - choose or hide the terminal pet
+/invaders - play Space Invaders while Codex works
 /mcp - list configured MCP tools; use /mcp verbose for details
 /logout - log out of Codex
 /exit - exit Codex
diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__invaders_view__tests__a_narrow_pane_keeps_the_header_and_help_short.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__invaders_view__tests__a_narrow_pane_keeps_the_header_and_help_short.snap
new file mode 100644
index 0000000000000000000000000000000000000000..b5d1b226aff2735aaa03e4d8a07421ff2e6cf468
--- /dev/null
+++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__invaders_view__tests__a_narrow_pane_keeps_the_header_and_help_short.snap
@@ -0,0 +1,22 @@
+---
+source: tui/src/bottom_pane/invaders_tests.rs
+expression: "render(&view, 44)"
+---
+╭──────────────────────────────────────────╮
+│ Invaders                00000 · w1 · ♥♥♥ │
+│                                          │
+│                                          │
+│    /o\  /o\  /o\  /o\  /o\  /o\  /o\     │
+│    {@}  {@}  {@}  {@}  {@}  {@}  {@}     │
+│    {@}  {@}  {@}  {@}  {@}  {@}  {@}     │
+│    <=>  <=>  <=>  <=>  <=>  <=>  <=>     │
+│    <=>  <=>  <=>  <=>  <=>  <=>  <=>     │
+│                                          │
+│                                          │
+│                                          │
+│                                          │
+│                                          │
+│                                          │
+│                    ▟█▙                   │
+│ ←→ · space fire · p · esc                │
+╰──────────────────────────────────────────╯
diff --git a/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__invaders_view__tests__opens_with_the_formation_the_ship_and_the_keys.snap b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__invaders_view__tests__opens_with_the_formation_the_ship_and_the_keys.snap
new file mode 100644
index 0000000000000000000000000000000000000000..34bd1e506318587a49feae63b61e9980adf7e976
--- /dev/null
+++ b/codex-rs/tui/src/bottom_pane/snapshots/codex_tui__bottom_pane__invaders_view__tests__opens_with_the_formation_the_ship_and_the_keys.snap
@@ -0,0 +1,22 @@
+---
+source: tui/src/bottom_pane/invaders_tests.rs
+expression: "render(&view, 80)"
+---
+╭──────────────────────────────────────────────────────────────────────────────╮
+│ Space Invaders                       score 00000 · best 00000 · wave 1 · ♥♥♥ │
+│                                                                              │
+│                                                                              │
+│            /o\  /o\  /o\  /o\  /o\  /o\  /o\  /o\  /o\  /o\  /o\             │
+│            {@}  {@}  {@}  {@}  {@}  {@}  {@}  {@}  {@}  {@}  {@}             │
+│            {@}  {@}  {@}  {@}  {@}  {@}  {@}  {@}  {@}  {@}  {@}             │
+│            <=>  <=>  <=>  <=>  <=>  <=>  <=>  <=>  <=>  <=>  <=>             │
+│            <=>  <=>  <=>  <=>  <=>  <=>  <=>  <=>  <=>  <=>  <=>             │
+│                                                                              │
+│                                                                              │
+│                                                                              │
+│                                                                              │
+│                                                                              │
+│                                                                              │
+│                                      ▟█▙                                     │
+│ ←→ move · space fire (hold to keep firing) · p pause · esc closes            │
+╰──────────────────────────────────────────────────────────────────────────────╯
diff --git a/codex-rs/tui/src/chatwidget/slash_dispatch.rs b/codex-rs/tui/src/chatwidget/slash_dispatch.rs
index 4fe2ed057b968d68d793fdf6bdfb97fbf6fa64e6..6901fc6bbcc3b1977b7fb718854f5d8f043b7808 100644
--- a/codex-rs/tui/src/chatwidget/slash_dispatch.rs
+++ b/codex-rs/tui/src/chatwidget/slash_dispatch.rs
@@ -553,6 +553,10 @@ impl ChatWidget {
             SlashCommand::Pets => {
                 self.open_pets_picker();
             }
+            SlashCommand::Invaders => {
+                self.bottom_pane
+                    .show_view(Box::new(crate::bottom_pane::InvadersView::new()));
+            }
             SlashCommand::Ps => {
                 self.add_ps_output();
             }
@@ -1278,7 +1282,8 @@ impl ChatWidget {
             | SlashCommand::Statusline
             | SlashCommand::Theme
             | SlashCommand::Tui
-            | SlashCommand::Pets => QueueDrain::Stop,
+            | SlashCommand::Pets
+            | SlashCommand::Invaders => QueueDrain::Stop,
         }
     }
 
diff --git a/codex-rs/tui/src/slash_command.rs b/codex-rs/tui/src/slash_command.rs
index 301de2e05469cbc79d760ecc2abfe53d6ad96261..373c60ed6f59fcba05f8127a58cebcc103855e40 100644
--- a/codex-rs/tui/src/slash_command.rs
+++ b/codex-rs/tui/src/slash_command.rs
@@ -63,6 +63,8 @@ pub enum SlashCommand {
     Theme,
     #[strum(to_string = "pets", serialize = "pet")]
     Pets,
+    #[strum(to_string = "invaders", serialize = "space-invaders")]
+    Invaders,
     Mcp,
     Apps,
     Plugins,
@@ -124,6 +126,7 @@ impl SlashCommand {
             SlashCommand::Statusline => "configure which items appear in the status line",
             SlashCommand::Theme => "choose a syntax highlighting theme",
             SlashCommand::Pets => "choose or hide the terminal pet",
+            SlashCommand::Invaders => "play Space Invaders while Codex works",
             SlashCommand::Ps => "list background terminals",
             SlashCommand::Stop => "stop all background terminals",
             SlashCommand::MemoryDrop => "DO NOT USE",
@@ -298,6 +301,7 @@ impl SlashCommand {
             SlashCommand::TestApproval => true,
             SlashCommand::Agents | SlashCommand::MultiAgents => true,
             SlashCommand::Theme | SlashCommand::Pets => false,
+            SlashCommand::Invaders => true,
         }
     }

OpenCode 2.0.24 · behind

$openmods install shouryamaanjain/space-invaders --opencode
2.0.24OpenCode release
3files touched
+523 −34lines
1patch
3update

Update 3: OpenCode 2: the game takes the right pane

Has a version for OpenCode 2.0.24 (update 3), 2.0.23 (update 3), 2.0.22 (update 3), 2.0.21 (update 3), 2.0.20 (update 3), 2.0.19 (update 3), 2.0.18 (update 3). openmods builds the release you are on when all your mods have a version for it, else the newest they share; the files and diff below are for 2.0.24.

On OpenCode this mod is for 2.0.24. The latest release is 2.1.4. Installing it builds 2.0.24, which still works. Open issues · How to rebase it
packages/tui/src/app.tsx+15 −0
packages/tui/src/component/invaders.tsx new+441 −0
packages/tui/src/component/session-frame.tsx+67 −34
Full diff for OpenCode
diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx
index 44e49b1c19115a997ab30cec4e1f78c9b0ec0960..aca156c050be2c66c761fe10a1c702bc1b7772b4 100644
--- a/packages/tui/src/app.tsx
+++ b/packages/tui/src/app.tsx
@@ -103,6 +103,7 @@ import { SessionTerminalsProvider } from "./context/session-terminals"
 import { PanelProvider, usePanel } from "./context/panel"
 import { SessionFrame } from "./component/session-frame"
 import { createTuiClipboard } from "./clipboard"
+import { toggleInvaders } from "./component/invaders"
 
 registerOpencodeSpinner()
 
@@ -1090,6 +1091,20 @@ function App() {
         },
         category: "System",
       },
+      {
+        name: "opencode.invaders",
+        title: "Play Space Invaders",
+        slash: { name: "invaders", aliases: ["space-invaders"] },
+        run: () => {
+          dialog.clear()
+          if (route.data.type !== "session") {
+            toast.show({ message: "Space Invaders plays beside a session: start one first", variant: "info" })
+            return
+          }
+          toggleInvaders()
+        },
+        category: "System",
+      },
       {
         name: "app.exit",
         title: "Exit the app",
diff --git a/packages/tui/src/component/invaders.tsx b/packages/tui/src/component/invaders.tsx
new file mode 100644
index 0000000000000000000000000000000000000000..aa6c8352d2aa879af5f4a0efbc29fa397f3f731e
--- /dev/null
+++ b/packages/tui/src/component/invaders.tsx
@@ -0,0 +1,441 @@
+import { TextAttributes, type BoxRenderable, type RGBA } from "@opentui/core"
+import { createEffect, createMemo, createRoot, createSignal, For, Show } from "solid-js"
+import { Keymap } from "../context/keymap"
+import { useTheme } from "../context/theme"
+
+// Space Invaders in the right half of the session screen. `/invaders` opens
+// and closes it: while it is open the conversation takes the left half and the
+// game the right pane, in place of the sidebar, and the agent keeps working.
+// The session frame gives it the keyboard: the arrow keys, space and p belong
+// to the game while it is open; escape closes it. Game state lives at module
+// level, so reopening resumes the same game, paused.
+
+const TICK_MS = 50
+const SPRITES = [
+  ["/o\\", "\\o/"],
+  ["{@}", "}@{"],
+  ["<=>", ">=<"],
+] as const
+const POINTS = [30, 20, 10]
+const ALIEN_W = 3
+const ALIEN_STEP = 5 // an alien and the gap after it
+const ROWS = 5
+const SHIP = "▟█▙"
+const UFO = "<o=o>"
+const MAX_SHOTS = 3 // in the air at once
+const SHOT_SPEED = 2 // rows per tick
+const COOLDOWN = 3 // ticks between shots
+const EXTRA_LIFE = 1500 // points per extra life
+const MAX_LIVES = 5
+const START_ROOM = 4 // rows from a new wave's lowest row down to the ship's
+
+type Color = "a0" | "a1" | "a2" | "ufo" | "ship" | "shot" | "bomb" | "boom" | "banner" | "none"
+type Alien = { x: number; y: number; kind: number; alive: boolean }
+type Point = { x: number; y: number }
+type Model = {
+  w: number
+  h: number
+  ship: number
+  shots: Point[]
+  cool: number
+  bombs: Point[]
+  aliens: Alien[]
+  total: number
+  dir: 1 | -1
+  wait: number
+  frame: 0 | 1
+  ufo: { x: number; dir: 1 | -1 } | null
+  booms: (Point & { ttl: number; text: string })[]
+  banner: { text: string; ttl: number } | null
+  score: number
+  nextLife: number
+  lives: number
+  wave: number
+  hit: number
+  over: boolean
+  ticks: number
+}
+
+function build(w: number, h: number, wave: number, carry?: Pick<Model, "score" | "lives" | "nextLife">): Model {
+  const cols = Math.max(4, Math.min(11, Math.floor((w - 6 + 2) / ALIEN_STEP)))
+  const rowGap = h >= 24 ? 2 : 1
+  const left = Math.floor((w - (cols * ALIEN_STEP - 2)) / 2)
+  // Later waves start lower, but always with room above the ship.
+  const top = Math.max(1, Math.min(2 + Math.min(wave - 1, 4), h - 1 - START_ROOM - (ROWS - 1) * rowGap))
+  const aliens: Alien[] = []
+  for (let r = 0; r < ROWS; r++)
+    for (let c = 0; c < cols; c++) aliens.push({ x: left + c * ALIEN_STEP, y: top + r * rowGap, kind: r === 0 ? 0 : r < 3 ? 1 : 2, alive: true })
+  return {
+    w,
+    h,
+    ship: Math.floor(w / 2) - 1,
+    shots: [],
+    cool: 0,
+    bombs: [],
+    aliens,
+    total: aliens.length,
+    dir: 1,
+    wait: 0,
+    frame: 0,
+    ufo: null,
+    booms: [],
+    banner: wave > 1 ? { text: `WAVE ${wave}`, ttl: 30 } : null,
+    score: carry?.score ?? 0,
+    nextLife: carry?.nextLife ?? EXTRA_LIFE,
+    lives: carry?.lives ?? 3,
+    wave,
+    hit: 0,
+    over: false,
+    ticks: 0,
+  }
+}
+
+const game = createRoot(() => {
+  const [open, setOpen] = createSignal(false)
+  const [paused, setPaused] = createSignal(false)
+  const [frame, setFrame] = createSignal(0)
+  const [best, setBest] = createSignal(0)
+  let size = { w: 48, h: 24 }
+  let m: Model | undefined
+
+  const model = () => (m ??= build(size.w, size.h, 1))
+  const redraw = () => setFrame(frame() + 1)
+  const boom = (x: number, y: number, text: string) => m!.booms.push({ x, y, text, ttl: 6 })
+
+  const award = (g: Model, points: number) => {
+    g.score += points
+    if (g.score >= g.nextLife) {
+      g.nextLife += EXTRA_LIFE
+      if (g.lives < MAX_LIVES) {
+        g.lives++
+        g.banner = { text: "EXTRA LIFE", ttl: 25 }
+      }
+    }
+  }
+
+  // Moves one shot a row; true when it hit something and is spent.
+  const advance = (g: Model, s: Point) => {
+    s.y--
+    if (s.y < 0) return true
+    const alien = g.aliens.find((a) => a.alive && a.y === s.y && s.x >= a.x && s.x < a.x + ALIEN_W)
+    if (alien) {
+      alien.alive = false
+      award(g, POINTS[alien.kind]!)
+      boom(alien.x, alien.y, "*#*")
+      return true
+    }
+    if (g.ufo && s.y === 0 && s.x >= g.ufo.x && s.x < g.ufo.x + UFO.length) {
+      const bonus = [50, 100, 150, 300][Math.floor(Math.random() * 4)]!
+      award(g, bonus)
+      boom(g.ufo.x, 0, String(bonus))
+      g.ufo = null
+      return true
+    }
+    const bomb = g.bombs.findIndex((b) => b.x === s.x && (b.y === s.y || b.y === s.y + 1))
+    if (bomb !== -1) {
+      g.bombs.splice(bomb, 1)
+      return true
+    }
+    return false
+  }
+
+  const tick = () => {
+    const g = model()
+    if (g.over) return
+    g.ticks++
+    g.cool = Math.max(0, g.cool - 1)
+    g.booms = g.booms.filter((b) => --b.ttl > 0)
+    if (g.banner && --g.banner.ttl <= 0) g.banner = null
+    if (g.hit > 0) {
+      g.hit--
+      redraw()
+      return
+    }
+
+    // Shots climb fast, checked a row at a time so nothing is skipped.
+    g.shots = g.shots.filter((s) => {
+      for (let i = 0; i < SHOT_SPEED; i++) if (advance(g, s)) return false
+      return true
+    })
+
+    // The formation steps sideways, faster as it thins out, and drops a row
+    // at each edge.
+    const alive = g.aliens.filter((a) => a.alive)
+    if (alive.length === 0) {
+      setBest(Math.max(best(), g.score))
+      m = build(size.w, size.h, g.wave + 1, g)
+      redraw()
+      return
+    }
+    if (--g.wait <= 0) {
+      g.wait = Math.max(1, Math.round(2 + (14 * alive.length) / g.total) - Math.min(g.wave - 1, 3))
+      g.frame = g.frame === 0 ? 1 : 0
+      const minX = Math.min(...alive.map((a) => a.x))
+      const maxX = Math.max(...alive.map((a) => a.x + ALIEN_W))
+      if ((g.dir === 1 && maxX + 1 > g.w) || (g.dir === -1 && minX - 1 < 0)) {
+        g.dir = g.dir === 1 ? -1 : 1
+        for (const a of alive) a.y++
+      } else for (const a of alive) a.x += g.dir
+      if (alive.some((a) => a.y >= g.h - 1)) g.over = true
+    }
+
+    // Bombs: the lowest alien of a random column drops one now and then.
+    if (g.bombs.length < Math.min(5, 1 + g.wave) && Math.random() < Math.min(0.08, 0.02 + 0.008 * g.wave)) {
+      const columns = new Map<number, Alien>()
+      for (const a of alive) if ((columns.get(a.x)?.y ?? -1) < a.y) columns.set(a.x, a)
+      const shooters = [...columns.values()]
+      const s = shooters[Math.floor(Math.random() * shooters.length)]
+      if (s) g.bombs.push({ x: s.x + 1, y: s.y + 1 })
+    }
+    if (g.ticks % 2 === 0) {
+      for (const b of g.bombs) b.y++
+      g.bombs = g.bombs.filter((b) => {
+        if (b.y >= g.h) return false
+        if (b.y === g.h - 1 && b.x >= g.ship && b.x < g.ship + SHIP.length) {
+          g.lives--
+          g.hit = 30
+          boom(g.ship, g.h - 1, "*▒*")
+          if (g.lives <= 0) g.over = true
+          return false
+        }
+        return true
+      })
+      if (g.hit > 0) {
+        g.bombs = []
+        g.shots = []
+      }
+    }
+
+    // Now and then a mystery ship crosses the top.
+    if (!g.ufo && Math.random() < 0.003 && Math.min(...alive.map((a) => a.y)) > 1) {
+      const dir = Math.random() < 0.5 ? 1 : -1
+      g.ufo = { x: dir === 1 ? -UFO.length : g.w, dir }
+    }
+    if (g.ufo && g.ticks % 2 === 0) {
+      g.ufo.x += g.ufo.dir
+      if (g.ufo.x < -UFO.length || g.ufo.x > g.w) g.ufo = null
+    }
+
+    if (g.over) setBest(Math.max(best(), g.score))
+    redraw()
+  }
+
+  let timer: ReturnType<typeof setInterval> | undefined
+  createEffect(() => {
+    const running = open() && !paused()
+    if (running && !timer) timer = setInterval(tick, TICK_MS)
+    if (!running && timer) {
+      clearInterval(timer)
+      timer = undefined
+    }
+  })
+
+  const restart = () => {
+    m = build(size.w, size.h, 1)
+    setPaused(false)
+    redraw()
+  }
+
+  const move = (dx: number) => {
+    const g = model()
+    if (paused() || g.over || g.hit) return
+    const step = Math.max(2, Math.round(g.w / 30))
+    g.ship = Math.max(0, Math.min(g.w - SHIP.length, g.ship + dx * step))
+    redraw()
+  }
+
+  return {
+    open,
+    paused,
+    frame,
+    best,
+    model,
+    // Closing pauses; reopening shows the game paused until p or space.
+    toggle: () => {
+      if (open()) setPaused(true)
+      setOpen(!open())
+    },
+    close: () => {
+      setPaused(true)
+      setOpen(false)
+    },
+    pause: () => setPaused(!paused()),
+    // The field is the measured size of its box. When the terminal is
+    // resized, the current wave starts again at the new size, keeping the
+    // score, lives, wave and a game over; only a game with nothing to lose
+    // starts fresh.
+    resize: (w: number, h: number) => {
+      w = Math.max(24, Math.floor(w))
+      h = Math.max(12, Math.floor(h))
+      size = { w, h }
+      if (m && m.w === w && m.h === h) return
+      const played = m && (m.ticks > 0 || m.score > 0 || m.wave > 1 || m.over)
+      if (!m || !played) m = build(w, h, 1)
+      else {
+        const { over, ticks } = m
+        m = build(w, h, m.wave, m)
+        m.over = over
+        m.ticks = ticks
+      }
+      redraw()
+    },
+    left: () => move(-1),
+    right: () => move(1),
+    fire: () => {
+      const g = model()
+      if (g.over) return restart()
+      if (paused()) return setPaused(false)
+      if (g.hit || g.cool > 0 || g.shots.length >= MAX_SHOTS) return
+      g.shots.push({ x: g.ship + 1, y: g.h - 1 })
+      g.cool = COOLDOWN
+      redraw()
+    },
+  }
+})
+
+export const toggleInvaders = game.toggle
+export const isInvadersOpen = game.open
+
+/** The game, `width` columns wide beside the conversation; shown only while open. */
+export function InvadersPanel(props: { width: number }) {
+  const theme = useTheme()
+
+  Keymap.createLayer(() => ({
+    priority: 20,
+    enabled: game.open,
+    commands: [
+      { bind: "left", title: "Move left", group: "Space Invaders", run: game.left },
+      { bind: "right", title: "Move right", group: "Space Invaders", run: game.right },
+      { bind: "space", title: "Fire", group: "Space Invaders", run: game.fire },
+      { bind: "p", title: "Pause", group: "Space Invaders", run: game.pause },
+      { bind: "escape", title: "Close Space Invaders", group: "Space Invaders", run: game.close },
+    ],
+  }))
+
+  const colors = (): Record<Color, RGBA> => ({
+    a0: theme.text.feedback.error.base,
+    a1: theme.text.feedback.warning.base,
+    a2: theme.hue.accent[400],
+    ufo: theme.text.feedback.info.base,
+    ship: theme.text.action.primary.base,
+    shot: theme.text.base,
+    bomb: theme.text.feedback.warning.base,
+    boom: theme.text.base,
+    banner: theme.text.action.primary.base,
+    none: theme.text.base,
+  })
+
+  // One text line per field row, cut into runs of one colour.
+  const rows = createMemo(() => {
+    game.frame()
+    const g = game.model()
+    const chars = Array.from({ length: g.h }, () => Array<string>(g.w).fill(" "))
+    const paint = Array.from({ length: g.h }, () => Array<Color>(g.w).fill("none"))
+    const put = (x: number, y: number, text: string, color: Color) => {
+      if (y < 0 || y >= g.h) return
+      ;[...text].forEach((ch, i) => {
+        if (x + i < 0 || x + i >= g.w) return
+        chars[y]![x + i] = ch
+        paint[y]![x + i] = color
+      })
+    }
+    for (const a of g.aliens) if (a.alive) put(a.x, a.y, SPRITES[a.kind]![g.frame]!, `a${a.kind}` as Color)
+    if (g.ufo) put(g.ufo.x, 0, UFO, "ufo")
+    for (const b of g.bombs) put(b.x, b.y, "¦", "bomb")
+    for (const s of g.shots) put(s.x, s.y, "│", "shot")
+    if (!g.hit || Math.floor(g.hit / 3) % 2 === 0) put(g.ship, g.h - 1, g.hit ? "*▒*" : SHIP, g.hit ? "boom" : "ship")
+    for (const b of g.booms) put(b.x, b.y, b.text, "boom")
+    if (g.banner) put(Math.floor((g.w - g.banner.text.length) / 2), Math.floor(g.h / 2), g.banner.text, "banner")
+    return chars.map((line, y) => {
+      const runs: { text: string; color: Color }[] = []
+      line.forEach((ch, x) => {
+        const color = paint[y]![x]!
+        const last = runs.at(-1)
+        if (last && last.color === color) last.text += ch
+        else runs.push({ text: ch, color })
+      })
+      return runs
+    })
+  })
+
+  const status = createMemo(() => {
+    game.frame()
+    const g = game.model()
+    return { score: g.score, lives: g.lives, wave: g.wave, over: g.over }
+  })
+  const pad = (n: number) => String(n).padStart(5, "0")
+  const narrow = () => props.width < 70
+  const tiny = () => props.width < 50
+
+  return (
+    <Show when={game.open()}>
+      <box width={props.width} height="100%" flexShrink={0} paddingLeft={1} paddingRight={2} paddingBottom={1}>
+        <box
+          flexGrow={1}
+          border
+          borderStyle="rounded"
+          borderColor={theme.border.base}
+          paddingLeft={1}
+          paddingRight={1}
+          flexDirection="column"
+        >
+          <box flexDirection="row" justifyContent="space-between" gap={1} flexShrink={0}>
+            <text fg={theme.text.base} attributes={TextAttributes.BOLD} wrapMode="none">
+              {narrow() ? "Invaders" : "Space Invaders"}
+            </text>
+            <text fg={theme.text.muted} wrapMode="none">
+              {narrow() ? "" : "score "}
+              <span style={{ fg: theme.text.base }}>{pad(status().score)}</span> ·{" "}
+              <Show when={!tiny()}>
+                best <span style={{ fg: theme.text.base }}>{pad(Math.max(game.best(), status().score))}</span> ·{" "}
+              </Show>
+              {narrow() ? "w" : "wave "}
+              <span style={{ fg: theme.text.base }}>{String(status().wave)}</span> ·{" "}
+              <span style={{ fg: theme.text.feedback.error.base }}>{"♥".repeat(Math.max(0, status().lives))}</span>
+            </text>
+          </box>
+          {/* The field takes exactly the room left between the header and the
+              help line; its measured size is the game's size. */}
+          <box
+            flexGrow={1}
+            minHeight={0}
+            overflow="hidden"
+            onSizeChange={function (this: BoxRenderable) {
+              game.resize(this.width, this.height)
+            }}
+          >
+            <box position="absolute" top={0} left={0}>
+              <For each={rows()}>
+                {(runs) => (
+                  <text selectable={false} wrapMode="none">
+                    <For each={runs}>{(run) => <span style={{ fg: colors()[run.color] }}>{run.text}</span>}</For>
+                  </text>
+                )}
+              </For>
+            </box>
+          </box>
+          <box flexShrink={0}>
+            <Show
+              when={status().over}
+              fallback={
+                <text fg={theme.text.muted} wrapMode="none">
+                  {game.paused()
+                    ? narrow()
+                      ? "paused · p resumes · esc"
+                      : "paused · p or space resumes · esc closes"
+                    : narrow()
+                      ? "←→ · space fire · p · esc"
+                      : "←→ move · space fire (hold to keep firing) · p pause · esc closes"}
+                </text>
+              }
+            >
+              <text fg={theme.text.feedback.error.base} wrapMode="none">
+                {narrow() ? "game over · space again" : "game over · space plays again · esc closes"}
+              </text>
+            </Show>
+          </box>
+        </box>
+      </box>
+    </Show>
+  )
+}
diff --git a/packages/tui/src/component/session-frame.tsx b/packages/tui/src/component/session-frame.tsx
index ef267d8ed69d27c8363bea8307a7e7a40cc3d9b8..0d39518064a608066ae55e6ba8746a9fc268a0eb 100644
--- a/packages/tui/src/component/session-frame.tsx
+++ b/packages/tui/src/component/session-frame.tsx
@@ -14,6 +14,7 @@ import { Keymap } from "../context/keymap"
 import { InteractivityProvider } from "../context/interactivity"
 import { useSessionTerminals } from "../context/session-terminals"
 import { usePromptRef } from "../context/prompt"
+import { InvadersPanel, isInvadersOpen } from "./invaders"
 import { usePanel } from "../context/panel"
 import { useStorage } from "../context/storage"
 import { useDialog } from "../ui/dialog"
@@ -110,6 +111,7 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
     return (config.data.session?.sidebar ?? "auto") === "auto" && wide()
   })
   const rightPane = createMemo(() => {
+    if (isInvadersOpen()) return "invaders"
     if (activePanel()) return "panel"
     if (sidebarOpen() && sidebarVisible()) return "sidebar"
     if (selectedTerminal()) return "terminal"
@@ -154,8 +156,19 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
     if (fullscreen()) focusRightPane()
   })
   createEffect(() => {
-    if (rightPane() !== "terminal" && rightPane() !== "panel") setActivePane("session")
+    if (rightPane() !== "terminal" && rightPane() !== "panel" && rightPane() !== "invaders") setActivePane("session")
   })
+  // Space Invaders takes the right half and the keyboard while it is open:
+  // the prompt stays in view, muted, and gets the keyboard back when it closes.
+  const invadersWidth = () => availableWidth() - Math.floor(availableWidth() / 2)
+  createEffect(
+    on(isInvadersOpen, (open, was) => {
+      if (open) {
+        renderer.currentFocusedRenderable?.blur()
+        setActivePane("right")
+      } else if (was) focusSession()
+    }),
+  )
   createEffect(() => {
     if (!restoreTerminalFocus() || selectedTerminal()) return
     setRestoreTerminalFocus(false)
@@ -322,60 +335,80 @@ export function SessionFrame(props: { sessionID: string; verticalTabsWidth: numb
           />
         </Show>
       </box>
-      <Show when={rightPane() === "terminal" || rightPane() === "panel" || (rightPane() === "sidebar" && wide())}>
+      <Show
+        when={
+          rightPane() === "terminal" ||
+          rightPane() === "panel" ||
+          rightPane() === "invaders" ||
+          (rightPane() === "sidebar" && wide())
+        }
+      >
         <box
           ref={(value: BoxRenderable) => (rightNode = value)}
           flexShrink={0}
           width={
-            fullscreen() ? availableWidth() : rightPane() === "sidebar" ? SESSION_SIDEBAR_WIDTH : paneResize.size()
+            fullscreen()
+              ? availableWidth()
+              : rightPane() === "sidebar"
+                ? SESSION_SIDEBAR_WIDTH
+                : rightPane() === "invaders"
+                  ? invadersWidth()
+                  : paneResize.size()
           }
           minWidth={0}
           minHeight={0}
         >
           <Show
-            when={rightPane() === "sidebar"}
+            when={rightPane() === "invaders"}
             fallback={
               <Show
-                keyed
-                when={activePanel()}
+                when={rightPane() === "sidebar"}
                 fallback={
-                  <Show keyed when={selectedTerminal()?.id}>
-                    {(ptyID) => (
-                      <TerminalPane
-                        ptyID={ptyID}
-                        resizing={paneResize.resizing()}
-                        autoFocus={restoreTerminalFocus() || sessions.shouldFocus(ptyID)}
-                        onAutoFocus={() => {
-                          sessions.clearFocus(ptyID)
-                          setRestoreTerminalFocus(false)
+                  <Show
+                    keyed
+                    when={activePanel()}
+                    fallback={
+                      <Show keyed when={selectedTerminal()?.id}>
+                        {(ptyID) => (
+                          <TerminalPane
+                            ptyID={ptyID}
+                            resizing={paneResize.resizing()}
+                            autoFocus={restoreTerminalFocus() || sessions.shouldFocus(ptyID)}
+                            onAutoFocus={() => {
+                              sessions.clearFocus(ptyID)
+                              setRestoreTerminalFocus(false)
+                            }}
+                            onFocusRequest={(value) => (focusTerminal = value)}
+                            onDisconnect={() => setRestoreTerminalFocus(true)}
+                          />
+                        )}
+                      </Show>
+                    }
+                  >
+                    {(item) => (
+                      <PanelHost
+                        panel={item}
+                        width={fullscreen() ? availableWidth() : paneResize.size()}
+                        focused={activePane() === "right"}
+                        onFocus={focusRightPane}
+                        onTarget={(node) => {
+                          panelNode = node
+                          if (node) {
+                            focusRightPane()
+                            return
+                          }
+                          setActivePane("session")
                         }}
-                        onFocusRequest={(value) => (focusTerminal = value)}
-                        onDisconnect={() => setRestoreTerminalFocus(true)}
                       />
                     )}
                   </Show>
                 }
               >
-                {(item) => (
-                  <PanelHost
-                    panel={item}
-                    width={fullscreen() ? availableWidth() : paneResize.size()}
-                    focused={activePane() === "right"}
-                    onFocus={focusRightPane}
-                    onTarget={(node) => {
-                      panelNode = node
-                      if (node) {
-                        focusRightPane()
-                        return
-                      }
-                      setActivePane("session")
-                    }}
-                  />
-                )}
+                <Sidebar sessionID={props.sessionID} />
               </Show>
             }
           >
-            <Sidebar sessionID={props.sessionID} />
+            <InvadersPanel width={fullscreen() ? availableWidth() : invadersWidth()} />
           </Show>
         </box>
       </Show>