B^

Statistically the best crosshair (2026)

Eight crosshair designs rendered as a halftone-dot grid

The crosshair occupies a fixed position at the center of a Counter-Strike player's screen and is among the most frequently adjusted elements of the game's interface. Given the breadth of available configuration options, substantial variation across the professional population might be expected.

This article examines the crosshair configurations of 1,650 active professional players, reconstructed from the share codes each player publishes. For every configuration we recover the full set of console variables: arm length, center gap, line thickness, color, opacity, and the complete set of secondary toggles.

The data exhibit strong convergence. Across nearly every parameter the population concentrates on a narrow range of values, and a substantial fraction of players adopt identical configurations. The sections below report these distributions and the methodology used to obtain them.

I. The Consensus Configuration

Computing the modal value of each parameter independently across all 1,650 players yields a single configuration: size 1, gap -4, thickness 1, full opacity, classic static style, no center dot, and no outline.

This configuration is not merely an artifact of aggregating marginal modes. The exact skeleton of size 1, gap -4, and thickness 1 is used by 485 players, approximately one in three, who differ only in color.

The convergence extends to complete configurations. 574 players, more than a third of the sample, share an identical configuration, every field matching, with at least one other player. The single most common configuration, shown above in cyan, is used by 31 players across different teams, regions, and roles.

This degree of agreement is notable given that professional players have strong incentives to identify any marginal advantage. The concentration is consistent with a parameter space that has been effectively exhausted.

II. Adoption Rates Across Settings

The settings divide into two categories: those exhibiting wide variation, consistent with personal preference, and those exhibiting near-uniform adoption.

97.4% of players use a static crosshair. The dynamic crosshair, which expands during movement and firing and is the game's default, has negligible professional adoption.

98% use a negative gap, drawing the four arms inward toward the center; only 10 players use a positive gap. 86% use full opacity, and 96.6% use a thickness of 1.0 or below.

The remaining options are rare. Outlines appear in 15% of configurations and center dots in 9.5%. Weapon-based gap, follow-recoil, and T-style crosshairs are used by 9, 3, and 2 players respectively, out of 1,650.

III. Color Distribution

After resolving every preset and custom RGB value into perceptual color categories, two colors dominate: green at 41% and cyan at 35%, together accounting for roughly three quarters of the sample. Yellow and white each account for approximately 9%.

The distribution is consistent with contrast considerations. Counter-Strike environments are dominated by browns, grays, and warm earth tones; green and cyan are perceptually distinct from this palette and remain visible against most surfaces and player models.

Blue provides a useful control. It is available as a one-click preset, equivalent in convenience to green and cyan, yet only 9 players out of 1,650 use any blue variant, consistent with its low visibility against sky and shadow.

The custom-color data contain two notable patterns. First, of the 725 players who defined a custom RGB color, 309 specified a value identical to a built-in preset, recreating a default through additional effort. Second, the most common genuinely custom color is mint, RGB (0, 255, 145), used by more than 50 players; popularized by s1mple, it is currently used by ropz and w0nderful. Even in custom configuration, players converge on shared values.

IV. The Size-Gap Relationship

The most notable relationship in the dataset is between arm length (size) and center gap.

Players with longer arms tend to use wider gaps; the correlation is moderate and approximately linear (r = 0.49). A corresponding negative correlation holds between size and thickness: larger crosshairs tend to be thinner.

This pattern is consistent with maintaining a constant visual footprint. A long-armed crosshair with a tight -4 gap forms a dense mass that occludes the target; players using longer arms therefore widen the gap to preserve the sight picture, while players using thicker lines use shorter arms. The total screen coverage remains approximately constant.

These parameters are therefore not tuned independently. Players appear to optimize a single property, a shape that frames the target without occluding it, of which gap, size, and thickness are interdependent coordinates.

The marginal distributions support this interpretation. Size is concentrated at 1.0 and gap at -4, while thickness is bimodal, splitting between 1.0 and 0 (hairline).

V. Outliers

The tail of the distribution contains several notable configurations.

Zero-opacity configurations. Two players, awzek and SeRCEra, maintain fully specified crosshairs rendered at zero opacity, and therefore aim without a visible crosshair.

