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
pkserverbinary from People Can Fly is very limited. It starts with the hardcoded+dedicatedserver +map +portparameters which cannot be overriden and only accepts the+interfaceand+privatecommand line options. You are not able to pass custom-lscriptsand-cfg. However, it's possible hack thepkserverbinary to accept customLScripts.pakandconfig.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.iniwith the similar names. For example, you can pass theCfg_ServerPortvariable and it will override theCfg.ServerPortparameter in theconfig.inifile. -
Outdated
gamespy.comwas replaced withopenspy.netin 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.ymlfile 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
ffaname. This command will automatically pull the Docker image from Docker Hub and start a Docker container on your machine. The LAN port3578is 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:mainNote
--rmmeans that the container will be removed once you stop it.
--name ffaassignes theffaname to the container.
-itimproves the interaction with the container.
-ddetaches your container to the background so it does not occupy your terminal session.
-ppublishes 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 ffaCheck 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
--rmoption 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, hackpkserverbinary beforehand, add your own scripts, make amendments to the Dockerfile. -
Run the following command:
Note
dotis an indication that you build the Docker image from the Dockerfile in this directory.
-t/--tagis the name of your future local Docker image.
--no-cachedoes 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
ffaname: -
Check that the container with the server is running:
-
Stop the
ffacontainer:
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.inifrom 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.inion your host machine toconfig.iniin the Docker container:
Bind a custom host config to the custom pkserver config
Note
This hack is outdated.
-
Modify
${HOME}/my_pkserver/config.inito your needs and rename this file to something likeconctf.ini.Warning
When the
PKS_CFGvariable is used, you can only give a custom name toconfig.iniwith the exact 6 letter size because scripts in this image hacks the binary, for example, you can rename it toconffa.ini,conctf.inibut 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:mainIf you connect to the Docker container, there will be
conctf.iniinside the/opt/pkserver/Bindirectory. Thepkserverbinary will useconctf.iniinstead of the defaultconfig.inifile:
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:mainNote
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
pkserverto 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
+privateparameter 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
/quitcommand:To detach again, press
CTRL + Pfollowed 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_RAWas 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: