Skip to content

Building Rust Games for Steam on Linux

Precompiled Linux games are challenging to distribute because the dynamic libraries and versions thereof available on user systems vary widely. Static linking and/or bundling required dynamic libraries alongside with the game can help, but are not a complete solution: glibc, the standard Linux implementation of the C standard library, only supports being dynamically linked from the host system. musl is an alternative libc that supports static linking, but it does not support dynamically loading shared libraries, which is absolutely necessary to use OpenGL or Vulkan. Furthermore, dynamic linking makes it more likely for software to keep working as intended into the future, as insecure or buggy libraries can be seamlessly updated--particularly important for fundamentals like glibc.

Fortunately, glibc maintains strong backwards compatibility guarantees, so in principle one need only build their software and all dependencies against a sufficiently old version. This can be difficult in practice as the total number of transitive dependencies may be large, and end users may be unhappy with many applications all bundling copies of the same common libraries. In recent years, a variety of solutions have been developed, including Appimage, Flatpak, Snaps, and the Steam Runtime. Steam's storefront and familiarity to users make it particularly interesting to game developers.

The Steam Runtime

Valve's solution is, in effect, a carefully maintained Linux distribution, containing a large collection of common libraries on the foundation of a very old glibc, all distributed by Steam. When launching an application through Steam, the current version of the runtime ("Scout") sets LD_LIBRARY_PATH such that Steam Runtime libraries are made visible to the operating system dynamic loader. A future version, "Soldier", will instead use Linux namespaces, a containerization technology, to expose a more strictly controlled runtime environment.

Software built for the Steam Runtime will likely run not only under Steam, but also directly on most FHS-compliant Linux distributions with a similar set of libraries installed. While a mechanism that guarantees a suitable runtime environment should be preferred, this can be useful for informal distribution early in development.

For software to run on the Steam Runtime, it should be compiled in an environment where only the Steam-supplied libraries exist. This is accomplished with Docker.

Building with Docker

Valve maintains first-party Docker images in which developers can install necessary compilers, build dependencies not supplied the runtime, and finally compile their software. Docker is driven primarily by a Dockerfile, a script that specifies construction of a container image. In theory, building a Steam-ready game is as simple as starting with Valve's base image, then scripting a procedure to install Rust, copy in your source code, and compile it. However, there are some subtleties.

A Surfeit of GCCs

As of this writing, the Scout Docker image (version 0.20210906.1) has no less than four different versions of GCC and its attendant libraries, ranging from 4.6 to 9.3, plus two versions of binutils. The environment's default is GCC 4.8 and binutils 2.22, about a decade old--far too old for modern C++ projects which may be among your dependencies, and even some low-level Rust projects like ring. GCC 9.3 with binutils 2.30 can be configured for most C/C++ build systems, including those sometimes invoked by Rust build scripts, by setting the environment variables CC=gcc-9 and CXX=g++-9.

Rust must be configured to use the same GCC version for linking as was used for any foreign dependencies, as the latest version cannot link build products from older versions and vis versa. Unfortunately, the GCC 9.3 build supplied by Valve uses a static libgcc, which Rust does not yet support, so passing -C linker=gcc-9 will fail. We can hack around this problem with a wrapper script around the linker that strips out instances of -lgcc_s and inserts -lgcc:

#!/bin/bash
# Hack around rust's lack of static libgcc support (https://github.com/rust-lang/rust/issues/29527)
for arg do
  shift
  case $arg in
    (-lgcc_s) : ;;
       (*) set -- "$@" "$arg" ;;
  esac
done

exec "$CC" "$@" -lgcc

Caching

Docker caches intermediate results after every RUN operation, and invalidates all operations that lexically follow after an input file changes. This is great for roughly one-time tasks like building foreign dependencies, but insufficient to avoid rebuilding a Rust application and its dependencies from scratch after every change. Docker's new buildkit feature allows any RUN operation to be annotated with --mount=type=cache,target=<path> to preserve a path between runs. Applying this to your target dir and cargo caches makes for a pleasant iterative Rust development experience. Buildkit must be opted into by setting the environment variable DOCKER_BUILDKIT=1 when running docker.

Putting It Together

A suitable Dockerfile looks something like this, replacing your-binary-here with the build products you want to save:

# Use the current version of the Scout runtime
FROM registry.gitlab.steamos.cloud/steamrt/scout/sdk:latest

# Install the current stable version of Rust
RUN wget https://sh.rustup.rs -O rustup.sh
ENV CARGO_HOME=/cargo
RUN sh rustup.sh --profile minimal -y
ENV PATH="/cargo/bin:${PATH}"

# Use a modern GCC
ENV CC=gcc-9
ENV CXX=g++-9

# Copy the linker wrapper hack in from the host and wire it up to Rust
WORKDIR /work
COPY linker.sh .
ENV RUSTFLAGS="-C linker=/work/linker.sh"

# Prepare a directory to gather build products
RUN mkdir -p out

# Build any foreign dependencies not provided by the Steam Runtime here
# ...

# Build the Rust application, caching the Cargo registry and the target dir
COPY . .
RUN --mount=type=cache,target=target \
    --mount=type=cache,target=/cargo/registry \
    --mount=type=cache,target=/cargo/git \
    cargo build --release && \
    cp target/release/your-binary-here out/

Note that the COPY . . directive will copy your entire project directory into the container. Considerable time can be saved by adding a .dockerignore file to that directory containing at least /target.

Building a foreign dependency is a matter of fetching source and driving its build procedure as usual. For example, this snippet builds the OpenXR loader and copies its libraries to the output directory:

WORKDIR /work
ADD https://github.com/KhronosGroup/OpenXR-SDK/archive/refs/tags/release-1.0.20.tar.gz openxr-loader.tar.gz
RUN mkdir openxr && tar xzf openxr-loader.tar.gz -C openxr --strip-components 1
WORKDIR /work/openxr
RUN cmake -Bbuild -DCMAKE_BUILD_TYPE=Release . && \
    make -C build && \
    cp -P build/src/loader/libopenxr_loader.so* /work/out/
WORKDIR /work

Dynamically linking bundled foreign dependencies is a good default because it makes a package friendlier to downstream maintenance. To ensure rustc can find bundled dynamic libraries at build time, add -L/work/out to RUSTFLAGS. To instruct the end user's dynamic loader to search the directory containing the binary for dynamic libraries at run time, additionally include -C link-args=-Wl,-rpath=$ORIGIN. You can also specify arbitrary paths relative to $ORIGIN if you prefer. A complete RUSTFLAGS specification might ultimately look like:

ENV RUSTFLAGS="-C linker=/work/linker.sh -L/work/out -C link-args=-Wl,-rpath=\$ORIGIN"

To drive Docker and extract build products, a shell script is convenient. This example assumes the current working directory is your project root, and should be adjusted to set PROJECT_NAME and DOCKERFILE appropriately.

#!/usr/bin/env bash
set -eu

PROJECT_NAME=foo
DOCKERFILE=Dockerfile

DOCKER_BUILDKIT=1 docker build . -t $PROJECT_NAME -f $DOCKERFILE
id=$(docker create $PROJECT_NAME)
rm -rf ./steam-out
docker cp $id:/work/out ./steam-out
docker rm -v $id