CS2 Mapveto

Webanwendung zur Organisation von Counter-Strike-2-Matches: Match-Lobbys mit Team-Einladungen, abwechselndes Map-Veto der Captains, Zuschauerzugang und automatische Verwaltung eines CS2-Servers pro Match.

  • Frontend: React 18 + Vite, deutschsprachig, responsives dunkles Design
  • Backend: Node.js (≥ 18.18), Express, Socket.IO
  • Datenbank: SQLite (better-sqlite3), Datei unter data/
  • Echtzeit: Socket.IO; jede Änderung schickt allen Teilnehmern ihren rollenabhängigen Match-Zustand
  • Serververwaltung: austauschbare Adapter hinter einer Schnittstelle (start, status, configure, stop)

Standardmäßig läuft der Demo-Adapter. Er startet keine echten CS2-Server. Oberfläche, Statusanzeigen und Verbindungsdaten sind dann überall als „Demo“ bzw. „simuliert“ gekennzeichnet. Wie die echte Anbindung eingerichtet wird, steht im Abschnitt Echte Server anbinden.


Schnellstart

Als Container (empfohlen für den Betrieb): ein Container mit Webseite, Backend und Datenbank, der die CS2-Server-Container auf demselben Docker-Host startet. Siehe docs/container.md:

docker -H ssh://USER@DOCKER-HOST-IP build -t cs2-mapveto:latest .

…dann den Stack aus der Anleitung in Portainer anlegen.

Zum Entwickeln:

npm install
cp .env.example .env      # optional, Standardwerte funktionieren für die Demo
npm run dev               # Backend auf :3000, Frontend auf :5173

Dann http://localhost:5173 öffnen.

Produktion (ein Prozess, Backend liefert das gebaute Frontend aus):

npm install
npm run build
PUBLIC_URL=https://veto.example.de npm start      # lauscht auf PORT (Standard 3000)

Tests:

npm test

Die Tests decken Veto-Engine und Ablaufvalidierung, RCON-Protokoll (gegen einen lokalen Test-RCON-Server), Befehls-Bereinigung und den kompletten API-Ablauf ab. Dazu gehören Berechtigungen, doppelte Aktionen, Server-Konfiguration mit Wiederholung, nächste Map, automatisches Herunterfahren und dass keine RCON-Daten an Clients gelangen.


Ablauf: von der Match-Erstellung bis zum Serverbeitritt

  1. Match erstellen (/neu): Der Organisator trägt beide Teamnamen ein und wählt Spielmodus (Wingman 2 gegen 2 oder Competitive 5 gegen 5), Serie (Best of 1/3/5) und wer das Veto beginnt (Münzwurf oder festes Team). Formate, für die der Map-Pool keinen gültigen Veto-Ablauf hat, sind ausgegraut und werden auch vom Backend abgelehnt. Der Browser des Organisators erhält einen geheimen Organisator-Token. Er wird lokal gespeichert und lässt sich über „Organisator-Zugang auf einem anderen Gerät öffnen“ übertragen.

  2. Server vorbereiten: Direkt beim Anlegen der Lobby fordert das Backend im Hintergrund einen Server an. Er fährt also schon während Lobby und Veto hoch. Der Status ist auf der Match-Seite live sichtbar.

  3. Einladungen: Das Match hat drei Zugangscodes (Format ABCD-EFGH) mit Links:

    • Team A: /join/<code>
    • Team B: /join/<code>
    • Zuschauer: /watch/<code> für Stream und Kommentatoren

    Diese Codes gelten nur für die Webseite. Der CS2-Serverzugang wird getrennt bereitgestellt (Schritt 6). Der Organisator kann jeden Code jederzeit neu erzeugen; der alte Link wird damit ungültig.

  4. Spieler treten bei: Über den Teamlink gibt ein Spieler seinen Namen ein (optional die SteamID64) und landet in genau diesem Team. Pro Team sind maximal 2 bzw. 5 Spieler erlaubt.

    • Der erste Spieler eines Teams wird Captain. Organisator oder Captain können den Captain wechseln.
    • Jeder Spieler setzt sich auf „bereit“. Ein Team ist bereit, sobald mindestens ein Spieler beigetreten ist, es einen Captain hat und alle beigetretenen Spieler bereit sind. Mit REQUIRE_FULL_TEAMS=true muss das Team zusätzlich vollständig sein (2 bzw. 5 Spieler).
  5. Map-Veto: Sind beide Teams bereit, startet der Organisator das Veto. Die Captains bannen und wählen abwechselnd nach dem festen Ablauf (siehe unten). Die Seite zeigt jederzeit:

    • welches Team am Zug ist
    • welche Aktion ansteht
    • den Zustand jeder Map (verfügbar, gebannt, gewählt, Entscheidungsmap)
    • den gesamten Verlauf mit Uhrzeit

    Die letzte verbleibende Map wird automatisch als Entscheidungsmap übernommen. Zuschauer und Organisator sehen alles live, können aber nicht wählen.

  6. Server konfigurieren: Nach dem Veto wird der bereits laufende Server automatisch eingerichtet: Spielmodus, erste Map, Teamnamen und Startseiten, Match-Einstellungen, Server- und CSTV-Passwort. Die restliche Map-Reihenfolge ist gespeichert.

    • Spieler sehen danach den kopierbaren Konsolenbefehl password …; connect host:port und einen steam://connect-Link.
    • Zuschauer sehen den CSTV-Zugang (eigener Port, eigenes Passwort, Verzögerung).
  7. Während der Serie: Der Organisator kann die Aufwärmphase beenden (mp_warmup_end), das laufende Match pausieren und fortsetzen (mp_pause_match, mp_unpause_match), die nächste Map laden, den Server stoppen und das Match abschließen oder abbrechen.

  8. Fehlerfälle: Startet der Server nicht, schlägt die Konfiguration fehl oder fällt der Server aus, zeigt die Seite den Grund. Der Organisator bekommt einen Knopf für den erneuten Versuch: „Server neu starten“ bzw. „Konfiguration wiederholen“.

Rollen und Berechtigungen

