Statistically the best crosshair (2026)
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.