Tutorial 139: The Terminal Platform
What you’ll learn: how to build the 2D demo for the POSIX terminal platform, run it, read its diagnostics, what keyboard and mouse fidelity to expect, and what the CI pseudo-terminal test covers.
Before you start — Tutorial 03: Your First CNA Window for the project layout, and Tutorial 135 if you want the SDL-free variant. You need a POSIX machine (Linux, macOS, BSD) and a terminal emulator. The terminal platform is not offered on Windows.
Most CNA platforms give your game a window. CNA_PLATFORM=TERMINAL gives it your terminal: no display server, no GPU, no window. The software renderer's finished frame is turned into a character grid and written to stdout as ANSI colour escape sequences, keys arrive from stdin, and the mouse works at cell granularity. It is useful over SSH, inside tmux, in a container, and as a proof that CNA's platform contract is not shaped like SDL. This tutorial builds the 2D demo for the terminal and runs it.
What the terminal platform is
| Aspect | Terminal platform |
|---|---|
| Offered | Every non-Windows target (built on POSIX termios/poll). TERMINAL on Windows is refused as reserved. It is always compiled in on POSIX builds, so PlatformFactory::GetAvailable() lists Terminal even when it is not the default. |
| Renderers | Only CPU renderers: SOFTWARE, PORTABLEGL, HEADLESS, STUB. Any GPU renderer is refused at configure time. Of these, only SOFTWARE draws: it hands each finished RGBA frame to the platform's surface presenter. The others produce nothing visible. |
| Output | Alternate screen buffer, hidden cursor, one glyph per cell drawn in one foreground colour (no background colour is written, and no colour at all in monochrome), restored on exit. Colour depth is taken from COLORTERM/TERM (true colour, 256, 16, or monochrome). The presenter diffs against the previous frame and keeps a measured byte budget: on a slow link it drops frames rather than blocking your game. |
| Keyboard | The Kitty keyboard protocol is probed at start-up; when the terminal supports it, key state is exact (press, repeat and release). Otherwise keys arrive as traditional escape sequences, which have no release information, and exactKeyboardState is reported false. |
| Mouse | SGR extended reporting: buttons, motion, wheel. Positions are in character cells, so pixelAccurateMouse is false. |
| Window | One “window”: the terminal viewport. Resizing the terminal (SIGWINCH) resizes the back buffer. |
| Not available | Gamepads, clipboard, dialogs, IME, HiDPI, multiple windows, GL/Vulkan surfaces. |
The presenter only exists when stdout is a real terminal. If you redirect stdout to a file or pipe, the platform reports no presentation and the game runs but draws nothing — it never writes escape sequences into a log.
1. Configure and build
cd cna
cmake -S . -B build-terminal -G Ninja -DCMAKE_BUILD_TYPE=Release \
-DCNA_PLATFORM=TERMINAL \
-DCNA_AUDIO_PLATFORM=NULL \
-DCNA_GRAPHICS_RENDERER=SOFTWARE \
-DCNA_BUILD_TESTS=OFF \
-DCNA_ENABLE_NET=OFF
cmake --build build-terminal --target cna_demo_2d --parallel
This is the selection CI uses for its terminal cell (with tests on and SDL still configured). The renderer is SOFTWARE because a terminal has no native window for a GPU API to bind to. Audio is NULL so the build stays independent of any sound system. Because nothing here needs SDL, you can also add -DCNA_ENABLE_SDL=OFF (Tutorial 138); by CNA's selection logic this is permitted, but CI configures the terminal cell with SDL enabled, so we did not treat it as proven.
Try the wrong renderer to see the refusal:
cmake -S . -B build-bad -DCNA_PLATFORM=TERMINAL -DCNA_AUDIO_PLATFORM=NULL -DCNA_GRAPHICS_RENDERER=OPENGLES3
# CNA: CNA_PLATFORM=TERMINAL has no native graphical window, so renderer OPENGLES3 cannot be selected.
# Choose a CPU renderer: SOFTWARE, PORTABLEGL, HEADLESS, STUB.
2. Run it
cd build-terminal
./cna_demo_2d 2>terminal-diagnostics.txt
Run it in a real terminal, and preferably a large one (100×30 or more) with true-colour support. You should see the sprite demo drawn with coloured character cells. Press Esc to quit — the demo's Update exits on Escape on every platform, and on a terminal that is the live keyboard path. Your shell should come back exactly as it was: normal screen, visible cursor, echo on. (If a crash ever leaves the terminal in a strange state, reset restores it.)
Stderr is redirected because the frame goes to stdout; anything else written there would corrupt the picture. After the game exits, the presenter writes one diagnostic line to stderr:
cat terminal-diagnostics.txt
CNA terminal diagnostics: colour=TrueColour grid=120x40 dropped_frames=0 kitty_keyboard=yes
(The exact values depend on your terminal; colour is one of Monochrome, Ansi16, Indexed256 or TrueColour.) grid is the cell grid the game saw, dropped_frames counts frames skipped by the byte budget (non-zero means your link could not keep up, typically over a slow SSH connection), and kitty_keyboard says whether exact key state was available. For a short scripted run, ./cna_demo_2d --smoke 600 exits by itself after 600 frames.
Blank screen? Check that stdout is a terminal (not redirected), that you built with CNA_GRAPHICS_RENDERER=SOFTWARE (PORTABLEGL, HEADLESS and STUB draw nothing on a terminal), and that your terminal is large enough.
3. Input on a terminal
Your game code is the same as everywhere else. This small game exits on Escape and would move things with any other Keys value; it compiles against this snapshot's headers:
#include <memory>
#include "Microsoft/Xna/Framework/Color.hpp"
#include "Microsoft/Xna/Framework/Game.hpp"
#include "Microsoft/Xna/Framework/GraphicsDeviceManager.hpp"
#include "Microsoft/Xna/Framework/Input/Keyboard.hpp"
#include "Microsoft/Xna/Framework/Input/Keys.hpp"
using namespace Microsoft::Xna::Framework;
using namespace Microsoft::Xna::Framework::Input;
// A game that runs unchanged on SDL3, X11, Wayland, Win32 and the terminal.
class TerminalDemo final : public Game {
public:
TerminalDemo() : graphics_(this) {
getWindowProperty().setTitleProperty("Terminal demo");
}
protected:
void Update(GameTime&) override {
if (Keyboard::GetState().IsKeyDown(Keys::Escape)) {
Exit();
}
}
void Draw(const GameTime&) override {
auto& device = getGraphicsDeviceProperty();
device.Clear(Color::CornflowerBlue);
// No device.Present(): Game presents in EndDraw, after Draw() returns.
}
private:
GraphicsDeviceManager graphics_;
};
int main() {
TerminalDemo game;
game.Run();
return 0;
}
What differs from a windowed platform is fidelity, and CNA reports it honestly through capabilities instead of pretending:
- Without the Kitty keyboard protocol a terminal sends a key press byte (and auto-repeat), never a release. “Is the arrow key held?” then cannot be answered exactly;
exactKeyboardStateis false. Games that need exact held-key state should check the capability or expect stutter (initial repeat delay) and late releases. - Modifier keys are not independently observable on legacy terminals. While a terminal session is active (the whole run of a
SOFTWAREgame on a TTY) the platform puts the terminal in raw mode, clearingISIGandIXON, so Ctrl+C/Z/S arrive as ordinary control-chord key events rather than as signals or flow control; Ctrl+C does not interrupt the game (read from source, not executed). - The mouse reports cell positions expanded to that nominal 8×16 grid, which is fine for grid games and useless for pixel-precise pointing.
- No escape sequence reports a cell's pixel size, so the platform assumes a nominal 8×16-pixel cell (the classic text-cell ratio of 1:2) and letterboxes the frame on that assumption. The window keeps the size the game asked for until the first terminal resize (
SIGWINCH); from then on it iscolumns × 8byrows × 16pixels.
4. What CI covers
The platform workflow has a terminal cell in its matrix (TERMINAL + SOFTWARE + NULL audio) and a pseudo-TTY integration test, TerminalSoftwareDemoIntegration. It runs the real cna_demo_2d on a pseudo-terminal, waits for it to enter the alternate screen, resizes the terminal and sends SIGWINCH (checking the next frame reports the new grid, 100x30), feeds a key and then Escape, requires a clean exit, and checks the transcript for the alternate-screen enter and leave sequences and the diagnostics line. The platform's own unit tests, including 36 pseudo-TTY presenter tests, run in CnaPlatformTests. To run the integration test yourself, configure with tests and examples on and Python 3 available:
cmake -S . -B build-terminal-test -G Ninja -DCNA_PLATFORM=TERMINAL \
-DCNA_AUDIO_PLATFORM=NULL -DCNA_GRAPHICS_RENDERER=SOFTWARE \
-DCNA_BUILD_TESTS=ON -DCNA_BUILD_EXAMPLES=ON -DCNA_ENABLE_NET=OFF
cmake --build build-terminal-test --target cna_demo_2d --parallel
ctest --test-dir build-terminal-test -R '^TerminalSoftwareDemoIntegration$' --output-on-failure
What CI does not establish: behaviour in any particular terminal emulator (it drives a pseudo-terminal, not xterm, kitty, GNOME Terminal or Windows Terminal), colour fidelity, or bandwidth behaviour over a real network.
5. Where it is useful
- Over SSH or in a container where there is no display server: run the same game and look at it.
- CI smoke tests that need to look at frames without a virtual X server. (Use
HEADLESSwhen nothing should be shown at all.) - Reading frames back: the
SOFTWARErenderer holds its RGBA framebuffer, soGetBackBufferData()works on any platform for pixel checks.
Troubleshooting
| Symptom | Cause and fix |
|---|---|
| Configure fails: “CNA_PLATFORM=TERMINAL has no native graphical window” | Pick SOFTWARE (or another CPU renderer). |
Configure fails: “reserved identifier that is NOT implemented” for TERMINAL | You are targeting Windows; the terminal platform is POSIX-only. |
| Blank screen or no output | stdout is not a terminal, or the renderer is not SOFTWARE. |
| Garbled output or a broken shell afterwards | Something else wrote to stdout, or the process was killed hard. Run reset. Keep logging on stderr. |
| Monochrome or 16-colour output | The environment claims little colour: set COLORTERM=truecolor if your terminal supports it, and check TERM. |
dropped_frames is large | The terminal (or SSH link) cannot take the bytes: use a smaller window or a local terminal. Dropping is by design. |
Where to go next
- Tutorial 138: Building Without SDL
- Tutorial 127: Choose Platform, Renderer, and Audio Independently
- Platform Support — the seven platform implementations and the capability matrix.