ShellGame is a Click application that runs inside a controlled bash or fish subshell. Python owns all game rules and persistence; shell templates only forward events and apply a small, versioned command protocol.
shellgamecreates application services and discovers level modules.- Outside a wrapper,
boot_if_needed()launches bash or fish with the generated integration script. - Inside the wrapper,
GameSessionloads or creates state, prepares the current level, renders instructions, exports shell context, and teleports when the level declares a start directory. - User commands invoke a new Python process through the shell wrapper.
- State-changing actions are persisted with one atomic write.
| Area | Module | Responsibility |
|---|---|---|
| Click interface | cli/commands.py |
Parse commands and delegate to GameSession |
| Orchestration | core/session.py |
Initialization, display, hints, submit, reset, developer actions |
| Navigation | core/navigation.py |
Resolve level start directories and emit teleport requests |
| Shell launch | cli/subshell.py |
Render templates, detect shell, launch wrapper |
| Shell hooks | cli/hooks.py |
Generate bash/fish cd dispatch from level hook metadata |
| Protocol client | shell/client.py |
Emit versioned, base64-encoded operations to stderr |
| Persistence | state/manager.py |
Validation, migrations, atomic state replacement |
| Progress | core/progress.py |
Pure in-memory progress mutations |
| Level lifecycle | levels/base.py |
Preparation, rendering metadata, custom extension points |
| Level registration | levels/collector.py |
Section-local stable IDs, roots, and shared fixtures |
| Completion rules | levels/completion.py |
Typed answers and unconditional gameplay requirements |
| Workspace fixtures | levels/fixture.py |
Safe, repeatable setup and cleanup declarations |
cd policies |
levels/cdpolicy.py |
Declarative movement rules with structural soft-lock protection |
| Reference solutions | levels/solution.py |
Author walkthroughs proving a level is completable |
| Path containment | paths.py |
The one implementation of "stay inside this root" |
| UI | ui/display.py |
Rich panels, paging, notes, prompts |
bash --rcfile <integration_script> -i
Do not add --norc; it disables --rcfile. Do not add --noprofile;
the inherited environment must remain available.
fish --init-command "function fish_greeting; end; source <integration_script>"
Both templates follow the same order: setup, function definitions, generated hooks, wrapper function, autostart.
Python emits protocol lines to stderr:
__SHELLGAME_EXEC__v1 <verb> [base64-argument...]
Only cd, export, echo, pwd, and exit are supported. Templates decode
arguments and dispatch these verbs explicitly; arbitrary shell evaluation is
not part of the protocol. remove remains a wrapper-level success case and
exits the subshell without an exit directive.
Commands that must inspect their inherited file descriptors invoke the
temporary executable referenced by SHELLGAME_FD_HOOK. They must not call the
shell wrapper function, because that function intentionally captures stderr.
- A section declares its persistent number with
Section(number, ...). - Every level declares its stable suffix locally with
@section.level(number); IDs never depend on declaration order. - Section intro behavior is declared with
is_intro. - Start-directory enforcement is declared by the level.
- Regular levels declare a
Completion; only genuinely custom interactions overridevalidate(). Completion.requirementsare unconditional. A correct or empty answer never bypasses filesystem, location, permission, or command-evidence checks.- Navigation uses
AtDirectory, which compares the exact resolved workspace-relative path rather than only the final directory name. - Reusable answer shapes use typed rules such as
ExactAnswer,IntegerAnswer,TupleAnswer, andOrderedListAnswer. - Every level-declared path is relative to that level's section root -
fixtures, requirements,
start_directoryand solution steps all speak one vocabulary, so a level can never disagree with itself about where it lives. A level that starts above its own section says so with theWORKSPACE_ROOTsentinel rather than by overloading"". - All four resolve through
paths.resolve_within(), which rejects absolute paths,..and symlinked parents. It is the single implementation; fixtures, requirements and section roots share it. - Default answer feedback never contains the answer. A rule with
error_message=Nonerejects with the terseMessages.INCORRECT; anything more specific is written deliberately by the author. - Movement restrictions are declared as a
CdPolicy, not hand-written. The engine consultscd_enforcement_lifted()before every rejection and routes rejections throughblock_cd(), so the anti-soft-lock guarantee is structural rather than a convention each author must remember. - A policy's evidence marker is derived from the level ID, so marker names are unique by construction and need no global registry.
- Custom
_handle_cdremains available for genuinely stateful grading (level 1.9 walks from/to$HOMEone segment at a time) and carries the same obligations by hand. - Levels that can silently become unwinnable - custom
validate()orsetup(), acdpolicy, an evidence marker, or a filesystem requirement - must declare aSolution. It is replayed against a throwaway workspace, before and afterreset(). Level.is_bonusisoptional or extension. Only bonus levels may be skipped withshellgame skip; core levels never can.Level.prepare()clears declarative evidence, applies the shared section fixture, applies the level fixture, and only then calls customsetup().- Use custom
setup()only for content that is impractical to declare as a fixture. - Gameplay validation belongs to the level class, not CLI or shell code.
GameSession.submit()shows the level's ownsuccess_message; it never substitutes a generic one.- Advancing performs exactly one atomic save, which also starts the next
level's timer. The save happens before the next level's fixture is applied,
so a failing fixture reports
show_level_setup_errorwithout losing progress. - Finishing the last level sets
completed_at. After that,submit,skip,hint, and showing the current level are inert and only re-display the completion summary. reset()re-exports the shell context and callsensure_user_in_reasonable_place(), so it always rescues a player who wandered out of the level. Fixtures overwrite their files even when the player revoked write permission, so reset works after achmodlevel.- A deleted working directory is a normal, recoverable failure.
Path.cwd()is never called unguarded;paths.current_directory()returnsNoneand rules report it instead of raising. - An unknown
current_levelresyncs to the nearest valid level and keepslevels_complete, so renumbering between versions cannot brick a save.
State files contain a version and are migrated during loading. Missing state
causes initialization; malformed or unsupported state raises an explicit error
and is never treated as missing. Each action mutates state in memory and then
uses os.replace() for one atomic commit.
Command evidence is not state: markers live in the workspace and are named after the level that owns them. Renaming a marker therefore never invalidates a save; at worst a player standing in the middle of that one level repeats a single move.
make lint
make format-check
make test
CI runs these gates on the oldest and newest supported Python versions with bash, fish, and shellcheck available.
Beyond per-feature tests, several suites assert structural invariants that a new level cannot opt out of:
| Suite | Invariant |
|---|---|
test_path_vocabulary.py |
No level restates its section root or builds a path by hand |
test_marker_invariants.py |
Every evidence marker is owned and cleared by exactly one level |
test_cd_hooks_no_softlock.py |
No level that can reject a move lacks soft-lock coverage |
test_solutions.py |
Risky levels stay completable, before and after reset() |
test_level_invariants.py |
Start directories exist; default feedback never reveals an answer |
test_authoring_docs.py |
Every example in docs/AUTHORING.md runs against the real API |
See docs/AUTHORING.md for how to add a level or section.