Deploying a Counter-Strike: Source server with Ansible

The first COVID-19 lockdown in France took me by surprise during the preliminary step of a move to a new city: I was searching for a flat with a luggage only containing one week worth of clothes. Let’s say I was not exactly well prepared.

To avoid boredom I started playing a lot of relatively old video games with friends and family members, games that anyone could run even on not so recent hardware: Left4Dead: 2, Age of Empires II, Bzflag or Counter-Strike: Source. Here I will focus on the last one, showing how I automated its server deployment using Ansible.

Steam

Like many (all?) games developed using the source engine the dedicated server for Counter-Strike: Source is available to download using Steam. Fortunately this program is available on Linux and allows to download games on the command line. As the installation is bit wacky (32bits repository needs to be enabled on Debian for example) I looked for an existing role on the Internet and found an unfinished work which I took and improved as ansible-steamcmd.

Counter-Strike: Source Dedicated Server

With Steam installed I searched how to install the game server using the command line. It is not a comfortable tool to use: there is no man page and neither --help nor -h option. Using various online blogs and resources I devised the following incantation:

$ steamcmd +login anonymous +app_update 232330 +validate +quit
  1. +login anonymous means Steam won’t pester you with authentication ;
  2. app_update will update the game to its latest published version ;
  3. 232330 is the Steam AppID, it is discoverable using services such as https://steamdb.info or simply Valve wiki ;
  4. +validate check the integrity of downloaded files ;
  5. +quit exists after the installation is completed rather than hanging uselessly.

The downloaded files are located in a hidden directory in $USER’s home: ~/.steam/steamapps/common/. For ease of use I like to create a symbolic link to the current directory.

Again to ease maintenance I wrote an ansible role named ansible-role-cstrike-source wrapping it all up. The main problem I had was to handle the command line option +app_update in an idempotent fashion. Here is what I got to solve this problem:

- block:
  - name: Check if Counter-Strike Source dedicated server is installed
    stat:
      path: "/home/{{ steamcmd_user }}/.steam/steamapps/common/Counter-Strike Source Dedicated Server"
    register: __cstrikesource_exists

  - name: Install Counter-Strike Source dedicated server
    command: "{{ steamcmd_bin }} +login anonymous +app_update 232330 +validate +quit"
    when: not __cstrikesource_exists.stat.exists

  - name: Ensure Counter-Strike Source dedicated server is up to date
    command: "{{ steamcmd_bin }} +login anonymous +app_update 232330 +quit"
    register: __cstrikesource_update
    changed_when: "'already up to date' not in __cstrikesource_update.stdout"
    when: __cstrikesource_exists.stat.exists

Using the stat module I first check if the game server is already installed or not and depending on this condition I can either install or update. In the later case and because of the way the command module operates I implemented a custom mechanism to detect a change: logging the output of steamcmd to a variable and then checking its content.

Real fun

After a few days of playing my team mates and I became a bit bored with the default, vanilla offering. There was no automatic team rotation, no votes to choose the next map, etc…

So I looked around, wondering how it was possible to implement. This led to the discovery of the active mod scene for Source based games: https://www.alliedmods.net/.

Apparently at the beginning of the modding scene most people devised their own method of integrating their code in the game memory, leading to varying degrees of incompatibility between mods. To solve this problem Metamod:Source was created: a project written in C++ allowing a common API over the Source engine memory. The project must have been successful because a light search did not reveal a single C++ mod not using Metamod:Source that was still actively maintained. Later on I will use the term “plugins” for mods written with Metamod:Source.

Writing C++ code is not necessarily easy nor fun in the long run: modders are not all full time developers. Plus portability between Linux, macOS and Windows can be a pain to manage.

To solve this problem AlliedMods created a new plugin named SourceMod, responsible for handling mods written in a dedicated scripting language named SourcePawn. Today most mods are written using this language.

Of course I implemented the installation and upgrade of both Metamod:Source and SourceMod with ansible: ansible-role-metamod-source and ansible-role-sourcemod.

As a nice bonus Sourcemod comes with a few mods already, enabling players to vote for the next map among others.

Maps

Making new maps for a given game is probably one of the most popular modification ever, one just needs to compare the number of available mods to the number of maps on dedicated community sites like GameBanana: as of march 2021 I count ~610 mods versus ~41600 maps.

Counter-Strike: Source has a feature named “mapcycles”, a list of maps in rotation on the game server. An administrator can freely choose a different mapcycle at run time.

In order to handle this feature well I implemented mapcycles in my role using a structured variable. Here is an extract from my personal configuration, creating five additional mapcycles:

cstrike_source_extra_mapcycles:
  - name: deathrun # Deathrun are special mini-games maps with murderous intents
    content: |
      deathrun_goldfever_a
  - name: csgo # Maps ported from CS:GO to CS:S by mappers
    content: |
      cs_agency
      de_bank
      de_cache_csgo
      cs_compound_csgo
      de_fend
      cs_hijack
      de_lake_csgo
      cs_outpost
      de_pharaoh
      de_ruins
      de_safehouse
      de_zoo
  - name: scout # Maps for sniper fun
    content: |
      scout_shine
      scout_no_mercy
  - name: gg # Maps for the GunGame mod
    content: |
      gg_alleycat
      gg_factory
      gg_flyingdutchman
      gg_overpass
  - name: deathmatch # Map for CS:DM mod
    content: |
      de_dust2_unlimited

A nice addition is to provide a web server with compressed maps for the players, but this is outside the scope of my role and best handled in a playbook.

Playbook

Tying it all together here is a complete playbook:

- name: Configure Counter-Srike Source dedicated server.
  hosts: game
  tags: [ 'game', 'cstrike' ]
  vars:
    cstrike_source_motd: |
      <html>
      <head>
        <title>Counter-Strike: Ansible</title>
      </head>
      <body scroll="no">
        <pre>Ansible based cstrike server, yeah!</pre>
      </body>
      </html>
    cstrike_source_mapcycle:
      cs_italy
      de_dust
      de_aztec
      de_cbble
      cs_office
      de_chateau
      de_dust2
      de_piranesi
      cs_havana
      de_prodigy
      cs_compound
      de_train
      de_tides
      de_port
      de_inferno
      cs_assault
      de_nuke
      cs_militia
    cstrike_source_extra_mapcycles: [] # Define your owns !
    cstrike_source_server_cfg: |
      hostname "Ansible-based CS:S server"
      rcon_password "ansible"
      bot_prefix Puppet
    metamod_source_install_path: /home/steam/cstrike-source/cstrike
    sourcemod_admins_simple: # Configure the server administrator
      - identity: "{{ your_steam_id }}"
        flags: z
    sourcemod_plugins:
      - name: mapchooser
        state: enabled
      - name: funcommands
        state: disabled
      - name: funvotes
        state: disabled
  pre_tasks:
    - name: Ensure acl package is installed
      package:
        name: acl
        state: present
  roles:
    - role: ansible-role-cstrike-source
    - role: ansible-role-metamod-source
    - role: ansible-role-sourcemod

Changelog