This post was featured on the Kubernetes Podcast and discussed on Hacker News.
A long time ago, I played a fun point-and-click puzzle game Machinarium. This game, having been released in 2009, is not quite “vintage”, though at the time of this writing, it’s already 11 years old!
Given that there are a number of such games, I was wondering how easy it would be to run them all today, but although I last played this game on Windows, this time, I wanted to see if I could run it on Linux (it has a native port, so we wouldn’t be using Wine). Additionally, I’ve been thinking about using containers for running games to ensure they get the correct environment, without having to install all their contemporary (i.e., old and possibly insecure) dependencies on my system directly, as well as isolating them from the rest of the system. On top of that, we can check if any of these games actually need to have network access by entirely disabling it.
Ready? Let’s go!
Disclaimer: this post was not sponsored by any of the companies mentioned, and I did not receive any promotional copies, discounts, or encouragement to write this post. I purchased all of the games described here myself, at public retail prices at the time of purchase (game prices tend to fluctuate).
List of games
In this post, we’ll try to make the following two games to work:
- Machinarium (2009)
- Botanicula (2012)
They have similar runtime requirements and were both written about a decade ago (hence the title of this post), so we should be able to come up with a shared solution for both of them.
TL;DR: if you want to skip the trial-and-error (of which there will be plenty), you can skip straight to the following:
- conclusions where we’ll summarize what we’ve learned
- security considerations when running such old software and supporting infrastructure
- resources has the final configuration scripts you’ll need
- usage section describes how to actually install and play these games
OK, with that, let’s get started!
Goals & principles
Although there are several places where we can buy Machinarium and many other games, including from the developers themselves, most distributions are DRM-restricted and depend on network access as well as the service being around and operational, for us to be able to play this game.
To avoid the issues that come with this, we will be using games purchased from GOG (Good Old Games), a DRM-free distributor of both recent and classic games on multiple platforms (Linux, macOS, and Windows).
Thus, these instructions will be somewhat specific for the GOG distributions of the specific games we’re looking into, but should be easily adjustable in the end to the same games acquired from other distributors.
Since we’re going to make this work for multiple games, we’ll try to make our approach as portable and reusable as possible, without hard-coding game-specific features or names into our container images.
Our goal is not to distribute these containers, but just use containerized environments for running games, so we won’t actually bake games into the container images themselves; we’ll keep the games on the system’s native filesystem and bind-mount the downloaded binaries and the installation directores in to the container on an as-needed basis. This also makes it much faster to iterate on container builds, and keeps containers portable and reusable across games, as they will have no game-specific data.
Finally, we’ll try to follow good style for our containers, such as minimizing
their size by installing just what’s required, and good security practices
such as not running our processes as root
, even inside our containers.
So, to summarize:
- we’ll be using games purchased from GOG
- we’ll aim to make our approach generalized and shared as much as possible, so that we can reuse it across multiple games
- we won’t copy the game content (whether setup/installation files or the final installed data files) into the containers
- we will run our games as regular users inside containers, not
root
Environment
FYI, here’s my setup, in case you want to replicate these results:
- Ubuntu 19.04
- Docker version 18.09.7, build 2d0083d
- Linux kernel 5.0.0-38-generic
Step 1: how hard can this be?
The GOG pages for the games specify that they are compatible with Ubuntu 14.04 and Mint 17. I’ll be using Ubuntu 14.04 in our containers, since I’m already familiar with Ubuntu, having used it for a while across multiple versions.
Also, since I’ve already played Machinarium before, let’s begin with Botanicula.
We’ll start by creating a directory structure as follows:
base
├── .dockerignore
├── Dockerfile
├── runner.sh
├── install/
└── source/
└── gog_botanicula_2.0.0.2.sh
In the base
directory, we’ll create a directory source
for storing the
installation file provided by GOG (in the case of Botanicula, that’s
gog_botanicula_2.0.0.2.sh
), and install
will be the target installation
directory.
Make sure the downloaded shell script is executable:
$ chmod u+x source/gog_botanicula_2.0.0.2.sh
We’ll create a Dockerfile
in the same directory, but to avoid the Docker
daemon sending the contents of the directory tree (recursively) as the build
context to the Docker daemon, we’ll add a .dockerignore
file to ignore
everything, literally:
*
Let’s start with a basic Dockerfile
:
# Copyright 2020 Misha Brukman
# SPDX-License-Identifier: Apache-2.0
# https://misha.brukman.net/blog/2020/04/running-decade-old-games-in-containers/
FROM ubuntu:14.04
# Create a non-root user to run games with reduced permissions.
# Note: you can set any username you want here; it doesn't matter.
ENV USER=user
RUN useradd -ms /bin/bash $USER
# Drop root permissions; all commands from now on will be executed as a
# regular user, so be sure to do anything requiring root permissions prior to
# this line.
USER $USER
# Declare that we will mount two directories into this container at runtime,
# one for the source media and one for the installation folder.
VOLUME /opt/game/source
VOLUME /opt/game/install
We’ll be using a runner.sh
script to simplify running our many
Docker commands; it will store all of the flags and parameters, so we’ll use
it as runner.sh build
and runner.sh install
:
#!/bin/bash -u
#
# Copyright 2020 Misha Brukman
# SPDX-License-Identifier: Apache-2.0
# https://misha.brukman.net/blog/2020/04/running-decade-old-games-in-containers/
declare -r DOCKER_IMAGE="game-image"
# Build the Docker container.
build() {
docker build -t "${DOCKER_IMAGE}" .
}
# Run the game installer.
install() {
docker run \
-v "$(pwd)/source:/opt/game/source:ro" \
-v "$(pwd)/install:/opt/game/install:rw" \
--network none \
"${DOCKER_IMAGE}" \
"/opt/game/source/gog_botanicula_2.0.0.2.sh"
}
# This is where we should do some error checking and provide a useful error
# message if the user provides a command other than `build` or `install`, but
# for brevity, we're just running the provided command directly.
"$1"
So now let’s start by building the container:
$ ./runner.sh build
[... lots of docker output ...]
Successfully built 615c5bae6d1c
Successfully tagged game-image:latest
… and install the game:
$ ./runner.sh install
Verifying archive integrity... All good.
Uncompressing Botanicula (GOG.com) 100%
Collecting info for this system...
Operating system: linux
CPU Arch: x86_64
trying mojosetup in bin/linux/x86_64
USING en_US
PANIC
Failed to start GUI. Is your download incomplete or corrupt?
Error: Couldn't run mojosetup
Hmm… can’t start the GUI?
Step 2: there’s a GUI?
Let’s see if we can run the installation manually to see what the problem
might be. For that, we’ll need to add a new command—let’s call it
install-debug
—to our runner.sh
script, which will start an
interactive shell in the container, instead of launching the game installer
directly, which we can do by adding the -it
flag to the docker run
command
line, and changing the command to be run in the container from
/opt/game/source/gog_botanicula_2.0.0.2.sh
to simply /bin/bash
:
# https://misha.brukman.net/blog/2020/04/running-decade-old-games-in-containers/
declare -r DOCKER_IMAGE="game-image"
+declare -r DOCKER_FLAGS="${DOCKER_FLAGS:-}"
# Build the Docker container.
build() {
@@ -17,8 +18,15 @@
-v "$(pwd)/source:/opt/game/source:ro" \
-v "$(pwd)/install:/opt/game/install:rw" \
--network none \
+ ${DOCKER_FLAGS} \
"${DOCKER_IMAGE}" \
- "/opt/game/source/gog_botanicula_2.0.0.2.sh"
+ "${INSTALL_CMD:-/opt/game/source/gog_botanicula_2.0.0.2.sh}"
+}
+
+# Run the container as if for installation, but substitute an interactive
+# shell instead of running the game installer directly.
+install-debug() {
+ env DOCKER_FLAGS="-it" INSTALL_CMD="/bin/bash" "$0" install
}
# This is where we should do some error checking and provide a useful error
Expand to see the full v2 runner.sh
.
#!/bin/bash -u
#
# Copyright 2020 Misha Brukman
# SPDX-License-Identifier: Apache-2.0
# https://misha.brukman.net/blog/2020/04/running-decade-old-games-in-containers/
declare -r DOCKER_IMAGE="game-image"
declare -r DOCKER_FLAGS="${DOCKER_FLAGS:-}"
# Build the Docker container.
build() {
docker build -t "${DOCKER_IMAGE}" .
}
# Run the game installer.
install() {
docker run \
-v "$(pwd)/source:/opt/game/source:ro" \
-v "$(pwd)/install:/opt/game/install:rw" \
--network none \
${DOCKER_FLAGS} \
"${DOCKER_IMAGE}" \
"${INSTALL_CMD:-/opt/game/source/gog_botanicula_2.0.0.2.sh}"
}
# Run the container as if for installation, but substitute an interactive
# shell instead of running the game installer directly.
install-debug() {
env DOCKER_FLAGS="-it" INSTALL_CMD="/bin/bash" "$0" install
}
# This is where we should do some error checking and provide a useful error
# message if the user provides a command other than `build` or `install`, but
# for brevity, we're just running the provided command directly.
"$1"
Once we have a shell, we can simply run the installer ourselves:
$ ./runner.sh install-debug
[container] $ /opt/game/source/gog_botanicula_2.0.0.2.sh
And voilà! The installer starts… but not a GUI, more of a TUI (terminal UI)!
Turns out, it was failing to start because our original installation docker run
command did not include the -it
flag for an interactive terminal, while
our debugger command does include it, which is why it worked.
Step 3: interative terminals for everyone!
Let’s update our install
command to include the -it
flag and see if that
fixes our issue:
# Run the game installer.
install() {
docker run \
+ -it \
-v "$(pwd)/source:/opt/game/source:ro" \
-v "$(pwd)/install:/opt/game/install:rw" \
--network none \
@@ -26,7 +27,7 @@
# Run the container as if for installation, but substitute an interactive
# shell instead of running the game installer directly.
install-debug() {
- env DOCKER_FLAGS="-it" INSTALL_CMD="/bin/bash" "$0" install
+ env INSTALL_CMD="/bin/bash" "$0" install
}
# This is where we should do some error checking and provide a useful error
Expand to see the full v3 runner.sh
.
#!/bin/bash -u
#
# Copyright 2020 Misha Brukman
# SPDX-License-Identifier: Apache-2.0
# https://misha.brukman.net/blog/2020/04/running-decade-old-games-in-containers/
declare -r DOCKER_IMAGE="game-image"
declare -r DOCKER_FLAGS="${DOCKER_FLAGS:-}"
# Build the Docker container.
build() {
docker build -t "${DOCKER_IMAGE}" .
}
# Run the game installer.
install() {
docker run \
-it \
-v "$(pwd)/source:/opt/game/source:ro" \
-v "$(pwd)/install:/opt/game/install:rw" \
--network none \
${DOCKER_FLAGS} \
"${DOCKER_IMAGE}" \
"${INSTALL_CMD:-/opt/game/source/gog_botanicula_2.0.0.2.sh}"
}
# Run the container as if for installation, but substitute an interactive
# shell instead of running the game installer directly.
install-debug() {
env INSTALL_CMD="/bin/bash" "$0" install
}
# This is where we should do some error checking and provide a useful error
# message if the user provides a command other than `build` or `install`, but
# for brevity, we're just running the provided command directly.
"$1"
Now that we’ve updated runner.sh
accordingly, let’s try the
automated installation again:
$ ./runner.sh install
Success! It starts the TUI, we accept the EULA, when it asks for the
installation directory, we manually input /opt/game/install
since that’s
what we designated as our installation directory, and skip adding the start
menu and desktop icons since we’ll be reusing the container across games, and
we are keeping the game files outside of the container anyway, so it won’t
make much sense without the game actually being present.
Now let’s run the game!
Step 4: can we play now?
Since we installed the game outside of the container, we can just see what was installed directly on the filesystem, to find out the name of the game launcher command.
$ ls -F install
docs/ game/ gameinfo start.sh* support/ uninstall-Botanicula.sh*
This shows us there’s an executable script named start.sh
, so that must be
it! Let’s add a play
command to our runner.sh
so we can start
it:
env INSTALL_CMD="/bin/bash" "$0" install
}
+play() {
+ docker run \
+ -it \
+ -v "$(pwd)/install:/opt/game/install:rw" \
+ --network none \
+ ${DOCKER_FLAGS} \
+ "${DOCKER_IMAGE}" \
+ "/opt/game/install/start.sh"
+}
+
# This is where we should do some error checking and provide a useful error
# message if the user provides a command other than `build` or `install`, but
# for brevity, we're just running the provided command directly.
Expand to see the full v4 runner.sh
.
#!/bin/bash -u
#
# Copyright 2020 Misha Brukman
# SPDX-License-Identifier: Apache-2.0
# https://misha.brukman.net/blog/2020/04/running-decade-old-games-in-containers/
declare -r DOCKER_IMAGE="game-image"
declare -r DOCKER_FLAGS="${DOCKER_FLAGS:-}"
# Build the Docker container.
build() {
docker build -t "${DOCKER_IMAGE}" .
}
# Run the game installer.
install() {
docker run \
-it \
-v "$(pwd)/source:/opt/game/source:ro" \
-v "$(pwd)/install:/opt/game/install:rw" \
--network none \
${DOCKER_FLAGS} \
"${DOCKER_IMAGE}" \
"${INSTALL_CMD:-/opt/game/source/gog_botanicula_2.0.0.2.sh}"
}
# Run the container as if for installation, but substitute an interactive
# shell instead of running the game installer directly.
install-debug() {
env INSTALL_CMD="/bin/bash" "$0" install
}
play() {
docker run \
-it \
-v "$(pwd)/install:/opt/game/install:rw" \
--network none \
${DOCKER_FLAGS} \
"${DOCKER_IMAGE}" \
"/opt/game/install/start.sh"
}
# This is where we should do some error checking and provide a useful error
# message if the user provides a command other than `build` or `install`, but
# for brevity, we're just running the provided command directly.
"$1"
And let’s start the game:
$ ./runner.sh play
/opt/game/install/start.sh: line 18: bin/adl: No such file or directory
Hmmm… this is starting to be reminiscent of when we tried to upgrade our Hugo binary and it couldn’t find the right shared libraries. I wonder what’s wrong here? Let’s see what symbols we’re missing here:
$ ./runner.sh install-debug
[container] $ cd /opt/game/install
[container] $ cat start.sh
[...]
run_game() {
cd game
bin/adl bin/BotaniculaLinux-app.xml
}
[...]
OK, so the actual binary we need to look at is game/bin/adl
, so let’s see
what it’s all about:
[container] $ cd /opt/game/install/game/bin
[container] $ file adl
adl: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), dynamically linked (uses shared libs), for GNU/Linux 2.6.9, stripped
[container] $ ldd adl
not a dynamic executable
It’s interesting to note that file
thinks it’s a dynamically-linked
executable, but ldd
disagrees? I wonder why that might be… wait, did it
say it’s a 32-bit executable?
Step 5: 32 bits should be enough for anyone
Looking back at the Botanicula page on GOG, it actually says this:
Requires libc6:i386 libasound2:i386 libasound2-data:i386 libasound2-plugins:i386 libgtk2.0-0:i386 libxml2:i386 libnss3:i386 and dependencies
this game comes with a 32-bit binary only
This might be the issue! Conveniently, they also pointed out all the packages we need, so we can just install them in our container directly:
FROM ubuntu:14.04
+# Add 32-bit support to a 64-bit Ubuntu or Debian system:
+# https://support.gog.com/hc/en-us/articles/213039665
+# Without this line, `apt-get` will not be able to find the packages below.
+RUN dpkg --add-architecture i386
+
+RUN apt-get update && \
+ apt-get install -y \
+ libasound2-data:i386 \
+ libasound2:i386 \
+ libasound2-plugins:i386 \
+ libc6:i386 \
+ libgtk2.0-0:i386 \
+ libnss3:i386 \
+ libxml2:i386
+
# Create a non-root user to run games with reduced permissions.
# Note: you can set any username you want here; it doesn't matter.
ENV USER=user
Expand to see the full v5 Dockerfile
.
# Copyright 2020 Misha Brukman
# SPDX-License-Identifier: Apache-2.0
# https://misha.brukman.net/blog/2020/04/running-decade-old-games-in-containers/
FROM ubuntu:14.04
# Add 32-bit support to a 64-bit Ubuntu or Debian system:
# https://support.gog.com/hc/en-us/articles/213039665
# Without this line, `apt-get` will not be able to find the packages below.
RUN dpkg --add-architecture i386
RUN apt-get update && \
apt-get install -y \
libasound2-data:i386 \
libasound2:i386 \
libasound2-plugins:i386 \
libc6:i386 \
libgtk2.0-0:i386 \
libnss3:i386 \
libxml2:i386
# Create a non-root user to run games with reduced permissions.
# Note: you can set any username you want here; it doesn't matter.
ENV USER=user
RUN useradd -ms /bin/bash $USER
# Drop root permissions; all commands from now on will be executed as a
# regular user, so be sure to do anything requiring root permissions prior to
# this line.
USER $USER
# Declare that we will mount two directories into this container at runtime,
# one for the source media and one for the installation folder.
VOLUME /opt/game/source
VOLUME /opt/game/install
Let’s try this again:
$ ./runner.sh build
[... even more Docker output than previously ...]
Successfully built fad7ebd2b33a
Successfully tagged game-image:latest
We can skip the install
step since nothing needs to change there, and go
straight to play
ing:
$ ./runner.sh play
(bin/adl:21): Gtk-WARNING **: cannot open display:
Well, now we’re getting somewhere! At least we’re not getting mysterious “file
not found” issues, but looks like we’ll need to forward our $DISPLAY
into
the container to allow the process to access it directly.
Step 6: sharing is caring
Looks like in Step 5, we weren’t able to open $DISPLAY
from inside the
container; we’ll have to share it with the container for this to work. While
we’re at it, we should also share the sound device /dev/snd
(and add the
user to the audio
group) so that we can get music and sound effects to work,
so let’s update our docker run
command in runner.sh
script
accordingly:
docker run \
-it \
-v "$(pwd)/install:/opt/game/install:rw" \
+ -v "${HOME}/.Xauthority:/home/user/.Xauthority:rw" \
+ -v "/tmp/.X11-unix:/tmp/.X11-unix:rw" \
+ -v "${XDG_RUNTIME_DIR}:/run/user/1000:rw" \
+ --device /dev/snd \
+ --group-add=audio \
+ --env=DISPLAY \
+ --env=XDG_RUNTIME_DIR=/run/user/1000 \
--network none \
${DOCKER_FLAGS} \
"${DOCKER_IMAGE}" \
Expand to see the full v6 runner.sh
.
#!/bin/bash -u
#
# Copyright 2020 Misha Brukman
# SPDX-License-Identifier: Apache-2.0
# https://misha.brukman.net/blog/2020/04/running-decade-old-games-in-containers/
declare -r DOCKER_IMAGE="game-image"
declare -r DOCKER_FLAGS="${DOCKER_FLAGS:-}"
# Build the Docker container.
build() {
docker build -t "${DOCKER_IMAGE}" .
}
# Run the game installer.
install() {
docker run \
-it \
-v "$(pwd)/source:/opt/game/source:ro" \
-v "$(pwd)/install:/opt/game/install:rw" \
--network none \
${DOCKER_FLAGS} \
"${DOCKER_IMAGE}" \
"${INSTALL_CMD:-/opt/game/source/gog_botanicula_2.0.0.2.sh}"
}
# Run the container as if for installation, but substitute an interactive
# shell instead of running the game installer directly.
install-debug() {
env INSTALL_CMD="/bin/bash" "$0" install
}
play() {
docker run \
-it \
-v "$(pwd)/install:/opt/game/install:rw" \
-v "${HOME}/.Xauthority:/home/user/.Xauthority:rw" \
-v "/tmp/.X11-unix:/tmp/.X11-unix:rw" \
-v "${XDG_RUNTIME_DIR}:/run/user/1000:rw" \
--device /dev/snd \
--group-add=audio \
--env=DISPLAY \
--env=XDG_RUNTIME_DIR=/run/user/1000 \
--network none \
${DOCKER_FLAGS} \
"${DOCKER_IMAGE}" \
"/opt/game/install/start.sh"
}
# This is where we should do some error checking and provide a useful error
# message if the user provides a command other than `build` or `install`, but
# for brevity, we're just running the provided command directly.
"$1"
While we’re at it, since the uid of the user running X Window System outside
of the container is 1000 (in our case; your user uid might differ), we need to
ensure that the user we’re running as inside our container is also uid 1000,
so let’s update our Dockerfile
as well:
# Note: you can set any username you want here; it doesn't matter.
ENV USER=user
RUN useradd -ms /bin/bash $USER
+# Note: the `docker run` command in `runner.sh` expects this user to have
+# UID=1000; if you change it here, update it there as well.
+RUN usermod --uid 1000 "$USER"
+RUN groupmod --gid 1000 "$USER"
# Drop root permissions; all commands from now on will be executed as a
# regular user, so be sure to do anything requiring root permissions prior to
Expand to see the full v6 Dockerfile
.
# Copyright 2020 Misha Brukman
# SPDX-License-Identifier: Apache-2.0
# https://misha.brukman.net/blog/2020/04/running-decade-old-games-in-containers/
FROM ubuntu:14.04
# Add 32-bit support to a 64-bit Ubuntu or Debian system:
# https://support.gog.com/hc/en-us/articles/213039665
# Without this line, `apt-get` will not be able to find the packages below.
RUN dpkg --add-architecture i386
RUN apt-get update && \
apt-get install -y \
libasound2-data:i386 \
libasound2:i386 \
libasound2-plugins:i386 \
libc6:i386 \
libgtk2.0-0:i386 \
libnss3:i386 \
libxml2:i386
# Create a non-root user to run games with reduced permissions.
# Note: you can set any username you want here; it doesn't matter.
ENV USER=user
RUN useradd -ms /bin/bash $USER
# Note: the `docker run` command in `runner.sh` expects this user to have
# UID=1000; if you change it here, update it there as well.
RUN usermod --uid 1000 "$USER"
RUN groupmod --gid 1000 "$USER"
# Drop root permissions; all commands from now on will be executed as a
# regular user, so be sure to do anything requiring root permissions prior to
# this line.
USER $USER
# Declare that we will mount two directories into this container at runtime,
# one for the source media and one for the installation folder.
VOLUME /opt/game/source
VOLUME /opt/game/install
OK, since we’ve modified the Dockerfile
, we need to rebuild the container:
$ ./runner.sh build
We don’t have to reinstall the game, so let’s try to play it:
$ ./runner.sh play
And … a window shows up with the title “Botanicula!” But … the game actually crashes, and we see the following error in the terminal:
Gtk-Message: Failed to load module "canberra-gtk-module"
TypeError: Error #1009: Cannot access a property or method of a null object reference.
at BotaniculaLinux/invokeHandler()[/Users/macbookpro/Documents/flex/BotaniculaLinux/src/BotaniculaLinux.as:369]
at flash.events::EventDispatcher/dispatchEventFunction()
at flash.events::EventDispatcher/dispatchEvent()
at flash.desktop::NativeApplication/dispatchEvent()
at Runtime/dispatchInvokeEventUnderProfile()
at runtime::AppRunner/onComplete()
Hmm… maybe those instructions from GOG didn’t have the complete package listing; perhaps they’re assuming a standard Ubuntu desktop installation which includes the common GTK packages already?
As an aside, at this point we can see that this “Linux” version was actually built on a Mac (see the
/Users/macboookpro
directory), and that this is actually a game written in Flash (since we can seeflash.events
andflash.desktop
) and theas
extension onBotaniculaLinux.as
tells us this was written in ActionScript. Investigating the contents of theinstall
folder, we also find Adobe AIR runtime.
Step 7: canberra-gtk-module
to the rescue
Looks like we’ll need to install the
libcanberra-gtk-module
, but since we’re running in
32-bit mode, that’ll be actually libcanberra-gtk-module:i386
.
That’s a simple update to our Dockerfile
:
libasound2:i386 \
libasound2-plugins:i386 \
libc6:i386 \
+ libcanberra-gtk-module:i386 \
libgtk2.0-0:i386 \
libnss3:i386 \
libxml2:i386
Expand to see the full v7 Dockerfile
.
# Copyright 2020 Misha Brukman
# SPDX-License-Identifier: Apache-2.0
# https://misha.brukman.net/blog/2020/04/running-decade-old-games-in-containers/
FROM ubuntu:14.04
# Add 32-bit support to a 64-bit Ubuntu or Debian system:
# https://support.gog.com/hc/en-us/articles/213039665
# Without this line, `apt-get` will not be able to find the packages below.
RUN dpkg --add-architecture i386
RUN apt-get update && \
apt-get install -y \
libasound2-data:i386 \
libasound2:i386 \
libasound2-plugins:i386 \
libc6:i386 \
libcanberra-gtk-module:i386 \
libgtk2.0-0:i386 \
libnss3:i386 \
libxml2:i386
# Create a non-root user to run games with reduced permissions.
# Note: you can set any username you want here; it doesn't matter.
ENV USER=user
RUN useradd -ms /bin/bash $USER
# Note: the `docker run` command in `runner.sh` expects this user to have
# UID=1000; if you change it here, update it there as well.
RUN usermod --uid 1000 "$USER"
RUN groupmod --gid 1000 "$USER"
# Drop root permissions; all commands from now on will be executed as a
# regular user, so be sure to do anything requiring root permissions prior to
# this line.
USER $USER
# Declare that we will mount two directories into this container at runtime,
# one for the source media and one for the installation folder.
VOLUME /opt/game/source
VOLUME /opt/game/install
Let’s rebuild our container yet again:
$ ./runner.sh build
And start the game:
$ ./runner.sh play
At which point… we see:
TypeError: Error #1009: Cannot access a property or method of a null object reference.
at BotaniculaLinux/invokeHandler()[/Users/macbookpro/Documents/flex/BotaniculaLinux/src/BotaniculaLinux.as:369]
at flash.events::EventDispatcher/dispatchEventFunction()
at flash.events::EventDispatcher/dispatchEvent()
at flash.desktop::NativeApplication/dispatchEvent()
at Runtime/dispatchInvokeEventUnderProfile()
at runtime::AppRunner/onComplete()
This is peculiar. Let’s add a separate debug command for game playing as we did
for the installation, since now the docker run
command for playing has
diverged quite far from the command for the installation step:
--network none \
${DOCKER_FLAGS} \
"${DOCKER_IMAGE}" \
- "/opt/game/install/start.sh"
+ "${PLAY_CMD:-/opt/game/install/start.sh}"
+}
+
+# Run the container as if for playing, but substitute an interactive shell
+# instead of running the game directly.
+play-debug() {
+ env PLAY_CMD="/bin/bash" "$0" play
}
# This is where we should do some error checking and provide a useful error
Expand to see the full v7 runner.sh
.
#!/bin/bash -u
#
# Copyright 2020 Misha Brukman
# SPDX-License-Identifier: Apache-2.0
# https://misha.brukman.net/blog/2020/04/running-decade-old-games-in-containers/
declare -r DOCKER_IMAGE="game-image"
declare -r DOCKER_FLAGS="${DOCKER_FLAGS:-}"
# Build the Docker container.
build() {
docker build -t "${DOCKER_IMAGE}" .
}
# Run the game installer.
install() {
docker run \
-it \
-v "$(pwd)/source:/opt/game/source:ro" \
-v "$(pwd)/install:/opt/game/install:rw" \
--network none \
${DOCKER_FLAGS} \
"${DOCKER_IMAGE}" \
"${INSTALL_CMD:-/opt/game/source/gog_botanicula_2.0.0.2.sh}"
}
# Run the container as if for installation, but substitute an interactive
# shell instead of running the game installer directly.
install-debug() {
env INSTALL_CMD="/bin/bash" "$0" install
}
play() {
docker run \
-it \
-v "$(pwd)/install:/opt/game/install:rw" \
-v "${HOME}/.Xauthority:/home/user/.Xauthority:rw" \
-v "/tmp/.X11-unix:/tmp/.X11-unix:rw" \
-v "${XDG_RUNTIME_DIR}:/run/user/1000:rw" \
--device /dev/snd \
--group-add=audio \
--env=DISPLAY \
--env=XDG_RUNTIME_DIR=/run/user/1000 \
--network none \
${DOCKER_FLAGS} \
"${DOCKER_IMAGE}" \
"${PLAY_CMD:-/opt/game/install/start.sh}"
}
# Run the container as if for playing, but substitute an interactive shell
# instead of running the game directly.
play-debug() {
env PLAY_CMD="/bin/bash" "$0" play
}
# This is where we should do some error checking and provide a useful error
# message if the user provides a command other than `build` or `install`, but
# for brevity, we're just running the provided command directly.
"$1"
Now we can test out our play environment:
$ ./runner.sh play-debug
[container] $ /opt/game/install/start.sh
And… this is where it gets interesting. In some cases I’ve tried this, this ended up working, and in some cases, it did not, but then the following set of commands (inside the container) did end up working for me:
[container] $ cd /opt/game/install
[container] $ ./start.sh
which means the working directory matters when starting the game. Rather than
trying to write a custom entrypoint Docker script with cd /opt/game/install && ./start.sh
as the command, we can just specify the working directory
directly in the docker run
command, so let’s just do that.
Step 8: working on the workdir
-v "$(pwd)/source:/opt/game/source:ro" \
-v "$(pwd)/install:/opt/game/install:rw" \
--network none \
+ --workdir "/opt/game/source" \
${DOCKER_FLAGS} \
"${DOCKER_IMAGE}" \
- "${INSTALL_CMD:-/opt/game/source/gog_botanicula_2.0.0.2.sh}"
+ "${INSTALL_CMD:-./gog_botanicula_2.0.0.2.sh}"
}
# Run the container as if for installation, but substitute an interactive
@@ -42,9 +43,10 @@
--env=DISPLAY \
--env=XDG_RUNTIME_DIR=/run/user/1000 \
--network none \
+ --workdir "/opt/game/install" \
${DOCKER_FLAGS} \
"${DOCKER_IMAGE}" \
- "${PLAY_CMD:-/opt/game/install/start.sh}"
+ "${PLAY_CMD:-./start.sh}"
}
# Run the container as if for playing, but substitute an interactive shell
Expand to see the full v8 runner.sh
.
#!/bin/bash -u
#
# Copyright 2020 Misha Brukman
# SPDX-License-Identifier: Apache-2.0
# https://misha.brukman.net/blog/2020/04/running-decade-old-games-in-containers/
declare -r DOCKER_IMAGE="game-image"
declare -r DOCKER_FLAGS="${DOCKER_FLAGS:-}"
# Build the Docker container.
build() {
docker build -t "${DOCKER_IMAGE}" .
}
# Run the game installer.
install() {
docker run \
-it \
-v "$(pwd)/source:/opt/game/source:ro" \
-v "$(pwd)/install:/opt/game/install:rw" \
--network none \
--workdir "/opt/game/source" \
${DOCKER_FLAGS} \
"${DOCKER_IMAGE}" \
"${INSTALL_CMD:-./gog_botanicula_2.0.0.2.sh}"
}
# Run the container as if for installation, but substitute an interactive
# shell instead of running the game installer directly.
install-debug() {
env INSTALL_CMD="/bin/bash" "$0" install
}
play() {
docker run \
-it \
-v "$(pwd)/install:/opt/game/install:rw" \
-v "${HOME}/.Xauthority:/home/user/.Xauthority:rw" \
-v "/tmp/.X11-unix:/tmp/.X11-unix:rw" \
-v "${XDG_RUNTIME_DIR}:/run/user/1000:rw" \
--device /dev/snd \
--group-add=audio \
--env=DISPLAY \
--env=XDG_RUNTIME_DIR=/run/user/1000 \
--network none \
--workdir "/opt/game/install" \
${DOCKER_FLAGS} \
"${DOCKER_IMAGE}" \
"${PLAY_CMD:-./start.sh}"
}
# Run the container as if for playing, but substitute an interactive shell
# instead of running the game directly.
play-debug() {
env PLAY_CMD="/bin/bash" "$0" play
}
# This is where we should do some error checking and provide a useful error
# message if the user provides a command other than `build` or `install`, but
# for brevity, we're just running the provided command directly.
"$1"
At this point, the game starts, with graphics and music and everything seems to be going great! Or does it…?
The intro plays well, and I played a small part of the opening sequence, and
then I decided to save progress while I was testing my changes to the
Dockerfile
and runner.sh
, but when I restarted the game and tried to load
my saved progress … I found that there were no save points from which to
continue!
As you recall, we mount the installation directory with read-write access, specifically for these types of use cases, but it seems that it does not save the game to that location.
Using our trusty debugging command:
$ ./runner.sh play-debug
[container] /opt/game/install $ ./start.sh
When we create a save point, and then exit, we can examine our $HOME
directory to see where the save game may have ended up, and in this case we
see it’s right there:
[container] $ ls -F ~
BotaniculaSaves/
Step 9: save and exit
So far, we’ve been aiming to make our Dockerfile
and runner.sh
game-independent so that we could reuse it across multiple games; having the
name of the game hard-coded in our save directory (which will end up as a
volume in our Dockerfile
) threatens to upend that, but we can just mount
$HOME
to handle them all transparently as well!
Let’s update our Dockerfile
accordingly:
# one for the source media and one for the installation folder.
VOLUME /opt/game/source
VOLUME /opt/game/install
+
+# Saved games will be stored in a subdirectory of `$HOME`, so let's just
+# mount the whole home directory.
+VOLUME $HOME
Expand to see the full v9 Dockerfile
.
# Copyright 2020 Misha Brukman
# SPDX-License-Identifier: Apache-2.0
# https://misha.brukman.net/blog/2020/04/running-decade-old-games-in-containers/
FROM ubuntu:14.04
# Add 32-bit support to a 64-bit Ubuntu or Debian system:
# https://support.gog.com/hc/en-us/articles/213039665
# Without this line, `apt-get` will not be able to find the packages below.
RUN dpkg --add-architecture i386
RUN apt-get update && \
apt-get install -y \
libasound2-data:i386 \
libasound2:i386 \
libasound2-plugins:i386 \
libc6:i386 \
libcanberra-gtk-module:i386 \
libgtk2.0-0:i386 \
libnss3:i386 \
libxml2:i386
# Create a non-root user to run games with reduced permissions.
# Note: you can set any username you want here; it doesn't matter.
ENV USER=user
ENV HOME=/home/$USER
RUN useradd -ms /bin/bash $USER
# Note: the `docker run` command in `runner.sh` expects this user to have
# UID=1000; if you change it here, update it there as well.
RUN usermod --uid 1000 "$USER"
RUN groupmod --gid 1000 "$USER"
# Drop root permissions; all commands from now on will be executed as a
# regular user, so be sure to do anything requiring root permissions prior to
# this line.
USER $USER
# Declare that we will mount two directories into this container at runtime,
# one for the source media and one for the installation folder.
VOLUME /opt/game/source
VOLUME /opt/game/install
# Saved games will be stored in a subdirectory of `$HOME`, so let's just
# mount the whole home directory.
VOLUME $HOME
We’ll also need to update our runner.sh
script to mount a directory
accordingly:
docker run \
-it \
-v "$(pwd)/install:/opt/game/install:rw" \
+ -v "$(pwd)/home:/home/user:rw" \
-v "${HOME}/.Xauthority:/home/user/.Xauthority:rw" \
-v "/tmp/.X11-unix:/tmp/.X11-unix:rw" \
-v "${XDG_RUNTIME_DIR}:/run/user/1000:rw" \
Expand to see the full v9 runner.sh
.
#!/bin/bash -u
#
# Copyright 2020 Misha Brukman
# SPDX-License-Identifier: Apache-2.0
# https://misha.brukman.net/blog/2020/04/running-decade-old-games-in-containers/
declare -r DOCKER_IMAGE="game-image"
declare -r DOCKER_FLAGS="${DOCKER_FLAGS:-}"
# Build the Docker container.
build() {
docker build -t "${DOCKER_IMAGE}" .
}
# Run the game installer.
install() {
docker run \
-it \
-v "$(pwd)/source:/opt/game/source:ro" \
-v "$(pwd)/install:/opt/game/install:rw" \
--network none \
--workdir "/opt/game/source" \
${DOCKER_FLAGS} \
"${DOCKER_IMAGE}" \
"${INSTALL_CMD:-./gog_botanicula_2.0.0.2.sh}"
}
# Run the container as if for installation, but substitute an interactive
# shell instead of running the game installer directly.
install-debug() {
env INSTALL_CMD="/bin/bash" "$0" install
}
play() {
docker run \
-it \
-v "$(pwd)/install:/opt/game/install:rw" \
-v "$(pwd)/home:/home/user:rw" \
-v "${HOME}/.Xauthority:/home/user/.Xauthority:rw" \
-v "/tmp/.X11-unix:/tmp/.X11-unix:rw" \
-v "${XDG_RUNTIME_DIR}:/run/user/1000:rw" \
--device /dev/snd \
--group-add=audio \
--env=DISPLAY \
--env=XDG_RUNTIME_DIR=/run/user/1000 \
--network none \
--workdir "/opt/game/install" \
${DOCKER_FLAGS} \
"${DOCKER_IMAGE}" \
"${PLAY_CMD:-./start.sh}"
}
# Run the container as if for playing, but substitute an interactive shell
# instead of running the game directly.
play-debug() {
env PLAY_CMD="/bin/bash" "$0" play
}
# This is where we should do some error checking and provide a useful error
# message if the user provides a command other than `build` or `install`, but
# for brevity, we're just running the provided command directly.
"$1"
Since we’ve updated our Dockerfile
, let’s rebuild it:
$ ./runner.sh build
We don’t have to reinstall the game, so let’s just play it:
$ mkdir Documents
$ ./runner.sh play
There should be no start-up issues anymore, so let’s make some progress in the game, save our game, and exit. Then, restart the game:
$ ./runner.sh play
and we should have a save point to restore from! Hooray! Phew, that was a lot.
Note: some sources suggest that the save directory on Linux is
~/Documents/BotaniculaSaves
(rather than~/BotaniculaSaves
, which we discovered experimentally), which I originally took as given, only to find out that that was not correct, at least not for the version that I’m using. Always verify your assumptions or you will spend a bit of time debugging the wrong issues!
Step 10: does it generalize?
Now that we got our solution working for Botanicula, you might be wondering: will the same approach work for Machinarium as well? Let’s find out!
First, let’s create the directory structure for this game as before:
base
├── .dockerignore
├── Dockerfile
├── runner.sh
├── home/
├── install/
└── source/
└── gog_machinarium_2.0.0.2.sh
We’ll use the same .dockerignore
file as previously and we should be able to
re-use the Dockerfile
as-is, since it’s entirely generic. As a bonus, since
we’re not modifying the Dockerfile
, we don’t have to rebuild the image!
We’ll also use the same runner.sh
script, we’ll just have to run it
differently:
$ chmod u+x source/gog_machinarium_2.0.0.2.sh
$ env INSTALL_CMD=./gog_machinarium_2.0.0.2.sh ./runner.sh install
As before, make sure you install into a different directory,
/opt/game/install
, so that it works with our customized setup.
It should install with no issues, and now let’s play it:
$ ./runner.sh play
Running Machinarium
/opt/game/install/game/Machinarium: error while loading shared libraries: libXt.so.6: cannot open shared object file: No such file or directory
Uh-oh! What’s this? This again? Let’s find out the complete list of missing libraries so we don’t have to play whack-a-mole with them one-at-a-time:
$ ./runner.sh play-debug
[container] /opt/game/install $ ldd game/Machinarium | grep 'not found'
libXt.so.6 => not found
OK, so looks like it’s just a single library, that ought to be easy to fix.
Additionally, looking at the Machinarium page on GOG, we
find the full set of libraries actually includes libxt6:i386
, so they’ve
already thought of it! In fact, it’s nearly identical to the set of libraries
they specified for Botanicula, except it adds libcurl3:i386
, and
libxml2:i386
is swapped out for libxt6:9386
.
Since we want a generic container (it would be easier and less complex to have
1 container for a bunch of 32-bit games than two almost identical ones, save
for a package here or there), we might as well extend our existing container
image with the two new libraries, libcurl3:i386
and libxt6:i386
:
libasound2-plugins:i386 \
libc6:i386 \
libcanberra-gtk-module:i386 \
+ libcurl3:i386 \
libgtk2.0-0:i386 \
libnss3:i386 \
- libxml2:i386
+ libxml2:i386 \
+ libxt6:i386
# Create a non-root user to run games with reduced permissions.
# Note: you can set any username you want here; it doesn't matter.
Expand to see the full v10 Dockerfile
.
# Copyright 2020 Misha Brukman
# SPDX-License-Identifier: Apache-2.0
# https://misha.brukman.net/blog/2020/04/running-decade-old-games-in-containers/
FROM ubuntu:14.04
# Add 32-bit support to a 64-bit Ubuntu or Debian system:
# https://support.gog.com/hc/en-us/articles/213039665
# Without this line, `apt-get` will not be able to find the packages below.
RUN dpkg --add-architecture i386
RUN apt-get update && \
apt-get install -y \
libasound2-data:i386 \
libasound2:i386 \
libasound2-plugins:i386 \
libc6:i386 \
libcanberra-gtk-module:i386 \
libcurl3:i386 \
libgtk2.0-0:i386 \
libnss3:i386 \
libxml2:i386 \
libxt6:i386
# Create a non-root user to run games with reduced permissions.
# Note: you can set any username you want here; it doesn't matter.
ENV USER=user
ENV HOME=/home/$USER
RUN useradd -ms /bin/bash $USER
# Note: the `docker run` command in `runner.sh` expects this user to have
# UID=1000; if you change it here, update it there as well.
RUN usermod --uid 1000 "$USER"
RUN groupmod --gid 1000 "$USER"
# Drop root permissions; all commands from now on will be executed as a
# regular user, so be sure to do anything requiring root permissions prior to
# this line.
USER $USER
# Declare that we will mount two directories into this container at runtime,
# one for the source media and one for the installation folder.
VOLUME /opt/game/source
VOLUME /opt/game/install
# Saved games will be stored in a subdirectory of `$HOME`, so let's just
# mount the whole home directory.
VOLUME $HOME
Our runner.sh
stays the same, but since we’ve updated the Dockerfile
,
we’ll need to rebuild our container image:
$ ./runner.sh build
Our installation should be fine as-is, so let’s play:
$ ./runner.sh play
And it works out of the box! Graphics, music, save games, all good! We’re done!
Conclusions
I didn’t know this before diving into these games, but it turns out that both of these games were actually written using Flash, so in both cases, we ended up using the Adobe AIR runtime. I hope this was still a useful post and you’ve learned something new about running 32-bit binaries with audio & video support in containers, or at least inspired to try playing these or other games, whether using containers or otherwise.
Happy gaming!
Security considerations
Since we’re running old software in these containers, please be aware that the software is very much not secure; while Ubuntu 14.04 is an LTS (Long-Term Support) release, standard support for it ended in April 2019, and it will be EOL’d in April 2022. Plus, we’re using it to run Flash, which has had a number of security issues in the past.
Thus, it is imperative to avoid giving these containers running old software access to the network—this is what the containers I’m providing are doing by default (other than during the build step, which is unavoidable), so please keep it that way!
Resources
In case you want to follow along the entire process, here are the games we discussed, all by Amanita Design:
- Botanicula; we used the version purchased via GOG
- Machinarium; we used the version purchased via GOG
And here are all the Dockerfile
and runner.sh
scripts used in this post,
available under the Apache 2.0 license:
- v1
runner.sh
andDockerfile
- v2
runner.sh
andDockerfile
- v3
runner.sh
andDockerfile
- v4
runner.sh
andDockerfile
- v5
runner.sh
andDockerfile
- v6
runner.sh
andDockerfile
- v7
runner.sh
andDockerfile
- v8
runner.sh
andDockerfile
- v9
runner.sh
andDockerfile
- v10
runner.sh
andDockerfile
However, if you just want to play the game without going throught the trouble we just went through here, just grab the last version (v10), as that was what finally worked for me! See the usage section below for detailed instructions.
Usage
Set up your directory structure as follows:
base
├── .dockerignore
├── Dockerfile
├── runner.sh
├── home/
├── install/
└── source/
├── gog_machinarium_2.0.0.2.sh (OR)
└── gog_botanicula_2.0.0.2.sh
Note: put only one install file here; since we’re using the same installation
directory for both of them (install
), you should only install one game
at-a-time there. If you want to install both games, create a separate
(similar) tree for the other game.
The .dockerignore
file should have just a single line with just an asterisk:
*
Use the runner.sh
and Dockerfile
from the
last version (v10) above.
Build the container:
$ ./runner.sh build
Install the game of your choice:
# Install Botanicula
$ env INSTALL_CMD=./gog_botanicula_2.0.0.2.sh ./runner.sh install
Or:
# Install Machinarium
$ env INSTALL_CMD=./gog_machinarium_2.0.0.2.sh ./runner.sh install
Be sure to choose a “custom installation directory” and install into
/opt/game/install
.
And now, you can play the game:
$ ./runner.sh play
Have fun and let me know how it goes! Let’s continue the conversation on Twitter or Hacker News.