Skip to main content

Side Channel

Tests

A terminal hacking thriller. A hacker-themed text adventure with a fake terminal, in the spirit of Hackers, WarGames, and 23. Play it in your terminal (a full-screen Textual app) or right in your browser at https://derd1ngs.github.io/sidechannel/, with nothing to install. Ships with three complete stories:

  • Zero Day -- a 3-chapter campaign that also doubles as an in-fiction tutorial: by the end you'll have used the core shell commands (help, whoami, scan, connect/disconnect, ls/cd/cat/grep, set + systemctl, decrypt with both cipher types, journal, chat, and mail), plus trust-building, gated dialogue, and a branching set of six endings.
  • Dead Drop -- a short one-chapter follow-up built on the newer shell features: filtering a years-long log with pipes (cat ... | grep ... | grep ...), an ssh login with a password you have to dig up, a runbook you must follow to the letter, status/journal <category>, and four endings gated on how far you trusted the friend who sent you.
  • Night Shift -- a one-chapter incident at 3 AM: dig a hidden, obfuscated deploy key out of a build server (tail, find, ls -a, decrypt), then fix the public mirror before its trace meter cuts you off -- with one retry. Tools you pick up along the way (the key, a colleague's packet sniffer) open hosts and endings.

Installing

pipx install sidechannel   # or: pip install sidechannel
sidechannel                # play

Saves go to your user data directory, e.g. ~/.local/share/sidechannel/saves/ on Linux. Or skip installing entirely and play in the browser: https://derd1ngs.github.io/sidechannel/.

Running from a checkout

./setup.sh   # one-time: creates .venv and installs the package into it
./start.sh   # launches the game (re-run this any time to play)

setup.sh is safe to re-run (e.g. after pulling changes) -- it reuses the existing .venv and just reinstalls the package. start.sh forwards any arguments straight to sidechannel, e.g. ./start.sh zero_day --new, and tells you to run setup.sh first if .venv doesn't exist yet.

If you'd rather manage the virtual environment yourself:

python3 -m venv .venv
.venv/bin/pip install -e .
.venv/bin/sidechannel
# or: .venv/bin/python -m sidechannel.main

With no arguments, story/save-slot selection is a plain pre-flight prompt. You can skip it with CLI args instead (via ./start.sh or .venv/bin/sidechannel directly -- both take the same arguments):

./start.sh --list                       # list available stories, then exit
./start.sh zero_day --list               # list zero_day's save slots, then exit
./start.sh zero_day                       # launch directly (story dir name or manifest id), slot "default"
./start.sh zero_day --slot speedrun --new # launch a specific, named slot fresh
./start.sh zero_day --slot speedrun --continue # launch that slot, failing if it doesn't exist

Each story can have multiple save slots (saves/<story_id>/<slot>.json), so you can run several playthroughs of the same story side by side. Launched without --slot, a direct launch uses the default slot; the interactive picker instead lists existing slots (with their current chapter/scene and last-saved time) and lets you pick one to continue or name a new one. (Saves from before slots existed, at the old flat saves/<story_id>.json path, are migrated into the default slot automatically.)

Each story also keeps an endings gallery across all its slots: reaching an ending shows "Endings found: 2/4", and --list (or the slot picker) names the ones you've found without spoiling the rest. It's stored as saves/<story_id>/found-endings.txt; ending names come from the -- ENDING: Name -- line at the end of an ending scene's text. The browser version shows the same gallery in its slot menu.

saves/ is the repo's own folder when you run from a checkout (as setup.sh does). An installed copy (pip install ., or later from PyPI) keeps its saves in your user data directory instead, e.g. ~/.local/share/sidechannel/saves/ on Linux. If a story was edited so that a save's scene no longer exists, continuing explains that and offers to restart the slot.

Each slot also gets a real sandbox directory on disk (saves/<story_id>/<slot>_sandbox/hosts/<host_id>/) -- every file a story's network.yaml describes for a host is materialized there as an actual file, and cat/ls/grep/set/systemctl/decrypt read and write those real files rather than an in-memory simulation. Continuing a slot reuses its sandbox as-is (so anything you've edited via set stays edited); restarting a slot wipes it back to the story's original files.

