
Bare Metal Gaming on Linux: Steam from a TTY Without a Desktop
How I set up desktop-free gaming on Pop!_OS with an RTX 3080 Ti — every failure, every fix, and the launcher script that finally made it work.
Table of Contents
Why Should You Care?
My workstation runs local AI inference — 69 Ollama models, CUDA compute, the works. When I want to play Noita, every GPU cycle the COSMIC desktop compositor is using is a cycle my game isn’t. The fix should be simple: ditch the desktop entirely, drop to a raw TTY, and launch Steam directly into Xorg with nothing else running.
It took four hours to make that “simple” idea actually work. Here’s everything that broke and why, plus the scripts that came out the other side.
The Goal
Play games from a raw Linux TTY — no GNOME, no KDE, no COSMIC, no compositor, no desktop session manager. Just:
TTY login → startx → Steam → game → exit → clean terminal
Full GPU to the game. No shared memory with a desktop. Works alongside long-running inference sessions on the CPU side.
Dead End #1: cage (Wayland Compositor)
My first attempt used cage, a minimal single-application Wayland compositor. The appeal is obvious — Wayland is the future, NVIDIA supports it now, and cage is purpose-built for kiosk/bare-metal scenarios.
$ cage steam -bigpicture
SDL_VIDEODRIVER=wayland
[cage] starting compositor
libEGL warning: MESA-LOADER: failed to open nouveau
[cage] EGL initialization failed
SDL_Init failed: No available video device
The problem: NVIDIA’s proprietary driver stack and Wayland have a specific initialization path that requires a running desktop session for the environment setup cage doesn’t provide. cage works well with Mesa/nouveau or on machines that aren’t running NVIDIA proprietary. On a system locked to nvidia-drm.modeset=1 for CUDA, it falls apart.
Wayland is off the table for now. Back to Xorg.
Dead End #2: Flatpak Steam from a TTY
Pop!_OS ships Steam as a Flatpak by default. Running it from a bare TTY produces:
$ startx /usr/bin/flatpak run com.valvesoftware.Steam
bwrap: loopback: Failed to create new loopback for /run/user/1000:
Permission denied
error: app/com.valvesoftware.Steam not installed for the user
xdg-desktop-portal: D-Bus session bus not available
Flatpak sandboxing depends on several system services that only exist inside a proper desktop session: xdg-desktop-portal, a running D-Bus session bus (DBUS_SESSION_BUS_ADDRESS), and systemd --user socket activation. On a bare TTY, none of those are present.
The solution is native (non-Flatpak) Steam. On Pop!_OS:
sudo apt install steam
This installs Steam to /usr/games/steam. No sandbox, no portal dependency, launches fine from a bare Xorg session.
Dead End #3: Missing video Group
With native Steam installed, startx itself failed before Steam ever launched:
$ startx ./xinitrc-test -- vt2
Fatal server error:
(EE) Cannot open /dev/fb0 (Permission denied)
(EE)
(EE)
Please consult the The X.Org Foundation support
at http://wiki.x.org
for help.
(EE) Please also check the log file at "/var/log/Xorg.0.log" for additional information.
(EE) Server terminated with error (1). Closing log file.
Xorg needs access to /dev/fb0 and /dev/dri/card* for direct rendering. These devices belong to the video and render groups:
$ ls -la /dev/fb0 /dev/dri/card0
crw-rw---- 1 root video 29, 0 May 2 14:33 /dev/fb0
crw-rw---- 1 root video 226, 0 May 2 14:33 /dev/dri/card0
My user wasn’t in either group:
$ groups
danko adm cdrom sudo dip plugdev lpadmin sambashare
Fix:
sudo usermod -aG video,render danko
# Log out and back in (or use newgrp video for the current session)
After re-login, Xorg could open the devices and startx succeeded.
Dead End #4: Keyboard Not Working In-Game
Xorg started, Steam launched, Noita opened — and then I couldn’t move. Mouse worked, keyboard was dead. No WASD, no escape, no anything.
The minimal xinitrc I’d written had nothing in it except the Steam launch command. Xorg provides no keyboard layout by default; without an explicit setxkbmap call, the X server initializes with an empty keymap.
# What I had:
exec /usr/games/steam -silent steam://rungameid/881100
# What I needed:
setxkbmap -layout us -model pc105
exec /usr/games/steam -silent steam://rungameid/881100
One line. Four hours of debugging to get there.
Dead End #5: Steam Can’t Update (IPv6 Hotspot)
During testing I was on a mobile hotspot — IPv6 only. Steam’s updater is 32-bit and uses IPv4 exclusively. The result:
Updating Steam...
[---- ]
Error: http error 0
“http error 0” is Steam’s way of saying it couldn’t establish a TCP connection at all. The 32-bit Steam bootstrap binary doesn’t understand IPv6 addresses, so it never even tried.
The workaround for testing was to use steam -noverifyfiles to skip the update check. On a proper dual-stack or IPv4 connection this isn’t an issue, but it’s worth knowing if you’re ever debugging Steam from a TTY and it just hangs at update.
What Works: startx + Native Steam + Minimal xinitrc
The working stack:
- Native Steam (
/usr/games/steam) — no Flatpak sandbox, no portal dependency startxwith an explicit VT number — ties the X session to your current TTY- Minimal xinitrc — keyboard layout, screen blanking disabled, then Steam
-- vt${N}argument tostartx— ensures X doesn’t steal a VT at random
The full launcher script I settled on is at ~/tools/noita-tty.sh.
The Launcher Script
#!/bin/bash
# Launch Noita (or Steam) from a TTY using a minimal X11 session.
# No desktop environment needed — just GPU + Xorg + native Steam.
#
# Usage from any TTY:
# noita # Launch Noita directly
# steam # Launch Steam Big Picture
#
# Switch VTs: Ctrl+Alt+F1/F2/F3 etc.
set -euo pipefail
NOITA_APPID=881100
STEAM_BIN=/usr/games/steam
if [ -n "${DISPLAY:-}" ] || [ -n "${WAYLAND_DISPLAY:-}" ]; then
echo "ERROR: You're already in a graphical session."
echo "Kill COSMIC first (gpu-mode), then run from a TTY."
exit 1
fi
if [ ! -x "$STEAM_BIN" ]; then
echo "ERROR: Native Steam not found at $STEAM_BIN"
exit 1
fi
touch "$HOME/.Xauthority"
MODE="${1:-noita}"
case "$MODE" in
steam)
STEAM_ARGS="-bigpicture"
echo "Launching Steam Big Picture..."
;;
noita)
STEAM_ARGS="steam://rungameid/$NOITA_APPID"
echo "Launching Noita..."
;;
[0-9]*)
STEAM_ARGS="steam://rungameid/$MODE"
echo "Launching Steam app $MODE..."
;;
*)
echo "Usage: $0 [noita|steam|<appid>]"
exit 1
;;
esac
XINITRC=$(mktemp /tmp/xinitrc-game.XXXXXX)
cat > "$XINITRC" << XEOF
#!/bin/sh
sleep 1
/usr/bin/setxkbmap -layout us -model pc105
xset s off -dpms
xset r rate 250 30
$STEAM_BIN -silent -no-browser $STEAM_ARGS
XEOF
chmod +x "$XINITRC"
trap "rm -f '$XINITRC'" EXIT
VTNUM=$(tty | grep -o '[0-9]*$')
startx "$XINITRC" -- -quiet "vt${VTNUM}" -xkblayout us -xkbmodel pc105
A few decisions worth noting:
The temporary xinitrc. The script creates a new xinitrc on each run via mktemp. This means the Steam launch command is baked into the file at runtime, which lets the case statement cleanly pick between Noita, Big Picture, or an arbitrary App ID without needing multiple scripts.
xset s off -dpms. Disables the screen blanker and DPMS power management. Without this, your monitor goes to sleep mid-game.
xset r rate 250 30. Sets keyboard repeat: 250ms delay before repeat starts, 30 repeats per second. Without this, held keys in a game feel sluggish.
-no-browser. Suppresses Steam’s built-in browser window on startup. Fewer resources, cleaner session.
vt${VTNUM}. Xorg starts on the same VT you’re logged into. This is important — if you omit it, X grabs a new VT and Ctrl+Alt+F{N} switching gets confusing.
Aliases that wire it up:
# ~/.bashrc
alias noita='~/tools/noita-tty.sh noita'
alias steam='~/tools/noita-tty.sh steam'
alias steam-kill='pkill -f "steam|steamwebhelper|reaper" 2>/dev/null; pkill Xorg 2>/dev/null; true'
TTY Recovery Tools
When something crashes mid-game, you can end up with a frozen TTY, a stale X lock file, and Steam processes still holding GPU memory. The recovery script handles all of it.
#!/bin/bash
# Reset a frozen TTY by killing all processes on it.
# agetty respawns automatically, giving a fresh login prompt.
#
# Usage:
# tty-reset 5 # Reset tty5
# tty-reset 5 6 # Reset tty5 and tty6
if [ $# -eq 0 ]; then
echo "Usage: tty-reset <tty#> [tty# ...]"
echo "Example: tty-reset 5 6"
echo ""
echo "Current TTY usage:"
who
exit 1
fi
for N in "$@"; do
DEV="/dev/tty${N}"
if [ ! -e "$DEV" ]; then
echo "tty${N}: device not found"
continue
fi
PIDS=$(sudo fuser "$DEV" 2>/dev/null | tr -s ' ')
if [ -z "$PIDS" ]; then
echo "tty${N}: already free"
continue
fi
echo "tty${N}: killing PIDs${PIDS}"
for PID in $PIDS; do
COMM=$(ps -p "$PID" -o comm= 2>/dev/null)
# Don't kill systemd-logind or agetty
if [ "$COMM" = "systemd-logind" ] || [ "$COMM" = "agetty" ]; then
continue
fi
sudo kill "$PID" 2>/dev/null
done
sleep 1
# Force-kill anything still hanging
REMAINING=$(sudo fuser "$DEV" 2>/dev/null | tr -s ' ')
for PID in $REMAINING; do
COMM=$(ps -p "$PID" -o comm= 2>/dev/null)
if [ "$COMM" = "systemd-logind" ] || [ "$COMM" = "agetty" ]; then
continue
fi
sudo kill -9 "$PID" 2>/dev/null
done
sleep 1
echo "tty${N}: reset"
done
# Clean stale X locks while we're at it
sudo rm -f /tmp/.X*-lock /tmp/xinitrc-game.* /tmp/serverauth.* 2>/dev/null
fuser finds all PIDs with an open file handle on /dev/tty{N}. The script skips systemd-logind and agetty — killing either of those would break your login infrastructure and require a reboot. Everything else gets SIGTERM, then SIGKILL if it’s still alive after one second.
The cleanup at the end removes stale X lock files (/tmp/.X*-lock). These are what cause the startx: error for display :0 error you get if you try to launch X again after a hard crash without cleaning up first.
The Full Workflow
With everything wired up, the workflow is clean:
# 1. Switch to a free TTY — Ctrl+Alt+F3 (or F4, F5, whatever's open)
# 2. Log in
# 3. Launch the game
$ noita
Launching Noita...
# X starts, Steam launches silently, Noita opens fullscreen
# Play until done, then quit Noita normally
# Steam exits, X shuts down, you're back at the TTY prompt
# 4. If Steam doesn't clean itself up:
$ steam-kill
# 5. If the TTY is frozen and you need to recover from a different TTY:
$ tty-reset 3
tty3: killing PIDs 48291 48334 48891
tty3: reset
Switching back to COSMIC is just Ctrl+Alt+F1 (or wherever your desktop session lives).
The GPU Difference
This is the part worth quantifying. With COSMIC running, nvidia-smi shows:
+-----------------------------------------------------------------------------+
| Processes: |
| GPU GI CI PID Type Process name GPU Memory |
| ID ID Usage |
|=============================================================================|
| 0 N/A N/A 2341 G /usr/lib/xorg/Xorg 312MiB |
| 0 N/A N/A 2487 G ...sktop/cosmic-comp 89MiB |
+-----------------------------------------------------------------------------+
About 400MB of VRAM committed to the desktop stack before the game even starts. On a 12GB card that’s not catastrophic, but it’s VRAM that isn’t available for game assets, and compositor scheduling introduces frame timing jitter that pure fullscreen doesn’t have.
From the TTY, that block is empty. The game gets the full card.
What You Learned
cage(minimal Wayland compositor) fails with NVIDIA proprietary drivers on bare TTY — the initialization path requires a desktop session that cage doesn’t set up- Flatpak apps depend on
xdg-desktop-portaland a D-Bus session bus; neither exists on a bare TTY, so native packages are required - Xorg needs the
videoandrendergroups for/dev/dri/card*access — missing groups produce confusing “permission denied” errors that look like Xorg config problems - X starts with no keyboard layout unless you explicitly call
setxkbmapin your xinitrc - Pass
-- vt${N}tostartxwhereNis your current TTY number — otherwise X grabs a random VT and recovery gets complicated fuser /dev/tty{N}lists all PIDs on a given terminal; skipsystemd-logindandagettywhen killing them or you’ll need a reboot