This post was featured on the Kubernetes Podcast and discussed on Hacker News.

Vintage TV by Francisco Andreotti via Unsplash

Vintage TV by Francisco Andreotti via Unsplash

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:

  1. Machinarium (2009)
  2. 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:

  1. we’ll be using games purchased from GOG
  2. we’ll aim to make our approach generalized and shared as much as possible, so that we can reuse it across multiple games
  3. we won’t copy the game content (whether setup/installation files or the final installed data files) into the containers
  4. 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 playing:

$ ./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 see flash.events and flash.desktop) and the as extension on BotaniculaLinux.as tells us this was written in ActionScript. Investigating the contents of the install 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:

  1. Botanicula; we used the version purchased via GOG
  2. 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:

  1. v1 runner.sh and Dockerfile
  2. v2 runner.sh and Dockerfile
  3. v3 runner.sh and Dockerfile
  4. v4 runner.sh and Dockerfile
  5. v5 runner.sh and Dockerfile
  6. v6 runner.sh and Dockerfile
  7. v7 runner.sh and Dockerfile
  8. v8 runner.sh and Dockerfile
  9. v9 runner.sh and Dockerfile
  10. v10 runner.sh and Dockerfile

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.