Rolle Zugang über Darf
Organisator Token beim Erstellen Veto starten, Spieler entfernen, Captain setzen, Codes erneuern, Server neu starten/stoppen/konfigurieren, pausieren/fortsetzen, nächste Map, Match beenden; sieht Einladungen, Spieler- und CSTV-Verbindung, die Match-Übersicht
Captain Teamcode (erster Spieler oder ernannt) alles wie Spieler, dazu Veto-Aktionen seines Teams und Captain-Übergabe
Spieler Teamcode bereit melden, Team verlassen; sieht die Spieler-Verbindungsdaten nach dem Veto
Zuschauer Zuschauercode alles live verfolgen; sieht nur den CSTV-Zugang, nicht das Spielerpasswort
Gast ohne Code nur Teamnamen und Status

Organisator spielt selbst mit: In der Lobby kann der Organisator unter „Selbst mitspielen?“ direkt einem Team beitreten, oder er öffnet einen Teamlink im selben Browser. Er bleibt Organisator und ist zusätzlich Spieler bzw. Captain. Ein zweiter Browser ist nicht nötig. Technisch speichert der Browser dafür beide Zugänge und schickt den Spieler-Token im Header X-Player-Token mit. Pro Aufrufer zählt höchstens ein Spieler.

Alle Prüfungen passieren im Backend. Die Oberfläche blendet nur zusätzlich aus, was die Rolle nicht darf.

Schutzmaßnahmen

  • Fremde Teambeitritte: Jeder Code gehört zu genau einem Team eines Matches. Zuschauercodes erlauben keinen Beitritt. Volle Teams, doppelte Namen und doppelte Steam-IDs werden abgelehnt. Codebasierte Endpunkte sind pro IP begrenzt (20 Versuche pro Minute).
  • Doppelte Aktionen: Jede Veto-Aktion trägt die Nummer des Schritts. Sie wird in einer SQLite-Transaktion geprüft und gespeichert; (match_id, idx) ist Primärschlüssel. Ein doppelter Klick oder ein veralteter Stand liefert „Diese Aktion ist veraltet“. Serveraktionen sind pro Match gesperrt, solange eine läuft.
  • Unberechtigte Befehle: Veto nur durch den Captain des Teams am Zug, Server- und Match-Befehle nur durch den Organisator.
  • Tokens: Organisator- und Spieler-Tokens sind 256 Bit zufällig und werden nur als SHA-256-Hash gespeichert.
  • Server-Zugangsdaten: RCON-Passwort, Container-Handle und GSLT liegen ausschließlich in der Datenbank bzw. den Umgebungsvariablen des Backends. Die Snapshot-Funktion gibt sie nie aus (per Test abgesichert). Server- und CSTV-Passwort sind als Beitrittsdaten nötig und werden nur an die jeweils berechtigte Rolle geliefert.
  • Befehls-Injection: Teamnamen werden vor der Übergabe an die Serverkonsole von Anführungszeichen, ; und Steuerzeichen bereinigt. Passwörter sind rein alphanumerisch.

Chat im Match

Auf der Match-Seite gibt es einen Chat mit zwei Reitern über der Chatbox:

  • Match-Chat: beide Teams und der Organisator.
  • Team-Chat: nur die Spieler des eigenen Teams, z. B. für Absprachen im Veto. Der Organisator sieht ihn nur, wenn er selbst im Team mitspielt.

Zuschauer und Gäste sehen keinen Chat. Er gilt das ganze Match über, von der Lobby bis zum Ende. Danach ist er nur noch lesbar. Nachrichten haben höchstens 300 Zeichen, pro Person gehen höchstens 5 Nachrichten in 10 Sekunden. Ein roter Punkt am Reiter zeigt ungelesene Nachrichten.

Wiederherstellung nach Neuladen oder Verbindungsabbruch

Der gesamte Zustand liegt in SQLite. Der Browser speichert pro Match nur seinen Zugangstoken (localStorage).

  • Neuladen der Seite: Die Seite lädt den Stand per REST und abonniert danach den Socket.
  • Abgebrochene Socket-Verbindung: Nach dem Reconnect meldet sich der Client automatisch neu an und erhält den vollständigen aktuellen Stand. Socket-Nachrichten enthalten immer den kompletten Zustand und keine Deltas, deshalb geht nichts verloren.
  • Backend-Neustart: Unterbrochene Serverstarts werden als fehlgeschlagen markiert (mit „Server neu starten“). Unterbrochene Konfigurationen werden automatisch wiederholt.

Automatisches Herunterfahren

Alle SERVER_REAPER_INTERVAL_SECONDS prüft das Backend alle aktiven Server:

  • Ein Match ist abgeschlossen oder abgebrochen: Server wird gestoppt.
  • Lobby, Veto oder Server ohne Spielerzahl: Ist niemand mit der Match-Seite verbunden und gab es länger als SERVER_IDLE_TIMEOUT_MINUTES keine Aktivität, wird der Server gestoppt.
  • Konfigurierter echter Server: Gestoppt wird, wenn länger als die Frist keine Spieler auf dem Server waren. Die Spielerzahl kommt per RCON aus status.
  • Unabhängig davon: Nach SERVER_MAX_LIFETIME_HOURS wird jeder Server gestoppt.

Ein gestoppter Server lässt sich vom Organisator neu starten.


Map-Pools und Veto-Abläufe

Konfiguration in server/config/map-pools.json (Pfad änderbar über MAP_POOL_FILE). Jeder Pool hat:

  • eine eigene Map-Liste mit ID, Anzeigename und Kartenfarbe
  • Teamgröße und game_type/game_mode
  • je Format einen Ablauf

Schritte im Ablauf:

Schritt Bedeutung
ban:X / ban:Y Team X bzw. Y bannt eine Map
pick:X / pick:Y Team X bzw. Y wählt eine Map für die Serie
side:X / side:Y Team wählt die Startseite (CT/T) auf der zuletzt gewählten Map bzw. der Entscheidungsmap
decider die letzte verbleibende Map wird automatisch übernommen

X ist das Team, das das Veto beginnt (Münzwurf oder vom Organisator festgelegt), Y das andere.

Beim Start wird jeder Ablauf geprüft:

  • Anzahl Picks = Maps der Serie − 1
  • Bans + Picks + 1 = Pool-Größe
  • genau eine Entscheidungsmap
  • Seitenwahl nur direkt nach einem Pick oder der Entscheidungsmap