The game itself then runs full-screen as a three-pane Textual app (sidechannel/tui.py): a story pane (pure narration -- scene text and choice echoes) with a small choices pane underneath it (narrative scenes -- arrow keys + Enter), and a terminal pane filling the rest of the screen (a command's echo/output log paired with the input line, terminal scenes).

In-game, at any point during a terminal scene you can type:

  • :save -- save your progress
  • :quit / :exit -- save and quit
  • Ctrl+S saves and Ctrl+Q saves and quits from anywhere, narrative scenes included

The terminal input behaves like a small shell: Up/Down walk your command history, Tab completes command names, hosts, paths, services, contacts and topics (listing the candidates when there's more than one), quotes group words into one argument (grep "failed login" /var/log/syslog), and help <command> prints that command's usage.

The current slot is also autosaved every time you cross into a new chapter, so a crash or an accidental quit never costs you more than the current chapter's progress.

Run the test suite with .venv/bin/pip install -e ".[test]" && .venv/bin/pytest (the TUI tests drive the Textual app headlessly via pytest-asyncio + App.run_test(), no real terminal needed).

Playing in a browser

The same game runs in a browser with nothing to install: https://derd1ngs.github.io/sidechannel/ (deployed from main by .github/workflows/pages.yml). The unchanged Python engine runs client-side in Pyodide (CPython compiled to WebAssembly); the first visit downloads about 10 MB. After that one visit the game works offline: a service worker (web/sw.js) keeps the game and the Python runtime, and the slot menu says so once it's ready. Saves are kept in the browser's IndexedDB, so they're per browser and don't mix with the terminal version's saves/. Each slot has an Export button that downloads it as one JSON save file: the game state plus the slot's whole sandbox, set edits and mail included. Import a save file… brings it back, in any browser. The file is fully validated before anything is written: right story, known scene, no paths outside the sandbox.

It plays like the TUI -- story, choices and terminal panes, Tab completion, Up/Down history, Ctrl+S -- with three browser-specific touches: number keys pick a choice; on phones and other touch or narrow screens, ⇥ ↑ ↓ buttons next to the input stand in for the Tab and arrow keys that on-screen keyboards lack; and a Mail button (in terminal scenes, or typing mail compose) opens a form instead of writing a draft file by hand for mail sync. It writes the draft into the slot's mail/draft/ directory and runs mail sync, so matching and bouncing follow exactly the same rules.

To run it locally:

python3 web/build.py                     # writes _site/
python3 -m http.server -d _site 8000     # then open http://localhost:8000

web/ holds the page (index.html, app.js, style.css) and build.py, which zips only what the browser runs -- engine/, stories/ and sidechannel/web_bridge.py, the JSON facade over GameSession that app.js calls.

web/e2e/e2e.mjs plays both stories in headless Firefox against a built site and runs in CI (the web-e2e job). It checks:

  • menus, choices and the terminal: Tab, history, pipes, ssh password masking and the procedure puzzle;
  • saves surviving a reload, and moving a save to a second browser with export/import;
  • the phone layout;
  • that no console errors occur.

On failure, CI uploads its screenshots as an artifact. To run it locally against the server above:

cd web/e2e && npm ci && npx playwright install firefox
node e2e.mjs http://localhost:8000/

How a story is put together

A story lives in sidechannel/stories/<story_id>/ and has:

story_dir/
  manifest.yaml     # id, title, start scene, list of chapter files
  chapters/
    chapter_01.yaml # one or more chapters -- a scene graph each
  network.yaml       # (optional) the virtual hosts the fake terminal exposes
  npcs.yaml           # (optional) chat/email contacts

Story files are loaded strictly. An unknown key (a typo like requries:) or an invalid value (a journal category tracee, a scene type, an NPC channel) is a load error that names the file, scene and choice, and suggests the closest valid key. Scenes must also be consistent: a terminal scene needs its terminal block, an ending has no choices, and a narrative scene without choices would be a dead end.

A story can be a single short chapter, a few (like Zero Day's three), or many chapters spanning a long, non-linear investigation -- the engine doesn't distinguish between the two. Scenes reference each other as chapter_id:scene_id, so a lead planted in chapter 3 can pay off in chapter 12.

manifest.yaml

id: zero_day
title: "Zero Day"
start: "chapter_01:intro"   # must be "chapter_id:scene_id"
chapters:
  - chapter_01.yaml

Chapters and scenes

Each chapter file is a list of scenes:

id: chapter_01
scenes:
  - id: intro
    type: narrative        # narrative | terminal | ending
    text: |
      Your story text. Rich markup ([bold]...[/bold]) is supported.
    choices:
      - text: "Do the thing"
        next: some_scene            # same chapter, or "other_chapter:scene_id"
        requires:                    # optional gate
          flag: some_flag
        sets:                        # optional effects on success
          some_flag: true
          trust.ghost: 1             # "trust.<npc_id>" adjusts trust instead of a flag
          tool.sniffer: true         # "tool.<tool_id>" grants a tool (false takes it away)
        logs:                        # optional journal entries
          - id: lead_1
            category: lead           # lead | trace | suspect | note
            text: "What the player learned."

requires supports: flag, flag_equals: {key, value}, tool (granted with tool.<id>: true in any sets), journal_has: <entry_id>, trust_at_least: {npc, value}, and the combinators all: [...], any: [...] and not: {...}, which nest. Every key in a block must hold, so a plain block is an implicit all:

requires:
  flag: found_archive_report
  not: {any: [{flag: reported_t}, {flag: trusted_t}]}

Because choices are gated rather than strictly sequential, a hub scene that offers several requires-gated leads (looping back to itself or to a menu scene) is how you build a non-linear, "follow whichever thread you want" investigation without any special engine support -- see the forward- looking design note in the project's plan file for the long-form game this was built to support.

Terminal scenes

  - id: gateway_shell
    type: terminal
    text: "You're in."
    terminal:
      host: gateway        # optional: auto-connect to this host on entry
      win_flag: netmon_fixed   # scene advances once this flag is set
      next: discovery
      logs: [...]           # logged once the scene is solved
      hints:                # optional: revealed one at a time by `hint`
        - "The dead service is netmon; its config lives under /etc/netmon/."
        - "`set /etc/netmon/netmon.conf bind_address 0.0.0.0`, then restart it."

Order hints from a gentle nudge to nearly the answer. hint reveals the next one ("Hint 2/3: ..."), and once all are shown it lists them again; how many a player has seen is saved per scene. Every shipped terminal scene has hints, and tests/test_hints.py plays each story solving every terminal scene using only the commands its hints spell out in backticks -- so a hint that stops working after a story edit fails CI.

win_flag is set by whichever shell command solves the puzzle (a service's on_fix_flag, a cipher file's on_success_flag, a host's on_connect_flag -- see network.yaml below), not by the terminal block itself -- with one exception: a procedure puzzle lists the exact commands to run, in order, and the scene sets its own win_flag once the player's most recent commands in it match that sequence (whitespace-normalized; any other command in between breaks the run):

    terminal:
      win_flag: db_recovered
      next: aftermath
      ordered_commands:
        - systemctl status replica
        - set /etc/db/replica.conf mode primary
        - systemctl restart replica

A terminal block can also carry a trace meter, trace: {limit: 8, on_trace: caught}. Every command that touches a host (scan, connect, ssh, ls, cd, cat, head, tail, grep, find, set, systemctl, decrypt) raises it, and the terminal shows [trace 3/8]. Local commands (help, man, hint, status, journal, history, clear, chat, mail) are free, so asking for help never costs anything. Reaching the limit without solving the scene drops the connection and moves the story to on_trace, which could be a retry, a setback or an ending. The count is saved, so reloading doesn't reset it; entering the scene anew does. check_story explores the traced outcome too.

If host is omitted, the player stays on whatever host they were last connected to -- connection state persists across scenes and across save/continue.

network.yaml -- the virtual network

hosts:
  gateway:
    address: "10.44.0.1"
    banner: "shown by `scan`"
    requires_to_connect:            # optional gate on `connect`/`ssh`
      flag: some_flag
    on_connect_flag: connected_gateway   # optional: set when `connect`/`ssh` succeeds
    logins:                        # optional: reach this host with `ssh user@host` + password
      ops: "hunter2"               #   (and `connect` refuses it)
    services:
      netmon:
        config_path: /etc/netmon/netmon.conf   # must point at a `config` file below
        required_config:
          bind_address: "0.0.0.0"
          allow_query: "allow"
        on_fix_flag: netmon_fixed    # set when `systemctl restart` validates
    filesystem:
      etc:
        type: dir
        entries:
          netmon:
            type: dir
            entries:
              netmon.conf:
                type: config
                values: {bind_address: "127.0.0.1", allow_query: "denied"}
              README:
                type: text
                content: "a hint file"
          secret.enc:
            type: cipher
            cipher: caesar            # caesar | xor
            ciphertext: "..."
            plaintext: "the answer"    # what a correct `decrypt` must produce
            on_success_flag: found_secret

A successful decrypt writes the plaintext to a real sibling file (same name, .txt suffix -- secret.enc -> secret.txt) rather than printing it inline, so the player then cats it like any other file. A wrong key just prints the garbled result and writes nothing.

Filesystem node types: dir, text, config, cipher.

Shell commands available to the player: help [command], man <command>, hint, whoami, status, history, clear, scan <host>, connect <host>, ssh <user>@<host> (prompts for the password on the next line), disconnect/exit, ls [-a] [path] (dotfiles only with -a), cd <path>, cat <file>, head/tail [-n N] <file>, find [path] [-name pattern], grep <pattern> <file>, set <file> <key> <value>, systemctl status|restart <service>, decrypt <file> <key>, journal/notebook [lead|note|suspect|trace], chat <npc> [topic], mail [list|read <id>|send <npc> <topic>|sync]. Any command's output can be piped into grep (cat /var/log/syslog | grep ssh | grep failed); grep is the only pipe target, and a quoted "|" still counts as a pipe.

There's deliberately no crack-style instant password break. The config-edit-and-restart puzzle (cat a config, set the wrong key, systemctl restart) is the sysadmin-flavored core loop; grep a log and decrypt a cipher round out the puzzle types a story can use (see sidechannel/engine/puzzles.py for the underlying, independently reusable validators), along with ssh logins with a password found somewhere in the story, and ordered_commands procedure puzzles.

npcs.yaml -- chat/email contacts

One shared mechanism for every talkable character (AI advisor, friend, handler, or antagonist) -- a story can define as many as it wants, including several distinct AI-advisor personas the player can choose between.

npcs:
  - id: ghost
    name: "GHOST"
    channel: chat          # chat (sync) | email (async, delayed reply)
    ask_limit: 5            # optional: max asks before they stop responding
    email_delay_scenes: 2   # email only: scenes before a sent question gets a reply
    topics:
      - id: netmon
        prompt: "ask about netmon"
        response: "The response text."
        requires: {flag: some_flag}   # optional gate, same shape as choices
        sets: {...}                    # optional effects, same shape as choices
        logs: [...]
        reliability: truthful          # truthful | misleading | evasive (informational -- write the response text accordingly)

Conversations are topic-based, not free text -- the player sees a menu of currently-available topics (chat <npc> with no topic lists them) and picks one. This keeps every NPC fully scripted and deterministic (no LLM call, no cost, and it's covered by tests/test_dialogue.py) while still supporting an unreliable advisor (reliability: misleading), a friend who needs trust built up first (requires: {trust_at_least: {npc: ..., value: ...}}), and a slow-to-reply email contact -- all through the same data shape a real LLM- backed persona could implement later behind the same ask_topic/ send_topic_by_email call shape, without touching the shell or any existing NPC's content.

Mail as real files

Email works three ways. mail send <npc> <topic> is the guided path -- exact topic id, no filesystem involved, same as chat. mail compose opens a form (To, Subject, message) in the TUI and the browser; it writes the draft for you and runs mail sync, so it follows the same matching and bounce rules as the real-file path below. mail sync is the real-file path: outside the game, in the slot's sandbox directory (saves/<story_id>/<slot>_sandbox/mail/draft/), write a plain text file with To:/Subject: headers and a body, e.g.

To: t
Subject: cold storage backup passphrase?

Saw in the access log you re-keyed it. Any chance you remember it?

then run mail sync in-game. It matches the Subject: line against any email topic that declares outbox_match: {subject_contains: "..."} (a loose, case-insensitive keyword match, not an exact topic id -- the player writes a real message in their own words) on the recipient NPC, subject to the same requires/ask_limit gating chat/mail send already enforce. A match moves the draft to mail/sent/; no match renames it in place with a .bounced suffix and a [bounced] <reason> line prepended, so it's always clear what happened rather than the file just vanishing. Delivered replies still arrive automatically on the same scene-count delay as always, and now also land as real files in mail/inbox/ (mail list/mail read still work too, reading from the same underlying state).

Checking a story for structural bugs

.venv/bin/python -m sidechannel.tools.check_story zero_day   # one story
.venv/bin/python -m sidechannel.tools.check_story --all      # every shipped story
.venv/bin/python -m sidechannel.tools.check_story dead_drop --graph   # Mermaid scene graph

Explores every reachable combination of choices via the real engine (not a separate reimplementation) to report scenes and endings that can never be reached, and terminal scenes whose win_flag is never actually set by anything in that story's network.yaml/npcs.yaml -- the classic typo between two files that's easy to introduce and hard to spot by reading either file alone. tests/test_story_reachability.py runs this against every shipped story as part of the normal test suite, so a broken story fails CI the same way a broken test would. See sidechannel/tools/check_story.py's module docstring for how the search works and what it deliberately doesn't model (e.g. an NPC's ask_limit running out isn't factored into reachability).

It also lints references across all of a story's files. These are problems, which fail the check:

  • a flag some requires reads (in a choice, a topic or a host) that nothing ever sets;
  • a journal_has id that nothing ever logs;
  • a tool that nothing ever grants.

A flag that is set, or a tool that is granted, but never read is only a warning: harmless, but usually a leftover or a typo.

--graph prints the story's scene graph as a Mermaid flowchart instead of checking it. Paste it into a ```mermaid block in any GitHub Markdown file (or the Mermaid live editor) to see it drawn:

  • narrative scenes are boxes, terminal scenes double-bordered boxes and endings rounded;
  • choices are arrows labelled with their text, dashed when gated by requires;
  • a terminal scene's exit is a thick arrow labelled with its win_flag.

Project layout

sidechannel/engine/ holds the engine modules (story.py, shell.py, dialogue.py, journal.py, state.py, puzzles.py, savefile.py for the export/import format) -- pure game logic with no UI dependency -- plus session.py, whose GameSession is the game loop itself (choices, commands, scene transitions, mail delivery, autosave) that any frontend drives (GameSession.open starts or continues a save slot), and loader.py, which finds stories and save slots on disk. The engine needs only PyYAML -- tests/test_engine_is_ui_free.py keeps Textual and Rich out of it. sidechannel/tui.py is the split-pane Textual frontend that renders it; main.py is just the pre-flight story/save picker that hands off to it. sidechannel/tools/ holds check_story.py, the structural story validator described above. sidechannel/stories/ holds the three shipped stories as complete worked content examples: story_01_zero_day/ for the core features, story_02_dead_drop/ for ssh logins, pipes, ordered_commands and requires combinators, and story_03_night_shift/ for tool grants and the trace meter.

License

Side Channel is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version (SPDX: GPL-3.0-or-later). See LICENSE for the full text.

Release files for sidechannel 1.0.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for sidechannel 1.0.1
File Size Uploaded
sidechannel-1.0.1.tar.gz 126.4 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for sidechannel 1.0.1
File Interpreter ABI Platform
sidechannel-1.0.1-py3-none-any.whl Python 3 none any Details

Total release size: 220.8 kB

Release files / sidechannel-1.0.1.tar.gz

Download URL sidechannel-1.0.1.tar.gz
Size 126.4 kB
Tags Source
SHA-256 checksum
How to use checksums
b09eb848bc6f6d50e09086c903d31f652c4c11f92f0bc1eac9b446d11837a4f4
BLAKE2b-256 checksum
How to use checksums
136f6e2c677d98bb342e1af588a9def1e2e4cf62dc200d72236c42f51c9ab14a
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 27, 2026.

Transparency log

Release files / sidechannel-1.0.1-py3-none-any.whl

Download URL sidechannel-1.0.1-py3-none-any.whl
Size 94.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
6442206dcb117dd6b6bd0f17f14c00b0563e1bbb902f1f87d3097950bce440c4
BLAKE2b-256 checksum
How to use checksums
8fecfa65a60486db6a4138700ac0c91508fcb47ca44b81b97fc4a761f00d0331
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 27, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

1.0.1 This release

2 release files

1.0.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page