Dot-only configurations. 44 players use a size of 0, and 42 of these enable the center dot, reducing the crosshair to a single point. This group includes the AWPer Jame, a configuration that is plausible for players whose primary weapon provides its own scope.

Maximal arm length. One player, Brooxsy, uses a size of 819.1, producing arms that span the entire display.

VI. Defining the Best Configuration

The term "best" admits two distinct definitions, each answerable from the data.

Under the first definition, the configuration on which the professional population has converged, the answer is the modal configuration: size 1, gap -4, thickness 1, full opacity, classic static style, no center dot, in cyan or green. This is the single most frequently selected configuration in the professional scene.

Under the second definition, the configuration closest to the center of the full distribution, the answer is an actual player. Computing the medoid, the configuration with minimum aggregate distance to all others, identifies Oczarka: gap -3.5, size 1.1, thickness 0.5, half outline, full opacity. This configuration is marginally softer than the mode across every parameter.

The two definitions yield configurations that differ only marginally, which is itself the central finding.

VII. Methods

The results above derive from a reproducible pipeline that retrieves, decodes, and aggregates the published share codes of the active professional roster; it can be executed with python -m scraper. The data source is procrosshairs.com, which maintains the active roster together with the share code each player uses. A share code is the CSGO-XXXXX-... string exported by the game when a crosshair is saved; it decodes losslessly into the underlying console variables, providing ground-truth configurations rather than self-reported values. Each stage of the pipeline is documented below.

VII.1 Pipeline Orchestration

The entry point defines the processing order: it first validates the decoder against known configurations, then enumerates the roster and processes each record in turn, extracting the share code, decoding it, and deduplicating by 64-bit Steam ID in rank order. Records without an identifier or a decodable code are logged with an explicit reason rather than discarded silently. The surviving rows are written to disk together with a manifest summarizing the number of records found, decoded, and skipped.

scraper/__main__.py
"""Pipeline: validate decoder -> enumerate -> extract -> decode -> dedupe -> write.

Run with:  python -m scraper
Resumable: all HTTP responses are cached in ./cache/, so re-runs only hit the
network for offsets not yet fetched.
"""

import logging

from . import decode, output
from .discover import enumerate_players
from .extract import extract_code
from .http_cache import CachedFetcher

logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
log = logging.getLogger("scraper")


def main():
    decode.run_validation()  # fail fast if csxhair drifts

    fetcher = CachedFetcher()
    records = enumerate_players(fetcher)
    log.info("enumerated %d raw records", len(records))

    rows, skipped, seen = [], [], set()
    for rec in records:
        steamid = rec.get("steam_id")
        name = rec.get("nickname")
        if not steamid:
            skipped.append({"steamid64": None, "name": name, "reason": "missing steam_id"})
            continue
        if steamid in seen:  # dedupe by steamid64, keep first (rank order)
            continue
        seen.add(steamid)

        code = extract_code(rec)
        if not code:
            skipped.append({"steamid64": steamid, "name": name, "reason": "no share code found"})
            continue
        cvars = decode.decode_code(code)
        if cvars is None:
            skipped.append({"steamid64": steamid, "name": name,
                            "reason": f"code failed to decode: {code}"})
            continue
        rows.append({"name": name, "steamid64": steamid, "share_code": code, **cvars})

    for s in skipped:
        log.warning("skipped %s (%s): %s", s["name"], s["steamid64"], s["reason"])

    output.write_dataset(rows)
    output.write_manifest(len(records), rows, skipped, fetcher)
    log.info("wrote %s, %s, %s", *(str(p) for p in
             (output.config.CSV_PATH, output.config.JSON_PATH, output.config.MANIFEST_PATH)))
    output.print_summary(rows)


if __name__ == "__main__":
    main()

VII.2 Configuration

Operational parameters are centralized in a single module: the base URL, request user agent, rate limit, pagination size, and output paths. The user agent identifies the scraper and includes a contact address, consistent with standard conventions for automated retrieval.

scraper/config.py
"""Central config for the procrosshairs.com scraper. Tweak everything here."""

from pathlib import Path

BASE_URL = "https://procrosshairs.com"

# Honest browser-style UA with a contact, per politeness requirements.
USER_AGENT = (
    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
    "(KHTML, like Gecko) Chrome/124.0 Safari/537.36 "
    "(crosshair-dataset-scraper; contact: baasil@elide.dev)"
)

# Politeness: minimum seconds between *network* requests (cache hits are free).
RATE_LIMIT_SECONDS = 1.0
REQUEST_TIMEOUT = 30

# Enumeration: the API pages 100 records per offset; stop on an empty page,
# but never walk past MAX_OFFSET as a runaway guard.
PAGE_SIZE = 100
MAX_OFFSET = 20_000

CACHE_DIR = Path("./cache")
DATA_DIR = Path("./data")
CSV_PATH = DATA_DIR / "crosshairs.csv"
JSON_PATH = DATA_DIR / "crosshairs.json"
MANIFEST_PATH = DATA_DIR / "manifest.json"

VII.3 Polite, Cached Retrieval

All network access is mediated by a disk-backed, rate-limited fetcher. Requests are issued single-threaded at no more than one per second, and robots.txt is consulted before the first request to the host. Each response is written to disk keyed by the SHA-256 hash of its URL, so subsequent runs are served from the cache without contacting the network. The pipeline is therefore fully resumable: an interrupted run resumes from the point at which the cache ends.

scraper/http_cache.py
"""Disk-cached, rate-limited HTTP fetcher.

Every response body is written to CACHE_DIR keyed by sha256(url), alongside a
.meta.json with the original URL and status. Re-runs serve from disk and never
touch the network, which makes the whole pipeline resumable: kill it anywhere,
restart, and it picks up where the cache ends.
"""

import hashlib
import json
import logging
import time
import urllib.robotparser
from urllib.parse import urlparse

import requests

from . import config

log = logging.getLogger(__name__)


class FetchError(Exception):
    pass


class CachedFetcher:
    def __init__(self):
        self._session = requests.Session()
        self._session.headers["User-Agent"] = config.USER_AGENT
        self._last_request = 0.0
        self._robots = None
        config.CACHE_DIR.mkdir(parents=True, exist_ok=True)
        self.network_hits = 0
        self.cache_hits = 0

    # ---- robots.txt ------------------------------------------------------
    def _robots_allowed(self, url: str) -> bool:
        if self._robots is None:
            rp = urllib.robotparser.RobotFileParser()
            robots_url = f"{config.BASE_URL}/robots.txt"
            try:
                body = self.get(robots_url, check_robots=False)
                rp.parse(body.decode("utf-8", "replace").splitlines())
            except FetchError:
                rp.parse([])  # unreachable robots.txt -> allow
            self._robots = rp
        return self._robots.can_fetch(config.USER_AGENT, url)

    # ---- cache -----------------------------------------------------------
    @staticmethod
    def _cache_paths(url: str):
        key = hashlib.sha256(url.encode()).hexdigest()
        return config.CACHE_DIR / f"{key}.body", config.CACHE_DIR / f"{key}.meta.json"

    # ---- fetch -----------------------------------------------------------
    def get(self, url: str, check_robots: bool = True) -> bytes:
        """Return response body, from cache if present, else network (rate-limited)."""
        body_path, meta_path = self._cache_paths(url)
        if body_path.exists() and meta_path.exists():
            meta = json.loads(meta_path.read_text())
            if meta.get("status") == 200:
                self.cache_hits += 1
                return body_path.read_bytes()
            raise FetchError(f"cached non-200 ({meta.get('status')}) for {url}")

        if check_robots and urlparse(url).netloc == urlparse(config.BASE_URL).netloc:
            if not self._robots_allowed(url):
                raise FetchError(f"robots.txt disallows {url}")

        # <=1 request/sec, single-threaded by design (concurrency cap = 1).
        wait = config.RATE_LIMIT_SECONDS - (time.monotonic() - self._last_request)
        if wait > 0:
            time.sleep(wait)
        self._last_request = time.monotonic()

        log.info("GET %s", url)
        resp = self._session.get(url, timeout=config.REQUEST_TIMEOUT)
        self.network_hits += 1

        # Cache every response, including failures, so re-runs don't re-hit
        # known-bad URLs. Delete the cache entry to force a retry.
        body_path.write_bytes(resp.content)
        meta_path.write_text(json.dumps({"url": url, "status": resp.status_code}))

        if resp.status_code != 200:
            raise FetchError(f"HTTP {resp.status_code} for {url}")
        return resp.content

VII.4 Roster Enumeration

The complete roster is not present in the homepage markup, which embeds only the top 100 players. It is instead obtained from the site's JSON API, GET /api/v1.2/crosshairs/{offset}, which returns up to 100 records per offset; the pipeline walks successive offsets until an empty page is returned. Should the API become unavailable, the module falls back to parsing player links and crosshair codes from the homepage markup. The investigation that established this approach is documented within the module.

scraper/discover.py
"""Player enumeration — THE FRAGILE PART. Read this before touching anything.

The site claims 1000+ players but the homepage HTML only embeds the top 100.
Enumeration options were investigated in this order (2026-06-05):

  1. /sitemap.xml          -> 404 ("Cannot GET /sitemap.xml"). Dead end.
     /robots.txt           -> only Cloudflare "content signal" comments; no
                              Sitemap: directive, no User-agent/Disallow rules.
  2. JSON API              -> WINNER. The homepage is an Astro site whose React
                              island (EliteCrosshairs) hydrates a TanStack Query
                              cache. Its bundle (/_astro/index.*.js) calls:
                                  GET /api/v1.2/crosshairs/{offset}
                              The path segment is an OFFSET, not a page number
                              (offset=1 starts at the 2nd-ranked player). Each
                              call returns {"data": [...]} with up to 100 records
                              containing steam_id, nickname, crosshair_code, and
                              crosshairImg (a CloudFront JWT URL). An offset past
                              the end returns {"data": []}.
  3. Pagination on listing -> none exists; the homepage has no page links.
  4. Homepage seed links   -> kept as FALLBACK below in case the API moves.

Strategy: walk offsets 0, 100, 200, ... until an empty page (or MAX_OFFSET).
Pages can overlap if rankings shift mid-run; the caller dedupes by steam_id.
If the API breaks entirely, fall back to scraping /player/ links + crosshair
alt text from the homepage HTML.
"""

from __future__ import annotations

import json
import logging
import re

from . import config
from .http_cache import CachedFetcher, FetchError

log = logging.getLogger(__name__)

# /player/{steamid64}/{name} links in served HTML (fallback path only).
PLAYER_LINK_RE = re.compile(r'href="/player/(\d{17})/([^"]+)"')
# alt="{name} Crosshair: CSGO-....." on listing images (fallback path only).
ALT_CODE_RE = re.compile(r'alt="([^"]*?)\s*Crosshair:\s*(CSGO(?:-[A-Za-z0-9]{5}){5})"')


def enumerate_players(fetcher: CachedFetcher) -> list[dict]:
    """Return raw player records ({steam_id, nickname, crosshair_code, crosshairImg, ...})."""
    try:
        return _enumerate_via_api(fetcher)
    except FetchError as exc:
        log.warning("API enumeration failed (%s); falling back to homepage HTML", exc)
        return _enumerate_via_homepage(fetcher)


def _enumerate_via_api(fetcher: CachedFetcher) -> list[dict]:
    records = []
    for offset in range(0, config.MAX_OFFSET, config.PAGE_SIZE):
        body = fetcher.get(f"{config.BASE_URL}/api/v1.2/crosshairs/{offset}")
        page = json.loads(body).get("data", [])
        log.info("offset %d -> %d records", offset, len(page))
        if not page:
            break  # walked off the end of the dataset
        records.extend(page)
        if len(page) < config.PAGE_SIZE:
            break  # short page == last page; saves one empty-page request
    else:
        log.warning("hit MAX_OFFSET=%d without an empty page; dataset may be truncated",
                    config.MAX_OFFSET)
    return records


def _enumerate_via_homepage(fetcher: CachedFetcher) -> list[dict]:
    """Worst-case seed set: ~100 players embedded in the homepage HTML."""
    html = fetcher.get(f"{config.BASE_URL}/").decode("utf-8", "replace")
    codes_by_name = {name.strip(): code for name, code in ALT_CODE_RE.findall(html)}
    records = []
    for steam_id, name in PLAYER_LINK_RE.findall(html):
        records.append({
            "steam_id": steam_id,
            "nickname": name,
            "crosshair_code": codes_by_name.get(name),
            "crosshairImg": None,
        })
    log.info("homepage fallback -> %d records", len(records))
    return records

VII.5 Share-Code Extraction

Each record's share code is recovered first from the API's explicit code field and, failing that, from the JWT payload embedded in the associated image URL. Candidates are validated against the expected share-code format before being passed to the decoder.

scraper/extract.py
"""Extract the CSGO share code from a raw player record.

Priority order:
  1. The API's `crosshair_code` field (same string the alt text carries).
  2. The CloudFront `crosshairImg` URL — its path is a JWT whose payload is
     base64url JSON: {"crosshairCode": "CSGO-...", "width": 128, "height": 128}.
Anything extracted is regex-validated here and *decode*-validated downstream.
"""

from __future__ import annotations

import base64
import json
import re
from urllib.parse import urlparse

CODE_RE = re.compile(r"CSGO(?:-[A-Za-z0-9]{5}){5}")


def extract_code(record: dict) -> str | None:
    # 1. Direct field (mirrors the image alt text).
    for candidate in (record.get("crosshair_code") or "",):
        m = CODE_RE.search(candidate)
        if m:
            return m.group(0)
    # 2. JWT payload inside the CloudFront image URL.
    img = record.get("crosshairImg")
    if img:
        code = _code_from_jwt_url(img)
        if code:
            return code
    return None


def _code_from_jwt_url(url: str) -> str | None:
    path = urlparse(url).path.lstrip("/")
    parts = path.split(".")
    if len(parts) != 3:
        return None
    payload = parts[1]
    try:
        raw = base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4))
        code = json.loads(raw).get("crosshairCode", "")
    except (ValueError, json.JSONDecodeError):
        return None
    m = CODE_RE.search(code)
    return m.group(0) if m else None

VII.6 Decoding and Validation

Share codes are decoded with the csxhair library, which returns a typed Crosshair object; its typed attributes are read directly into columns rather than parsed from the generated console-command strings, preserving the distinction between floating-point and boolean values. Prior to any network access, the decoder is validated against two configurations with hand-verified expected values (donk and ZywOo); if its output diverges from these references, the run aborts rather than producing incorrect data.

scraper/decode.py
"""Decode share codes into typed cvar columns via csxhair.

Rows are built from Crosshair's typed attributes (never by parsing the
cs2_commands strings). Booleans stay Python bools; CSV serialization lowercases
them to true/false.
"""

from __future__ import annotations

import logging

from csxhair import Crosshair

log = logging.getLogger(__name__)

# Output column order for CSV/JSON. share_code is the source of truth.
COLUMNS = [
    "name", "steamid64", "share_code",
    "gap", "size", "thickness", "outline_thickness", "alpha", "color", "style",
    "dot", "drawoutline", "usealpha", "t_style", "recoil", "gap_useweaponvalue",
    "fixed_gap", "splitdist", "inner_split_alpha", "outer_split_alpha",
    "split_size_ratio", "color_r", "color_g", "color_b",
]


def decode_code(code: str) -> dict | None:
    """Return the cvar columns for a share code, or None if it fails to decode."""
    try:
        c = Crosshair.decode(code)
    except Exception as exc:  # csxhair raises on bad checksums/chars
        log.debug("decode failed for %s: %s", code, exc)
        return None
    return {
        "gap": c.gap,
        "size": c.size,
        "thickness": c.thickness,
        "outline_thickness": c.outline_thickness,
        "alpha": c.alpha,
        "color": c.color,
        "style": c.style,
        "dot": c.dot,
        "drawoutline": c.draw_outline,
        "usealpha": c.use_alpha,
        "t_style": c.t,
        "recoil": c.recoil,
        "gap_useweaponvalue": c.gap_use_weapon_value,
        "fixed_gap": c.fixed_gap,
        "splitdist": c.dynamic_splitdist,
        "inner_split_alpha": c.dynamic_splitalpha_innermod,
        "outer_split_alpha": c.dynamic_splitalpha_outermod,
        "split_size_ratio": c.dynamic_maxdist_split_ratio,
        "color_r": c.red,
        "color_g": c.green,
        "color_b": c.blue,
    }


def run_validation():
    """Known-good decodes; abort the run if the decoder ever drifts."""
    donk = decode_code("CSGO-O36iX-6VGeQ-pnJLm-UWkab-osuWK")
    assert donk is not None, "donk code failed to decode"
    assert (donk["gap"], donk["color"], donk["style"]) == (-4.5, 1, 4), donk
    assert (donk["size"], donk["thickness"]) == (1.0, 1.0), donk
    assert (donk["color_r"], donk["color_g"], donk["color_b"]) == (0, 255, 0), donk
    assert (donk["alpha"], donk["usealpha"]) == (200, False), donk

    zywoo = decode_code("CSGO-FNOLG-fQcPX-V8P7K-VqtAf-ZbJaA")
    assert zywoo is not None, "ZywOo code failed to decode"
    assert (zywoo["gap"], zywoo["color"], zywoo["style"]) == (-3.0, 5, 4), zywoo
    assert (zywoo["size"], zywoo["thickness"]) == (1.5, 0.0), zywoo
    assert (zywoo["color_r"], zywoo["color_g"], zywoo["color_b"]) == (255, 255, 255), zywoo
    assert (zywoo["alpha"], zywoo["usealpha"]) == (255, True), zywoo
    log.info("validation passed (donk, ZywOo)")

VII.7 Serialization and Summary Statistics

The decoded rows are written to CSV and JSON, with boolean values serialized as true/false, and a manifest records the run timestamp, record counts, and the number of network versus cache requests. The module also emits the summary statistics, including the style and color distributions and the median gap and size, reported in the preceding sections.

scraper/output.py
"""Dataset + manifest writers and end-of-run summary stats."""

import csv
import json
import statistics
from collections import Counter
from datetime import datetime, timezone

from . import config
from .decode import COLUMNS


def _csv_cell(value):
    if isinstance(value, bool):
        return "true" if value else "false"  # booleans as true/false, not 0/1
    return value


def write_dataset(rows: list[dict]):
    config.DATA_DIR.mkdir(parents=True, exist_ok=True)
    with open(config.CSV_PATH, "w", newline="") as f:
        writer = csv.DictWriter(f, fieldnames=COLUMNS)
        writer.writeheader()
        for row in rows:
            writer.writerow({k: _csv_cell(row[k]) for k in COLUMNS})
    with open(config.JSON_PATH, "w") as f:
        json.dump(rows, f, indent=2)


def write_manifest(found: int, rows: list[dict], skipped: list[dict], fetcher):
    manifest = {
        "run_at": datetime.now(timezone.utc).isoformat(),
        "players_found": found,
        "players_decoded": len(rows),
        "players_skipped": len(skipped),
        "skipped": skipped,  # [{steamid64, name, reason}]
        "http": {"network_requests": fetcher.network_hits, "cache_hits": fetcher.cache_hits},
    }
    with open(config.MANIFEST_PATH, "w") as f:
        json.dump(manifest, f, indent=2)


def print_summary(rows: list[dict]):
    n = len(rows)
    print(f"\n=== summary ===\nrows: {n}")
    for field in ("style", "color"):
        counts = Counter(r[field] for r in rows)
        dist = ", ".join(f"{k}: {100 * v / n:.1f}%" for k, v in sorted(counts.items()))
        print(f"{field} distribution: {dist}")
    print(f"median gap:  {statistics.median(r['gap'] for r in rows)}")
    print(f"median size: {statistics.median(r['size'] for r in rows)}")

Conclusion

An analysis anticipating substantial variation across 1,650 professional configurations instead finds strong convergence, with only minor regional variation.

Across teams, roles, and regions, the professional population has independently arrived at a consistent configuration: a small, thin, static, fully opaque crosshair in green or cyan, with arms drawn tightly toward the center. Of the large configuration space the game permits, professionals occupy a narrow region.

The practical implication is that the crosshair is not a meaningful source of competitive advantage but standardized infrastructure. The professional consensus is stable, and further individual tuning yields negligible returns.

For players seeking a recommendation, the data support the modal configuration: size 1, gap -4, thickness 1, a green or cyan color, and a static rather than dynamic style.