Passt kein hinterlegter Ablauf zur Map-Anzahl, erzeugt die Anwendung einen (siehe unten). Unauswählbar ist ein Format nur, wenn es zu wenige Maps gibt (mit Begründung in der Oberfläche).

Maps im Admin-Bereich wählen

Unter Einstellungen → Map-Pools wählt der Admin pro Modus, welche Maps im Veto stehen (mindestens 2). Die Auswahl steht in der Datenbank und gilt für neue Matches. Laufende Matches behalten ihre Maps und ihren Ablauf. Maps mit "active": false in der Konfiguration stehen im Katalog, sind aber erst nach Auswahl aktiv.

Ablauf passend zur Map-Anzahl: Passt ein hinterlegter Ablauf zur Anzahl der gewählten Maps, gilt er. Sonst erzeugt die Anwendung einen Ablauf:

  1. bis zu zwei Bans (X, dann Y),
  2. die Picks der Serie im Wechsel, die Seite wählt jeweils das andere Team,
  3. die restlichen Bans im Wechsel,
  4. die letzte Map ist die Entscheidungsmap, ihre Seite wählt das Team, das nicht zuletzt dran war.

Jede weitere Map bedeutet also einen Ban mehr. BO3 braucht mindestens 3 Maps, BO5 mindestens 5. Der Admin-Bereich zeigt nach dem Speichern den Ablauf für jedes Format.

Beispiel Competitive mit allen 10 Maps, BO3: X bannt, Y bannt, X pickt (Y Seite), Y pickt (X Seite), X, Y, X, Y, X bannen → Entscheidungsmap, Y wählt Seite.

Mitgelieferte Abläufe

Competitive (Katalog mit 10 Maps): Ancient, Anubis, Cache, Dust II, Inferno, Mirage, Nuke, Overpass, Train, Vertigo. Alle 10 sind aktiv. Die festen Abläufe unten gelten für die klassischen 7 Maps (Ancient, Anubis, Dust II, Inferno, Mirage, Nuke, Train), bei jeder anderen Auswahl erzeugt die Anwendung den Ablauf.

Format (7 Maps) Ablauf
BO1 X bannt, Y bannt, X bannt, Y bannt, X bannt, Y bannt → Entscheidungsmap, X wählt Seite
BO3 X bannt, Y bannt, X pickt (Y wählt Seite), Y pickt (X wählt Seite), X bannt, Y bannt → Entscheidungsmap, X wählt Seite
BO5 X bannt, Y bannt, X pickt (Y Seite), Y pickt (X Seite), X pickt (Y Seite), Y pickt (X Seite) → Entscheidungsmap, Y wählt Seite

Wingman (4 Maps): Inferno, Nuke, Overpass, Vertigo

Format Ablauf
BO1 X bannt, Y bannt, X bannt → Entscheidungsmap, Y wählt Seite
BO3 X bannt, Y pickt (X Seite), X pickt (Y Seite) → Entscheidungsmap, X wählt Seite
BO5 nicht unterstützt: Mit 4 Maps sind keine 5 Karten möglich. Mit mindestens 5 Maps wird es automatisch auswählbar.

Regel für Startseiten

  • Auf einer gewählten Map wählt immer das Gegnerteam des wählenden Teams die Startseite.
  • Auf der Entscheidungsmap wählt das Team, das im Ablauf dafür vorgesehen ist. In BO1 ist das in Competitive Team X, weil Y den letzten Ban hatte, und in Wingman Team Y. Ohne side-Schritt werden die Seiten im Spiel festgelegt.

Die Wahl wird als mp_teamname_1 (Team mit CT-Start) und mp_teamname_2 (T-Start) an den Server übergeben.

Bilder für Maps und Seitenwahl

  • Map-Bilder liegen lokal unter client/public/maps/<map>.webp (960 px breit, je ca. 50–90 KB). "image" in map-pools.json verweist darauf. Neue oder fehlende Bilder lädt npm run images von ghostcap-gaming/cs2-map-images, verkleinert sie und trägt den Pfad ein (npm run images -- --force lädt alle neu). Die Bilder stammen aus dem Spiel; die Rechte liegen bei Valve. Ohne Bild zeigt die Kachel ein Muster in der Map-Farbe.
  • Seiten-Embleme für CT und T liegen unter client/public/sides/ct.png und client/public/sides/t.png (quadratisch, z. B. 256×256). Fehlt eine Datei, erscheint ein schlichtes Ersatzsymbol.
  • Logo und Farben: Das Maxlan-e.V.-Logo liegt freigestellt unter client/public/brand/maxlan-logo.webp. Die Farben stehen als Variablen am Anfang von client/src/styles.css: Logo-Blau #344A99 für Team A und Hauptbuttons, Logo-Rot #A72A1C für Team B und den Abschluss der Kopfzeile, jeweils aufgehellt für die Lesbarkeit.
  • Bestehende Matches behalten die Map-Liste, mit der sie erstellt wurden. Neue Bilder erscheinen in neu erstellten Matches.

Die mitgelieferten Map-Pools entsprechen dem Stand der Entwicklung. Valve ändert den aktiven Map-Pool regelmäßig. Map-IDs bitte gegen die eigene Serverinstallation prüfen. Eine Map, die nicht existiert, führt zu „Konfiguration fehlgeschlagen“ mit Meldung.


Echte Server anbinden

Schritt-für-Schritt mit Portainer: docs/portainer-cs2.md beschreibt, wie du einen CS2-Server als Portainer-Stack aufsetzt, dessen Spieldateien nur einmal heruntergeladen werden, und ihn mit CS2 Mapveto verbindest.

Die Serververwaltung ist hinter server/src/servers/adapter.js gekapselt. Auswahl über SERVER_ADAPTER.

