Painkiller 2004 Linux Game Server in Docker
You can find the code here.
The goal of the project was to make the 32-bit Painkiller Linux server work on most 64-bit Linux operating systems without any hassle.
Docker fits this purpose because it isolates containers from the host system.
Warning
Before you start to launch a server, please note that in order to see servers advertised on OpenSpy directly in the game browser, you need a patched Painkiller client DLL. Please check the details here.
Overview
-
Binary Hacks. The original Linux
pkserver
binary from People Can Fly is very limited. It starts with the hardcoded+dedicatedserver +map +port
parameters which cannot be overriden and only accepts the+interface
and+private
command line options. You are not able to pass custom-lscripts
and-cfg
. However, it's possible hack thepkserver
binary to accept customLScripts.pak
andconfig.ini
. The method is described here. This Docker image contains that binary hack. -
Docker environment variables. The Docker image also has a script that allows to pass the
Cfg_
environment variables to the Docker container that will override parameters in the defaultconfig.ini
with the similar names. For example, you can pass theCfg_ServerPort
variable and it will override theCfg.ServerPort
parameter in theconfig.ini
file. -
Outdated
gamespy.com
was replaced withopenspy.net
in the binary as well. -
The Docker image contains LScripts for PK++ 1.2 and PK++ 1.3 competition mods.
PK++ 1.2 is compatible with the original Painkiller while PK++ 1.3 server requires a client to have PK++ 1.3 mod installed.
-
Added semi-official competitive maps Meatless and Blink.
Note
The Painkiller server listens on 3455
UDP port by default. The game also listens on the 3578
UDP port for LAN discovery. If you increment your multiplayer port by one, like in 3456
, the LAN port will also be incremented by the game automatically to 3579
.
You only need to publish the LAN port in Docker if you run a clent and a server on the same machine which is unlikely.
Environment
A Docker container is a running instance of a Docker image. When you run a Docker image, it becomes a Docker container.
This Docker image can be used on most 64-bit Linux distributions.
I tested Docker containers on Ubuntu 22.04.4 LTS on Virtual Box 7.0.18 with Bridged Adapter and promiscuous mode. The containers were also launched on a production Ubuntu 24.04 LTS server.
Supported Architectures
- Linux AMD64 (x86-64)
- Linux ARM64
The Docker image is based on Debian. Unlike Ubuntu, Debian still has a very good support for 32-bit applications on AMD64 and ARM64 OS.
Prerequisites
- Docker Engine with Docker Compose
- Optional: QEMU for multi-platform builds
Warning
Podman and Podman Compose are not recommended due to potential issues with the default bridge network. Moreover, Podman doesn't directly manage restart policies and instead depends on systemd since Podman does not have its own deamon. However, Podman woks fine with the host network plugin. Pay attention that Docker cannot see Podman images and vice versa so better stick to one tool.
The Docker installation guides are a bit confusing. It's enough to install Docker Engine. Docker Desktop is only for systems with GUI and is optional.
-
Install Docker Engine. Here is the guide for Ubuntu. The Docker Compose plugin is already included in the default Docker plugin list.
-
Don't forget the Linux post-installation steps to manage Docker as a non-root user.
-
Check that the Docker daemon is running:
Note
QEMU emulation is only necessary if you want to build an ARM64 image on a AMD64 machine.
Quick start with Docker Compose
Docker Compose is a fast and convenient way to declaratively configure multiple containers. It uses YAML configuration file that have strict indentations. You can run multiple servers with one command, no additional scripts are required.
The docker-compose.yml
is provided in the repository as a template. Most parameters are preconfigured.
Note
Pay attention to the Cfg_ServerPort
variable or the Cfg.ServerPort
parameter in the config.ini
file. The port should be different for each server. If you, for example, run several servers on the same port, other containers will crash.
Issue: Docker Compose mounts files as folders.
Solution: indicate the path to the custom file with dot/slash, for instance:source: ./DM_K3Inzane.pkm
.
-
Copy the
docker-compose.yml
file from the repository to your host. -
Make necessary changes. For example, leave only one server.
-
Open necessary ports on your system.
-
Run the following command in the same directory:
It will launch preconfigured server/servers.
-
Stop and remove the servers:
Or
That's the bare minimum you need to know to launch your servers. The below profound information is necessary to understand how Docker works, how to manage it, what solutions were implemented in the Docker image, and how to build your own Docker image.
Quick start with Docker
By default, Docker uses the bridge network wich has its own subnet range. This network requires you to publish ports. Alternatively, you can use the host network which uses the IP addresses of your host network. You don't need to publish ports in case of the host network. However, you may need to pass +interface
or Cfg_NetworkInterface
to the Docker container if you have multiple interfaces on your host. This will tell the pkserver
binary to run on a particular IP address that has access to the internet.
Docker default bridge network
Warning
Podman may not work properly with the bridge network plugin. I recommend using Docker with this type of network.
Note
The Docker image is published to Docker Hub and to GitHub Container Registry. Use the one which is more convenient for you:
ghcr.io/t3r6/pkserver:main
docker.io/painkillergameclassic/pkserver:main
-
Start a server with the
ffa
name. This command will automatically pull the Docker image from Docker Hub and start a Docker container on your machine. The LAN port3578
is published as an example and is not required:docker run --rm --name ffa -itd -p 3455:3455/udp -p 3578:3578/udp docker.io/painkillergameclassic/pkserver:main
Note
--rm
means that the container will be removed once you stop it.
--name ffa
assignes theffa
name to the container.
-it
improves the interaction with the container.
-d
detaches your container to the background so it does not occupy your terminal session.
-p
publishes the UDP port in our case. The host port is mapped to the container port. -
Check that the container with the pkserver is running:
$ docker container ls CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES e9191538423b painkillergameclassic/pkserver:main "entrypoint.sh" 8 seconds ago Up 7 seconds 0.0.0.0:3455->3455/udp, :::3455->3455/udp, 3578/udp ffa
Check the port the container is running on:
-
Open and forward the default Painkiller 3455 port in your firewall if required. For example, if you run a server on a Virtual machine. Please check this guide on port forwarding. I suggest opening several ports beforehand in case of multiple servers: 3455, 3456, 3457.
-
Modify your firewall, iptables, AppArmor, SeLinux if you amended them before and blocked traffic. For example, open a range of ports on Ubuntu:
-
Check if your game is advertised on OpenSpy.
-
Open the Painkiller game client and join a server.
-
Stop the container named
ffa
: -
Remove the stopped container. Usually, this is not required since the
--rm
option removes the conatainer once you stop it. However, in some cases your container could get stuck so better double-check. Run the following command to check the failed containers:Remove the failed containers:
Docker host network
Docker and Podman work equally well on this type of network.
In case you have any issues with the default Docker bridge network, like errors connected with NATNEG, try the Docker host network. In this case, you'll need to indicate the IP of your network adapter that has access to the internet if you have multiple devices (192.168.0.106 in my example). Run the following command to check the network interfaces:
~$ ip --brief address show
lo UNKNOWN 127.0.0.1/8 ::1/128
enp0s3 UP 192.168.0.106/24 fe80::91ee:4f1b:1582:d667/64
docker0 UP 172.17.0.1/16 fe80::42:78ff:fecd:b400/64
It's the IP address of the enp0s3
interface in my case.
Launch a Docker container with the following command:
docker run --rm --name ffa -itd -e Cfg_NetworkInterface="192.168.0.106" --net=host docker.io/painkillergameclassic/pkserver:main
Alternative command:
docker run --rm --name ffa -itd --net=host docker.io/painkillergameclassic/pkserver:main +interface 192.168.0.106
Running on a different port
In case you need to run a game on a different port, use Docker port mapping and the Cfg_ServerPort
variable.
Warning
Note that Painkiller GameSpy backend is outdated which can cause issues with the NATNEG protocol. Thus, we must map the same Docker ports on the container and the host and also change the port in the Painkiller config.ini. For instance, with this command the client will get errors when connecting to your server via the in-game browser:
Pay attention that we map the3456
port on the host to the 3455
Docker container port. The clinet will get the following error:
Nevertheless, a clinet should be able to connect to your server by running manually the /connect ip:port
command in the game console.
It seems that the game tells OpenSpy that it is up on port 3455
, while 3456
port is mapped from the docker engine to the host system, but this is not an address translation, but simply a port mapping. Apparently, NATNEG is trying to negotiate through port 3455
when the negotiation should be through 3456
.
The below commands work and do not produce connection errors.
Custom ports with the Docker default bridge network
docker run --rm --name ffa -itd -p 3456:3456/udp -e Cfg_ServerPort="3456" docker.io/painkillergameclassic/pkserver:main
Custom ports with the Docker host network
docker run --rm --name ffa -itd -e Cfg_ServerPort="3456" -e Cfg_NetworkInterface="192.168.0.106" --net=host docker.io/painkillergameclassic/pkserver:main
Building
Build Docker image from a Dockerfile
-
Clone or download the github repository.
-
Modify files if required. You can put additional maps, modify
config.ini
, hackpkserver
binary beforehand, add your own scripts, make amendments to the Dockerfile. -
Run the following command:
Note
dot
is an indication that you build the Docker image from the Dockerfile in this directory.
-t/--tag
is the name of your future local Docker image.
--no-cache
does not use cache.To build from a Dockerfile with a custom name:
-
Check the built Docker images:
Run a server after the build
-
Start a server with the
ffa
name: -
Check that the container with the server is running:
-
Stop the
ffa
container:
Push your image to the container registry
You should be registered to either GitHub or Docker Hub.
-
Register in any of the mentioned services.
-
Re-tag your Docker image with:
-
Log in to the registry:
-
Push your image to the registry:
Run containers with different parameters
Using config variables
You can pass variables to the container instead of modifying the config.ini
file. Each variable corresponds to the Cfg
parameter in config.ini
and should start with Cfg_
. The letter case does not matter, except for special PKS_
variables.
For example, Cfg.ServerPort
corresponds to CFG_SERVERPORT
or Cfg_ServerPort
, Cfg.NetworkInterface
to CFG_NETWORKINTERFACE
or Cfg_NetworkInterface
. However, PKS_LSCRIPTS
should be passed with capital letters only. Let's start a PK++ 1.3
server on a 3456
port:
docker run --rm --name ffa -itd -p 3456:3456/udp -e Cfg_ServerPort="3456" -e PKS_LSCRIPTS='PKPlus13.pak' docker.io/painkillergameclassic/pkserver:main
Run a server with a custom config
Bind a custom host config to the default pkserver config
Docker containers are ephemeral. It means that when you restart a container, all the information will be lost and you start from scratch. If you are NOT going to use the Cfg_
variables with a container, you need to bind a config.ini
file from the outside to the Docker container. The simplest way would be to use the bind mounts Docker feature. As an alternative, you can use the Docker volumes but I found this method inconvenient for the Painkiller server management since you cannot insert data directly into a volume without copying it to a container.
-
Copy
./pkserver/Bin/config.ini
from the repository to your host, for example, to${HOME}/my_pkserver/config.ini
. -
Make sure the file has the right permissions:
-
You can leave the file as is or rename it whatever you like. Let's rename it to
config_ffa.ini
. -
Run the server with the following commands. You bind
config_ffa.ini
on your host machine toconfig.ini
in the Docker container:
Bind a custom host config to the custom pkserver config
Note
This hack is outdated.
-
Modify
${HOME}/my_pkserver/config.ini
to your needs and rename this file to something likeconctf.ini
.Warning
When the
PKS_CFG
variable is used, you can only give a custom name toconfig.ini
with the exact 6 letter size because scripts in this image hacks the binary, for example, you can rename it toconffa.ini
,conctf.ini
but you cannot name itmyconfig.ini
. -
Run the server with the following commands:
export PKS_CFG_SOURCE="${HOME}/my_pkserver/conctf.ini" docker run --rm --name ffa -itd -e PKS_CFG="conctf.ini" --mount type=bind,source=${PKS_CFG_SOURCE},target=/opt/pkserver/Bin/conctf.ini -p 3455:3455/udp docker.io/painkillergameclassic/pkserver:main
If you connect to the Docker container, there will be
conctf.ini
inside the/opt/pkserver/Bin
directory. Thepkserver
binary will useconctf.ini
instead of the defaultconfig.ini
file:
Run a server with custom LScripts
Run PK++
The Docker image has 2 custom packages: ./pkserver/Data/PKPlus12.pak
for PK++ 1.2
and ./pkserver/Data/PKPlus13.pak
for PK++ 1.3
.
Warning
Note that unlike PK++ 1.2, PK++ 1.3 is not backward compatible with the original Painkiller. A client should run PK++ 1.3 to connect to the PK++ 1.3 server.
In order to run a specific version of the mod, pass the package name to the CLI via the PKS_LSCRIPTS
Docker variable:
docker run --rm --name ffa -itd -p 3455:3455/udp -e PKS_LSCRIPTS='PKPlus13.pak' docker.io/painkillergameclassic/pkserver:main
Run a custom server mod
It is possible to use LScripts packs with custom names; however, you'll need to either bind them using bind mounts, copy to volumes, or add them to ./pkserver/Data/
and rebuild the Docker image.
Warning
You can only give a custom name to LScripts.pak
with the exact 8 letter size because it's a hack, for example, you can rename it to LScript3.pak
but you cannot name it MyLScripts.pak
.
-
Make sure the file has the right permissions:
-
Run the custom pak with this command:
Run a server with custom maps
Let's say you put a custom map to ${HOME}/my_pkserver/DM_K3Inzane.pkm
on your host machine:
-
Make sure the file has the right permissions:
-
Bind a map from the host to the Docker container with this command:
export MAP1_SOURCE="${HOME}/my_pkserver/DM_K3Inzane.pkm" docker run --rm --name ffa -itd -p 3455:3455/udp --mount type=bind,source=${MAP1_SOURCE},target=/opt/pkserver/Data/DM_K3Inzane.pkm docker.io/painkillergameclassic/pkserver:main
Note
You can't bind multiple maps with one command as Docker does not support wildcards with bind mounts. Each map should be bound one by one. It can be simplified with Docker Compose.
Run multiple containers based on the same image
You can run multiple server instances based on the same image by giving them different names and passing different ports. Setting the Cfg_ServerPort
variable is the most convenient way.
Let's say I want to launch 3 servers (FFA, DUEL, CTF) on different ports using the same Docker image. The examples are simplified since I don't pass any additional configuration variables and don't use any bind mounts:.
-
Run the containers:
-
Check the containers are running:
Check if there are failed containers:
-
Stop and delete all containers:
Using Docker volumes
This is an alternative to bind mounts. I find this way of managing the Painkiller server redundant since it will take additional space of your hard drive and prevents your configs from being "clean".
If you need your files to be persisted independently of the container state and bind mounts are not the option, you need to use the Docker volumes.
The default Docker anonymous volumes get deleted automatically when you remove the Docker container. That's not what you want.
Named Volumes allow you to save your server data independently of the container state and mount them to other containers.
-
Create a named volume:
-
Start a container with a volume:
-
Remove the resources:
It is not advised to copy and delete files directly from Docker volumes. In order to add files, you need to use the docker cp command. If you want to delete them, you'll need to log in to the container and remove the files from there.
Managing server logs and stats
This step is useful if you need to keep logs and you don't use Docker volumes.
To manage pkserver
logs and stats, you need to create a cron job that periodically copies LOG and TXT files from a container to your host.
pkserver
keeps them in the /opt/pkserver/Bin/
directory. In this example, we copy LOG and TXT files from the container with the ffa
name to the HOME
directory on the host:
docker exec ffa bash -c 'tar -cf - /opt/pkserver/Bin/*.log /opt/pkserver/Bin/*.txt' | tar --strip-components 3 -C ${HOME} -xvf -
Managing server resources
Resource limits
pkserver
has memory leaks. It is advised to set RAM limits on a container (250 MB for Duels and not more than 400 MB for FFA):
docker run --rm --name ffa -itd -p 3455:3455/udp --memory="400m" docker.io/painkillergameclassic/pkserver:main
Check the container stats:
Exit stats with Ctrl + C
.
Restart policy
Note
Restart policy option only works with docker
by default. If you want to use restart policies with podman
, you need to run podman generate systemd --restart-policy container_id
or find another workaround.
A restart policy is useful to keep your Docker containers up even after the system reboot.
Set the container restart policy:
docker run --name ffa -itd -p 3455:3455/udp --memory="400m" --restart always docker.io/painkillergameclassic/pkserver:main
If you container got stuck in a loop of restarts, run this command to turn off the restart policy:
Mini Dockerfile
This image is for rare cases. It is a solution when you want to run a "classic" server but you have issues with installing 32-bit packages on your system.
This is merely a wrapper around the pkserver
package. It is implied that you have the full pkserver package on your host and use Docker bind mounts to mount every folder to the Docker container.
-
Building:
-
Launching. Since the Linux Painkiller server has soft links, you need to bind them to the Docker container as well. Let's assume you installed
pkserver
to the${HOME}/pkserver/
directory:docker run --rm -it --name mini -p 3455:3455/udp --mount type=bind,source="${HOME}"/pkserver/Bin,target=/opt/pkserver/Bin --mount type=bind,source="${HOME}"/pkserver/bin,target=/opt/pkserver/bin --mount type=bind,source="${HOME}"/pkserver/Data,target=/opt/pkserver/Data --mount type=bind,source="${HOME}"/pkserver/data,target=/opt/pkserver/data pkserver-mini:v1
Debug a container
-
Pass the
+private
parameter to test Docker containers without exposing them to OpenSpy: -
To gracefully stop your server so that it won't dangle on OpenSpy, you need to attach to your container and run the
/quit
command:To detach again, press
CTRL + P
followed byCTRL + Q
. -
You can enter your container with:
To exit a container, type:
-
Enter a failed container by changing Docker entrypoint:
-
Log in to a container as a root:
You can then install all the required tools in the container with:
-
To ping from a container, you need to enable some container capabilities. Add
--cap-add=IPC_LOCK --cap-add=NET_RAW
as a command line options when starting a new container: -
To copy inside/outside containers use docker cp.
-
Clean your system from all Docker user packages: