cs2-gsi 0.1.1

Counter-Strike 2 Game State Integration listener — receive game state pushes, emit typed events
Documentation

cs2-gsi

crates.io docs.rs CI MSRV License: MIT OR Apache-2.0

English · 简体中文

Async Counter-Strike 2 Game State Integration listener for Rust.

A Rust port (and re-design) of antonpup/CounterStrike2GSI.

use cs2_gsi::{cfg::GsiCfg, events::PlayerDied, GameStateListener};

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    GsiCfg::for_localhost("MyApp", 4000).write_to_cs2()?;

    let gsl = GameStateListener::new(4000);
    gsl.on(|e: &PlayerDied| println!("{} died", e.player.name));
    gsl.start().await?;

    tokio::signal::ctrl_c().await?;
    gsl.stop().await?;
    Ok(())
}

Why this crate?

CS2 ships a Game State Integration feature that POSTs JSON documents about the live match to whichever HTTP endpoint you configure. Doing anything useful with those payloads requires four pieces:

  1. An HTTP listener.
  2. A model that mirrors the JSON CS2 actually sends (with all its quirks — numeric fields encoded as strings, casing inconsistencies, optional sub-objects).
  3. A diff engine that turns snapshots into actionable events (PlayerDied, BombPlanted, RoundConcluded, KillFeed, ...).
  4. A way to drop the right gamestate_integration_*.cfg into the right place so CS2 actually starts pushing.

cs2-gsi does all four — typed, async, and with a single clean API surface.

Feature Status
HTTP listener (hyper 1.x + tokio)
Strongly typed GameState model
40+ derived events with previous/new
Synthesised KillFeed events
Auto-write gamestate_integration_*.cfg
Steam library + appmanifest_730.acf discovery (Win/Linux/macOS)
auth { token "..." } blocks
Graceful start/stop, hot handler registration

Install

[dependencies]
cs2-gsi = "0.1"
tokio   = { version = "1", features = ["macros", "rt-multi-thread", "signal"] }

Cargo features (all enabled by default):

Feature Purpose
cfg-writer GsiCfg builder + writer for gamestate_integration_*.cfg
steam-discover Locate the CS2 install through Steam's libraryfolders.vdf

Disable both for a tiny build that only contains the HTTP listener and the data model:

cs2-gsi = { version = "0.1", default-features = false }

Architecture

┌────────────┐   POST JSON    ┌──────────────────┐
│ CS2 client │───────────────▶│ GameStateListener│
└────────────┘                │   (this crate)   │
                              │   • hyper server │
                              │   • parse → State│
                              │   • diff w/ prev │
                              └────────┬─────────┘
                                       │ typed events
              ┌────────────────────────┼─────────────────────────┐
              ▼                        ▼                         ▼
       PlayerDied{..}          RoundPhaseUpdated{..}      KillFeed{killer,victim,..}
       PlayerGotKill{..}       BombPlanted{..}            ...

Every payload is parsed into a [GameState], compared against the previous one, and turned into a stream of typed events. Handlers fire synchronously on the listener's tokio task (matching the upstream library's behaviour) — if your handler needs to do heavy work, tokio::spawn from inside it.


Subscribing to events

use cs2_gsi::{events::*, GameEvent, GameStateListener};

let gsl = GameStateListener::new(4000);

// Strongly typed callbacks.
gsl.on(|e: &PlayerDied| { /* ... */ });
gsl.on(|e: &BombPlanted| { /* ... */ });
gsl.on(|e: &KillFeed| {
    println!(
        "{} ({}{}) killed {}",
        e.killer.name,
        e.weapon.as_deref().unwrap_or("unknown"),
        if e.is_headshot { ", HS" } else { "" },
        e.victim.name,
    );
});

// Or one catch-all handler for every event:
gsl.on_any(|evt: &GameEvent| match evt {
    GameEvent::RoundPhaseUpdated(p) => println!("phase {:?}{:?}", p.previous, p.new),
    _ => {}
});

A non-exhaustive list of events the diff engine fires:

  • Player: PlayerUpdated, PlayerDied, PlayerRespawned, PlayerTookDamage, PlayerGotKill, PlayerHealthChanged, PlayerArmorChanged, PlayerHelmetChanged, PlayerFlashAmountChanged, PlayerSmokedAmountChanged, PlayerBurningAmountChanged, PlayerMoneyAmountChanged, PlayerEquipmentValueChanged, PlayerRoundKillsChanged, PlayerRoundHeadshotKillsChanged, PlayerRoundTotalDamageChanged, PlayerActivityChanged, PlayerTeamChanged, PlayerActiveWeaponChanged, PlayerStatsChanged
  • Round / Match: RoundUpdated, RoundPhaseUpdated, RoundStarted, RoundConcluded, FreezetimeStarted, FreezetimeOver, MatchStarted, GameOver, BombStateUpdated
  • Map: MapUpdated, GamemodeChanged, LevelChanged, MapPhaseChanged, TeamScoreChanged
  • Bomb (root): BombUpdated, BombPlanting, BombPlanted, BombDefusing, BombDefused, BombExploded, BombDropped, BombPickedUp
  • Synthesised: KillFeed (kill ↔ death pairing within one diff window)
  • Meta: NewGameState, AuthUpdated, ProviderUpdated

Generating the cfg file

CS2 has to be told where to send payloads. cs2-gsi builds an integration file identical in shape to the upstream CounterStrike2GSI library:

use cs2_gsi::cfg::GsiCfg;

// 1. Auto-discover and place into ...\Counter-Strike Global Offensive\game\csgo\cfg\
GsiCfg::for_localhost("MyApp", 4000)
    .with_auth("token", "secret-shared-with-myself")
    .write_to_cs2()?;

// 2. Or render to a string and place it yourself.
let kv = GsiCfg::for_localhost("MyApp", 4000).render();
println!("{kv}");

Output (snipped):

"MyApp Integration Configuration"
{
    "uri"          "http://localhost:4000/"
    "timeout"      "5.0"
    "buffer"       "0.1"
    "throttle"     "0.1"
    "heartbeat"    "10.0"
    "data"
    {
        "allgrenades"             "1"
        "allplayers_id"           "1"
        "allplayers_match_stats"  "1"
        ...
    }
}

Examples

cargo run --example quickstart   # PlayerDied + round phase
cargo run --example full_dump    # every event, debug-printed

MSRV & platforms

  • Rust 1.86 or newer. The dev-dependency reqwest 0.12 indirectly pulls in idna_adapter and the icu_* crates, whose latest versions require Rust 1.86. The library itself only needs basic 2021-edition features; cargo build (without dev-deps) on older toolchains still works.
  • Tested on Windows 10/11 (primary target — that's where CS2 lives). Linux & macOS are supported for the listener / parser / Steam discovery, using ~/.steam/steam and ~/Library/Application Support/Steam respectively.

Differences from upstream

cs2-gsi is not a 1:1 binding of the upstream C# library — it's a re-implementation that targets idiomatic Rust:

Upstream (C#) This crate
event / += handler `gsl.on(
PascalCase event types PascalCase types preserved
70+ events ~45 events covering all common cases
EventDispatcher calls handlers sync Same — handlers run sync on the listener task
GenerateGSIConfigFile(name) GsiCfg::for_localhost(name, port).write_to_cs2()
.NET HttpListener (single-threaded) hyper 1.x + tokio (multi-threaded accept)
Numeric fields awkward to consume Auto-coerced from JSON strings

If you need an upstream event that isn't yet exposed, open an issue — adding more variants is mechanical (one diff branch, one event struct).


License

Licensed under either of

at your option.

Contribution

Unless you explicitly state otherwise, any contribution intentionally submitted for inclusion in the work by you, as defined in the Apache-2.0 license, shall be dual-licensed as above, without any additional terms or conditions.

Acknowledgements

Inspired by and behaviour-compatible with antonpup/CounterStrike2GSI.