Ablauf, den jeder Adapter bedient

  1. start({ matchId, mode, secrets }): Server starten bzw. reservieren.

    • Rückgabe: { handle, host, port, tvPort }.
    • secrets (RCON-, Server- und CSTV-Passwort) erzeugt das Backend pro Match.
  2. status(handle): liefert starting | running | stopped | failed, optional mit Spielerzahl.

    • Wird alle SERVER_STATUS_POLL_SECONDS abgefragt, solange der Server startet, danach alle 30 s.
  3. configure(handle, plan): in zwei Phasen.

    1. game_type, game_mode, sv_password, tv_enable, tv_delay, tv_password, dann changelevel <map>.
    2. Warten, bis status die Map meldet (max. 2 Minuten), dann die Match-Einstellungen (mp_teamname_1/2, mp_maxrounds, mp_overtime_enable, mp_match_can_clinch, mp_warmup_pausetimer 1, CS2_EXTRA_COMMANDS) und die Turnierregeln (siehe unten).

    Die zweite Phase kommt erst nach dem Mapwechsel, weil die gamemode_*.cfg des Spiels diese Werte beim Laden der Map sonst überschreibt.

Turnierregeln: Jeder konfigurierte Server setzt außerdem diese Werte, die sich an FACEIT- und ESL-Richtwerten orientieren:

Befehl Wert Zweck
sv_pausable 1 erlaubt Pausen (mp_pause_match)
mp_halftime 1 Seitenwechsel zur Halbzeit
mp_freezetime 20 Freezetime in Sekunden
mp_buytime 20 Kaufzeit in Sekunden
mp_startmoney 800 Startgeld
mp_roundtime 1.92 Rundenzeit in Minuten (1:55)

Die Werte stammen nicht aus einer Regel eines konkreten Turniers. Vor einem echten Turnier bitte mit dem Regelwerk des Veranstalters abgleichen und in server/src/servers/cs2Config.js anpassen. Ob sv_pausable und die Pause-Befehle am echten Server wirken, ist noch nicht getestet. 4. stop(handle): Server beenden bzw. freigeben.

Option A: Docker (SERVER_ADAPTER=docker)

Startet pro Match einen Container mit dem Community-Image joedwards32/cs2 (SteamCMD, CS2-Dedicated-Server App 730; gepflegter Nachfolger von cm2network/cs2).

Voraussetzungen:

  1. Docker auf dem Backend-Host. Das Backend ruft die Docker-CLI auf und braucht dafür Zugriff auf den Docker-Daemon.
  2. Game Server Login Token für App-ID 730 unter https://steamcommunity.com/dev/managegameservers erstellen und als CS2_GSLT eintragen. Ohne GSLT sind Server nicht öffentlich erreichbar. Pro gleichzeitig laufendem Server ist ein eigenes Token empfehlenswert.
  3. CS2_PUBLIC_HOST: IP oder DNS-Name, unter dem Spieler den Host erreichen.
  4. Ports CS2_PORT_RANGE_START–CS2_PORT_RANGE_END in Firewall bzw. Router freigeben:
    • pro Match zwei aufeinanderfolgende Ports
    • Spielport per UDP und TCP (TCP wird für RCON genutzt, nur für das Backend freigeben)
    • CSTV-Port per UDP
  5. Etwa 60 GB Speicher für die Spieldateien im Volume CS2_DOCKER_VOLUME. Das Volume wird von allen Match-Containern geteilt. Der erste Start lädt das Spiel herunter und kann deutlich länger als 10 Minuten dauern (CS2_START_TIMEOUT_MINUTES). Spieldateien am besten vorab mit einem einzelnen Container laden, bevor mehrere Matches gleichzeitig starten.

Container heißen cs2mv-<matchId> und tragen das Label cs2-mapveto=1. Stoppen bedeutet docker rm -f.

Mehrere Docker-Hosts

Mit CS2_DOCKER_HOSTS verteilt die Anwendung Matches auf mehrere Docker-Hosts. Jedes neue Match läuft auf dem Host mit den meisten freien Ports. Der Host bleibt im Match gespeichert, Status, Konfiguration und Stopp gehen immer an denselben Host.

  1. Jeden Host einrichten (SSH-Schlüssel, Docker, Volume, Image):

    bash deploy/docker-host-einrichten.sh cs2@192.168.178.111
    
  2. Die Hosts in der .env eintragen, als JSON-Liste in einer Zeile:

    CS2_DOCKER_HOSTS=[{"name":"host1","dockerHost":"ssh://cs2@192.168.178.111","publicHost":"192.168.178.111","portStart":27100,"portEnd":27139},{"name":"host2","dockerHost":"ssh://cs2@192.168.178.112","publicHost":"192.168.178.112","portStart":27100,"portEnd":27139}]
    

Jeder Host braucht eigene Spieldateien (rund 60 bis 90 GB). Die Ports müssen auf jedem Host frei sein.

Messer-Server für die Seitenwahl

Im Admin-Bereich (Einstellungen → Gameserver → Messer-Server) startet „Messer-Server starten“ einen Gameserver, der keinem Match gehört und dauerhaft läuft. Teams verbinden sich, spielen eine Messerrunde und gehen wieder.

  • Modus wählbar: Competitive 5v5 (12 Plätze, Maps aus dem Competitive-Pool) oder Wingman 2v2 (6 Plätze, Maps aus dem Wingman-Pool).
  • Offen, ohne Passwort, sichtbar im LAN-Reiter von Steam (Maxlan | Messer 1 5v5 (Seitenwahl)) und auf der Startseite mit „Verbinden“.
  • Nur Messer, kein Geld, eine Runde. Vorher läuft eine Aufwärmphase zum Verbinden (KNIFE_WARMUP_SECONDS, Standard 90). Nach der Runde startet das Spiel von selbst neu (mp_match_end_restart). Wird der Server leer, setzt die App ihn zusätzlich zurück.
  • Fällt ein Messer-Server aus, startet die App ihn neu, höchstens fünfmal in Folge. Danach steht er auf „ausgefallen“ und lässt sich im Admin-Bereich neu starten.
  • Knöpfe pro Server: „Runde starten“ (Aufwärmphase sofort beenden), „Zurücksetzen“, „Neu starten“ (neuer Container), „Entfernen“.
  • Messer-Server belegen ihre Ports dauerhaft und nehmen zuerst LAN-Ports. Jeder Messer-Server ist also ein Platz weniger im LAN-Reiter für Matches.
  • Im Match: Wählt der Organisator beim Erstellen „Wer beginnt das Veto?“ → Messerrunde, zeigt die Lobby beiden Teams die freien Messer-Server (zuerst die mit passender Teamgröße) mit „Verbinden“. Nach der Runde klickt der Organisator statt „Veto starten“ auf „Team A beginnt“ bzw. „Team B beginnt“. Das gewählte Team beginnt das Veto. Im Admin-Bereich gibt es dieselben zwei Knöpfe in der Matchkarte.
  • Weitere Einstellungen: KNIFE_MAP (Standard-Map, muss in einem Map-Pool sein), KNIFE_MAX_SERVERS (Standard 4).

Server im Steam-Serverbrowser (LAN-Reiter)

Der LAN-Reiter des Steam-Serverbrowsers (Steam → Ansicht → Spielserver → LAN) fragt nur die Ports 27015 bis 27020 ab. Deshalb vergibt die App den Spielport zuerst aus CS2_LAN_PORTS (Standard 27015-27020), danach aus dem normalen Portbereich. Der CSTV-Port liegt immer 100 über dem Spielport (Spiel 27015, CSTV 27115; Spiel 27100, CSTV 27200), der Abstand lässt sich mit CS2_TV_PORT_OFFSET ändern. Auf jedem Host müssen also auch die Ports 100 über dem Portbereich frei sein. So passen bis zu sechs Server pro Host in den LAN-Reiter. Weitere Server laufen im normalen Portbereich. Sie sind dann nicht im LAN-Reiter sichtbar, Spieler erreichen sie aber über den Verbindungsbefehl.

  • Pro Host lassen sich die LAN-Ports in CS2_DOCKER_HOSTS mit "lanPorts":"27016-27019" einschränken, wenn dort schon etwas anderes auf einem dieser Ports läuft. "lanPorts":"" schaltet es für einen Host ab. Im Admin-Bereich steht das in der Spalte „LAN-Ports (Steam)“.
  • Ist ein Port trotzdem belegt, versucht der Start automatisch die nächsten freien Ports.
  • Server auf LAN-Ports laufen im Netz des Hosts (--network host), weil die Port-Weiterleitung von Docker die Suchanfragen (Broadcasts) des Serverbrowsers nicht weitergibt. Die Firewall des Hosts muss die Ports deshalb selbst erlauben.
  • Der Servername im Browser ist Maxlan | Team A vs. Team B. Den Anfang ändert CS2_SERVERNAME_PREFIX.
  • Die Server sind mit Passwort geschützt und zeigen ein Schloss. Ohne CS2_GSLT laufen sie mit sv_lan 1 und erscheinen nicht im Internet-Reiter.

Option B: Vorhandene Server (SERVER_ADAPTER=static)

Für selbst betriebene, dauerhaft laufende CS2-Server:

SERVER_ADAPTER=static
CS2_STATIC_SERVERS=203.0.113.10:27015:rconPasswort1:27020,203.0.113.10:27025:rconPasswort2:27030

Auf jedem Server müssen gesetzt sein:

  • rcon_password
  • CSTV-Port tv_port, falls abweichend von Spielport + 5 (dieser Wert wird angenommen, wenn kein vierter Teil angegeben ist)
  • ein GSLT (sv_setsteamaccount)

Bei diesem Adapter bedeutet „Start“: einen freien Server reservieren und per RCON prüfen. „Stopp“ gibt ihn frei und setzt ein neues zufälliges sv_password. Der Serverprozess selbst wird nicht beendet.

Eigener Adapter (z. B. Hosting-API)

  1. Klasse mit name, simulated = false und den vier Methoden anlegen. Vorlage: dockerAdapter.js. Für die Konfiguration per RCON gibt es configureViaRcon aus rconConfigure.js.
  2. In createServerAdapter in adapter.js registrieren.
  3. Zugangsdaten der Hosting-API ausschließlich über Umgebungsvariablen in config.js einlesen.

Verbindung für Spieler

password <serverpasswort>; connect <host>:<port>

Zusätzlich bietet die Seite einen steam://connect/<host>:<port>/<passwort>-Link an.

Zuschauer: CSTV (früher GOTV)

CS2 bringt CSTV mit (in der Konsole weiterhin tv_*-Befehle; früher GOTV). Die Anwendung setzt:

  • tv_enable 1 (greift erst nach dem Mapwechsel, deshalb vor changelevel)
  • tv_delay (Standard 90 s) als Verzögerung gegen Ghosting
  • tv_password als eigenes Passwort, getrennt vom Spielerpasswort

Zuschauer verbinden sich mit password <tvpasswort>; connect <host>:<tvPort>. Sie belegen keinen Spielerplatz und können die Spieler nicht beeinflussen. Für größere Zuschauerzahlen unterstützt CS2 CSTV-Relays (tv_relay) und HTTP-Broadcast (tv_broadcast_url). Beides ist nicht eingebaut und kann über CS2_EXTRA_COMMANDS bzw. die Serverkonfiguration ergänzt werden.

Prüfstand der CS2-Angaben

Die offizielle Seite Counter-Strike 2/Dedicated Servers im Valve Developer Wiki war während der Entwicklung wegen eines Bot-Schutzes nicht automatisiert abrufbar. Grundlage waren:

Bekannte Werte: game_type 0 + game_mode 1 = Competitive, game_mode 2 = Wingman; CSTV-Standardport 27020.

Nicht gegen einen echten CS2-Server getestet: Docker- und Static-Adapter sowie die Auswertung der status-Ausgabe (Map-Name, Spielerzahl). Das RCON-Protokoll ist gegen einen Test-Server geprüft. Vor dem ersten echten Einsatz bitte ein Testmatch durchspielen und bei Bedarf parseStatus in server/src/servers/rcon.js an die tatsächliche Ausgabe anpassen.

Bekannte Grenzen von Vanilla-CS2

  • Ohne Server-Plugin kann CS2 Spieler nicht automatisch einem Team zuordnen. Spieler wählen ihr Team im Spiel selbst. Teamnamen und Startseiten setzt die Anwendung. Für feste Teamzuordnung, Pausen und automatischen Mapwechsel eignen sich Plugins wie MatchZy oder Get5-Nachfolger. Sie lassen sich über einen eigenen Adapter anbinden; die SteamID64 der Spieler wird dafür bereits erfasst.
  • Das Team eines Spielers ergibt sich aus der Seite, die er im Spiel wählt. Wechselt ein Spieler ins falsche Team, zählen seine Runden für das falsche Team.

Live-Ergebnisse

Beim Konfigurieren setzt CS2 Mapveto auf dem Server log on und logaddress_add_http "<LOG_RECEIVER_URL>/api/logs/<match>/<schlüssel>". Der CS2-Server schickt damit seine Logzeilen per HTTP an das Backend. Der Schlüssel ist pro Match und Serverstart zufällig, Zeilen mit falschem Schlüssel werden abgelehnt.

  • Was ausgewertet wird:
    • Matchstart nach der Aufwärmphase
    • jedes Rundenende mit Sieger und Grund (Bombe, Entschärfung, Zeit, Ausschalten)
    • die Seitenzuordnung der Teams
    • das Spielende
  • Was angezeigt wird:
    • Live-Spielstand und Rundenverlauf pro Map
    • Serienstand
    • Ergebnisse in der Übersicht
    • Stream-Seite
  • Nach dem Spielende: Ist die Serie noch offen, lädt CS2 Mapveto nach SERIES_AUTO_ADVANCE_SECONDS (Standard 30) die nächste Map. Ist sie entschieden (z. B. 2:0 im Best of 3), wird das Match abgeschlossen und der Server freigegeben. Mit -1 geht beides nur manuell.
  • Voraussetzung: Der CS2-Server muss LOG_RECEIVER_URL erreichen, im LAN z. B. http://192.168.178.31:3000. Ob Daten ankommen, zeigt das Serverpanel des Organisators. Mit LOG_DEBUG=true gibt das Backend alle empfangenen Zeilen aus.
  • Geprüft: mit echten Logzeilen eines CS2-Servers (Wingman), anonymisiert in server/test/fixtures/wingman-anonym.log.

Zwei Eigenheiten von CS2 berücksichtigt der Parser:

  • CS2 sendet Match_Start schon beim Laden der Map. Gezählt wird erst, nachdem der Organisator „Aufwärmphase beenden“ geklickt hat.
  • Direkt nach der Halbzeit nennt CS2 zu Rundenbeginn noch die alte Seitenzuordnung. Ausgewertet werden deshalb nur die Zeilen direkt nach einem Rundenende.

Stream-Übersicht aller Matches

Unter /stream (in der Kopfzeile verlinkt) zeigt eine 1920×1080-Seite alle laufenden Vetos und Matches gleichzeitig, live aktualisiert:

  • Karten: eine Karte pro Match.
    • Im Veto: wer am Zug ist, Fortschritt, Map-Board.
    • Im Match: aktuelle Map mit Live-Stand, CT/T-Seiten und Serienstand.
  • Raster: passt sich der Anzahl an (1, 2, 4 oder 6 Karten). Bei mehr als 6 Matches blättert die Seite alle 12 Sekunden weiter.
  • Unten: offene Lobbys und die Ergebnisse der letzten drei Stunden.
  • Datenschutz: Codes und Verbindungsdaten erscheinen nicht.
  • Einzelnes Match: Ein Klick auf eine Karte oder einen Eintrag öffnet die Übersicht nur dieses Matches unter /stream/match/<matchId>. Der Link enthält keinen Zuschauercode, der Schlüssel ?key= wird übernommen.
  • Schlüssel: Mit STREAM_KEY ist die Seite nur mit /stream?key=<schlüssel> erreichbar, sonst offen. ?bg=transparent funktioniert ebenfalls.

Stream-Übersicht eines Matches

Unter /stream/<Zuschauercode> gibt es eine Vollbild-Übersicht für den Stream. Den Link findet der Organisator bei den Einladungen.

Für die Übersicht eines einzelnen Matches ohne Zuschauercode gibt es /stream/match/<matchId>. Der Organisator findet den Link unter „Match-Übersicht“ im Bereich Einladungen. Ist STREAM_KEY gesetzt, hängt der Link den Schlüssel an, damit Organisatoren die Seite öffnen können.

  • Format: 1920×1080, skaliert auf jede Fenstergröße. In OBS als Browserquelle mit 1920×1080 einbinden. Mit ?bg=transparent ist der Hintergrund durchsichtig.
  • Ablauf: Die Seite wechselt automatisch zwischen Lobby (Aufstellungen), Map-Veto (live) und Match (Spielstand, Rundenverlauf, Map-Leiste).
  • Laufband: zeigt das letzte für Zuschauer relevante Ereignis, keine technischen Servermeldungen.
  • Datenschutz: Verbindungsdaten und Passwörter erscheinen dort nie.

Discord-Meldungen für Streamer

Mit DISCORD_WEBHOOK_URL schickt die Anwendung kurze Meldungen in einen Discord-Kanal:

  • Veto gestartet
  • Match gestartet (Aufwärmphase beendet)
  • Map beendet mit Ergebnis
  • Serie entschieden
  • Match abgeschlossen oder abgebrochen
  • Serverfehler

Jede Meldung enthält einen Link auf die Match-Übersicht (/stream/match/<id>). Ist STREAM_KEY gesetzt, entfällt der Link, damit der Schlüssel nicht im Kanal landet. Ein Fehler beim Senden bremst den Matchablauf nicht. Er wird nur im Log vermerkt. Die Webhook-URL enthält ein Geheimnis und erscheint nie in Logs oder Oberflächen.

Webhook anlegen: Discord-Kanal → Einstellungen → Integrationen → Webhooks → Neuer Webhook, dann die URL kopieren.

Welche Ereignisse gemeldet werden, wählt der Admin im Admin-Bereich unter „Discord-Meldungen“. Die Auswahl wird in der Datenbank gespeichert. Die Webhook-URL bleibt in der Konfiguration, weil sie ein Geheimnis ist. Die Schaltfläche „Testmeldung senden“ prüft die Verbindung.

Hinter einem Reverse-Proxy

Die Webseite lässt sich hinter einen Reverse-Proxy wie nginx, Caddy oder Traefik stellen, etwa um sie per HTTPS unter einer eigenen Adresse anzubieten.

  • Eigene (Sub-)Domain, kein Unterpfad. Die Anwendung nutzt absolute Pfade (/api, /socket.io, /stream, /admin). Unter einem Unterpfad wie example.de/veto/ funktioniert sie nicht. Nimm eine eigene Subdomain, z. B. veto.example.de.
  • WebSockets durchreichen. Die Live-Updates laufen über Socket.IO. Der Proxy muss den Upgrade-Header weitergeben, sonst bleibt die Seite bei „Verbinde mit Lobby“ hängen.
  • .env anpassen:
    • PUBLIC_URL=https://veto.example.de, damit Einladungs- und Stream-Links stimmen
    • TRUST_PROXY=true, damit die Begrenzung der Anmeldeversuche die echte Client-IP sieht und nicht die des Proxys
  • Nicht über den Proxy:
    • Die CS2-Spielverbindungen laufen per UDP direkt zum Docker-Host. Für Spieler aus dem Internet braucht es dort Portfreigaben und einen GSLT (CS2_GSLT).
    • Die Logs der CS2-Server können weiter über das LAN kommen. LOG_RECEIVER_URL bleibt dann z. B. http://192.168.178.31:3000.
  • Vor dem Freigeben absichern: Öffentlich erreichbar sind dann auch /admin und, ohne Schlüssel, die Stream-Übersicht. Setze ein starkes ADMIN_PASSWORD und einen STREAM_KEY.

Beispiel für nginx:

server {
    listen 443 ssl http2;
    server_name veto.example.de;

    ssl_certificate     /etc/letsencrypt/live/veto.example.de/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/veto.example.de/privkey.pem;

    location / {
        proxy_pass http://192.168.178.31:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_read_timeout 1h;
    }
}

Ein 502 Bad Gateway vom Proxy heißt, dass er die Anwendung unter proxy_pass nicht erreicht: Sie läuft nicht, oder Adresse bzw. Port stimmen nicht.


Admin-Bereich

Unter /admin mit dem Passwort aus ADMIN_PASSWORD (leer = Admin-Bereich aus). Die Anmeldung gilt 12 Stunden und nur, solange die Anwendung läuft. Nach einem Neustart der Anwendung muss man sich neu anmelden.

Reiter „Matches“

  • Suche und Filter: Suche nach Team, Spieler oder Match-ID. Filter Alle, Laufend, Fehler (Zahl rot, wenn es Fehler gibt), Abgeschlossen und Archiv. Der Filter bleibt im Browser gespeichert.
  • Matchkarte: Teams, Modus, Format, Status, Live-Stand, Server- und CSTV-Adresse (Server 192.168.178.45:27015 · CSTV …:27115), Zeitpunkt der Server-Anforderung, Pausen-Abzeichen. Bei einem Serverfehler steht die Ursache rot in der Karte.
  • Aktionen: Ansehen, Veto starten, Aufwärmphase beenden, Pausieren/Fortsetzen, Nächste Map, Server neu starten / wiederholen, Server stoppen, Match abschließen, Match abbrechen, Archivieren / Aus dem Archiv holen, Löschen. Destruktive Aktionen fragen nach.
  • Konsole: Knopf „Konsole“ in der Matchkarte und bei jedem Messer-Server. Zeigt die Ausgabe des Containers live (docker logs --follow, die letzten 300 Zeilen und alles Neue), mit Filter, „Mitlaufen“, „Leeren“ und „Neu verbinden“. Passwörter des Servers sind ausgeblendet. Höchstens 8 Konsolen gleichzeitig.
  • Details: Einladungslinks und Codes (erneuerbar), Verbindungsbefehle für Spieler und CSTV, Spielerliste mit „Entfernen“, Rollback der laufenden Map.
  • Archiv: abgeschlossene oder abgebrochene Matches lassen sich archivieren. Nach ARCHIVE_AFTER_DAYS Tagen (Standard 3, 0 = nie) geschieht das automatisch. Archivierte Matches stehen nur unter dem Filter „Archiv“.

Startet man den Server eines laufenden Matches neu, lädt die Anwendung danach die aktuelle Map samt Passwort wieder. Ein frisch gestarteter Server hat die volle Leerlauf-Frist, bevor er automatisch heruntergefahren wird.

Reiter „Einstellungen“

Die Einstellungen sind in eine Seitenleiste gegliedert: Veto (Map-Pools), Gameserver (Match-Regeln, CSTV, Zeiten, Messer-Server), Server-Betrieb (Docker-Hosts und Image-Update) und Benachrichtigungen (Discord). Am Handy stehen die Menüpunkte als Knöpfe über dem Inhalt. Der zuletzt gewählte Punkt bleibt im Browser gespeichert.

  • Messer-Server: starten (Modus 5v5 oder 2v2 und Map wählen), Runde starten, zurücksetzen, neu starten, entfernen. Siehe „Messer-Server für die Seitenwahl“.
  • Map-Pools: pro Modus wählen, welche Maps im Veto stehen. Der Veto-Ablauf passt sich der Anzahl an, siehe „Maps im Admin-Bereich wählen“.
  • Gameserver: Runden Competitive und Wingman, Verlängerung, zusätzliche Konsolenbefehle, CSTV an/aus und Verzögerung, Zeit pro Veto-Schritt, Leerlauf-Frist, maximale Laufzeit, Startzeit (inklusive Download) und Archiv-Frist. Die Werte aus der .env sind die Vorgabe. Was hier geändert wird, steht in der Datenbank und gilt auch nach einem Neustart. Mit „live“ markierte Werte gehen beim Speichern sofort per RCON an alle laufenden Server. Befehle, die Passwörter ändern oder den Server beenden, sind gesperrt.
  • Docker-Hosts: oben die Auslastung pro Host (belegte und freie Plätze, freier Speicher dort, wo das Gameserver-Volume liegt, Matches und Messer-Server darauf), darunter Hosts hinzufügen, entfernen und bearbeiten (Name, Docker-Ziel, Adresse, Portbereich, LAN-Ports, Volume). Die Liste ersetzt CS2_DOCKER_HOSTS. „Liste aus .env verwenden“ schaltet zurück. Hosts mit laufenden Servern lassen sich nicht entfernen und ihre Adresse nicht ändern.
  • Image aktualisieren: lädt das CS2-Image auf allen Hosts gleichzeitig neu (per SSH direkt auf dem Host). Laufende Server laufen weiter.
  • Discord-Meldungen: welche Ereignisse gemeldet werden, plus Testmeldung.

Umgebungsvariablen

Alle Variablen mit Standardwerten stehen in .env.example. Die wichtigsten:

Variable Standard Zweck
PORT 3000 Port des Backends
PUBLIC_URL http://localhost:5173 Basis für Einladungslinks
DATABASE_FILE data/cs2-mapveto.db SQLite-Datei
CREATE_MATCH_PASSWORD leer Passwort zum Erstellen von Matches
REQUIRE_FULL_TEAMS false true: Veto nur mit vollständigen Teams
SERVER_ADAPTER demo demo, docker oder static
SERVER_IDLE_TIMEOUT_MINUTES 30 Frist bis zum automatischen Herunterfahren
SERVER_MAX_LIFETIME_HOURS 8 harte Obergrenze pro Server
ADMIN_PASSWORD leer Passwort für den Admin-Bereich /admin; leer = Admin-Bereich aus
VETO_STEP_SECONDS 30 Zeit pro Veto-Schritt, danach wird automatisch gewählt; 0 = ohne Frist
ARCHIVE_AFTER_DAYS 3 abgeschlossene Matches nach so vielen Tagen archivieren; 0 = nie
DOCKER_HOST leer Docker-Ziel für einen einzelnen Host, z. B. ssh://cs2docker
CS2_DOCKER_HOSTS leer mehrere Docker-Hosts als JSON-Liste (siehe „Mehrere Docker-Hosts“)
CS2_PORT_RANGE_START, CS2_PORT_RANGE_END 27015, 27115 Bereich der Spielports; CSTV liegt jeweils CS2_TV_PORT_OFFSET darüber
CS2_LAN_PORTS 27015-27020 Spielports für den LAN-Reiter von Steam, werden zuerst vergeben; leer = aus
CS2_TV_PORT_OFFSET 100 Abstand CSTV-Port zum Spielport (27015 → 27115)
CS2_SERVERNAME_PREFIX Maxlan Anfang des Servernamens im Serverbrowser
CS2_START_TIMEOUT_MINUTES 20 Startzeit eines Servers inklusive Download der Spieldateien
KNIFE_MAP de_mirage Standard-Map für Messer-Server
KNIFE_WARMUP_SECONDS 90 Zeit zum Verbinden, bevor die Messerrunde beginnt
KNIFE_MAX_SERVERS 4 höchstens so viele Messer-Server
CS2_TV_DELAY 90 CSTV-Verzögerung in Sekunden
CS2_GSLT leer Docker: Game Server Login Token; leer = LAN-Modus (sv_lan 1)
CS2_PUBLIC_HOST – Pflicht bei Docker: öffentliche Adresse
CS2_STATIC_SERVERS – Pflicht bei Static: Serverliste
DEMO_FAIL_FIRST_CONFIGURE false Demo: Konfigurationsfehler simulieren
DEMO_ROUND_SECONDS 4 Demo: Sekunden pro simulierter Runde
LOG_RECEIVER_URL PUBLIC_URL Adresse, unter der der CS2-Server das Backend für Live-Logs erreicht
LOG_DEBUG false empfangene Logzeilen im Terminal ausgeben
STREAM_KEY leer Schlüssel für die Stream-Übersicht aller Matches (/stream?key=…); leer = offen
DISCORD_WEBHOOK_URL leer Discord-Webhook für Meldungen zu Veto, Match-Start, Map- und Serienende sowie Serverfehlern; leer = aus
TRUST_PROXY false true, wenn die Anwendung hinter einem Reverse-Proxy läuft (echte Client-IP für die Begrenzung der Anmeldeversuche)
SERIES_AUTO_ADVANCE_SECONDS 30 Wartezeit nach Map-Ende bis zur nächsten Map bzw. zum Abschluss; -1 = manuell

Benötigte Zugangsdaten für den Echtbetrieb:

  • Docker: Zugriff auf den Docker-Daemon (lokal oder per DOCKER_HOST=ssh://…), Portfreigaben, für Internet-Betrieb ein GSLT (im LAN nicht nötig)
  • Static: RCON-Passwörter der Server

RCON-Passwörter für Docker-Container erzeugt das Backend selbst.


Projektstruktur

client/                  React-Frontend (Vite)
  src/pages/             Übersicht, Match erstellen, Match-Seite, Beitritt
  src/components/        Teamkarten, Veto-Board, Serverpanel, Einladungen, Verlauf
  src/lib/               API, Socket-Hook, lokale Zugangsdaten
server/
  config/map-pools.json  Map-Pools und Veto-Abläufe
  src/veto.js            reine Veto-Logik und Ablaufvalidierung
  src/matchService.js    Lobby, Rollen, Veto, rollenabhängige Snapshots
  src/servers/           Adapter-Schnittstelle, Demo/Docker/Static, RCON, ServerManager
  src/http.js            REST-API
  src/realtime.js        Socket.IO
  test/                  node:test-Tests

REST-API (Auszug)

Alle Aktionen laufen per POST mit Authorization: Bearer <token>. Der Token ist der Organisator- oder Spieler-Token bzw. der Zuschauercode.

Endpunkt Wer
GET /api/config, GET /api/matches alle
POST /api/matches alle (ggf. mit CREATE_MATCH_PASSWORD)
POST /api/access {code} prüft einen Code
GET /api/matches/:id rollenabhängiger Zustand
POST /api/matches/:id/join {code, nickname, steamId?} mit Teamcode
…/ready, …/leave Spieler
…/veto/action {stepIndex, map?, side?} Captain am Zug
…/captain {playerId} Organisator, Captain des Teams
…/veto/start, …/kick, …/codes/regenerate, …/server/retry, …/server/stop, …/server/warmup-end, …/server/pause, …/server/resume, …/series/next, …/finish, …/cancel Organisator

Socket.IO: match:subscribe {matchId, token} liefert match:state bei jeder Änderung; overview:subscribe liefert overview.

S
Description
No description provided
Readme
19 MiB
0 Stars 1 Watchers 0 Forks
Languages
JavaScript 87.1%
CSS 12.3%
Shell 0.3%
Dockerfile 0.2%
HTML 0.1%