Skip to content

Instantly share code, notes, and snippets.

@cetteup
Created October 22, 2026 13:21
Show Gist options
  • Select an option

  • Save cetteup/88ad964d23f38b2bf5c8d65d924837b1 to your computer and use it in GitHub Desktop.

Select an option

Save cetteup/88ad964d23f38b2bf5c8d65d924837b1 to your computer and use it in GitHub Desktop.
Special characters used in Battlefield 2 player names
#!/usr/bin/env python3
"""Count special characters in unique Registry player names."""
from __future__ import annotations
import argparse
import json
from collections import Counter
from pathlib import Path
from typing import Any, Iterator
from urllib.parse import urlencode
from urllib.request import Request, urlopen
API_URL = "https://api.registry.bf2.co/v1/players"
PER_PAGE = 1000
TIMEOUT_SECONDS = 30
GROUPS_BY_PROVIDER = {
"bf2hub": (
"bf2hub_gamespy",
"bf2hub_native",
),
"playbf2": (
"playbf2_gamespy",
"playbf2_native",
),
"openspy": ("openspy",),
"b2bf2": ("b2bf2",),
"gameppy": ("gameppy",),
}
REPORT_GROUPS = (
("GameSpy", ("bf2hub_gamespy", "playbf2_gamespy")),
("BF2Hub", ("bf2hub_native",)),
("PlayBF2", ("playbf2_native",)),
("OpenSpy", ("openspy",)),
("B2BF2", ("b2bf2",)),
("Gameppy", ("gameppy",)),
)
def fetch_provider(provider: str, api_url: str = API_URL) -> Iterator[Any]:
"""Yield API player records for one provider, following the ID cursor."""
after = ""
while True:
params = {"provider": provider, "perPage": PER_PAGE}
if after:
params["after"] = after
request = Request(
f"{api_url}?{urlencode(params)}",
headers={"User-Agent": "player-name-special-character-report"},
)
with urlopen(request, timeout=TIMEOUT_SECONDS) as response:
page = json.loads(response.read())
if not isinstance(page, dict):
raise ValueError(f"Registry returned an invalid page for {provider}")
players = page.get("players")
has_more = page.get("hasMore")
if not isinstance(players, list) or not isinstance(has_more, bool):
raise ValueError(f"Registry returned an invalid page for {provider}")
yield from players
if not has_more:
return
if not players or not isinstance(players[-1], dict):
raise ValueError(f"Registry returned no pagination cursor for {provider}")
next_after = players[-1].get("id")
if not isinstance(next_after, str) or not next_after or next_after == after:
raise ValueError(f"Registry returned an invalid pagination cursor for {provider}")
after = next_after
def group_key(provider: str, pid: int) -> str:
if provider == "bf2hub":
return "bf2hub_gamespy" if pid < 500_000_000 else "bf2hub_native"
if provider == "playbf2":
return "playbf2_gamespy" if pid < 480_000_000 else "playbf2_native"
return GROUPS_BY_PROVIDER[provider][0]
def special_characters(name: str) -> set[str]:
return {
character
for character in name
if not (character.isascii() and character.isalnum())
}
def count_provider(provider: str, api_url: str = API_URL) -> dict[str, Counter[str]]:
counts = {
key: Counter()
for key in GROUPS_BY_PROVIDER[provider]
}
seen_names = {
key: set()
for key in GROUPS_BY_PROVIDER[provider]
}
for player in fetch_provider(provider, api_url):
if not isinstance(player, dict):
raise ValueError(f"Registry returned an invalid player for {provider}")
name = player.get("nick")
pid = player.get("pid")
if not isinstance(name, str) or not isinstance(pid, int) or isinstance(pid, bool):
raise ValueError(f"Registry returned an invalid player for {provider}")
key = group_key(provider, pid)
if name in seen_names[key]:
continue
seen_names[key].add(name)
counts[key].update(special_characters(name))
return counts
def markdown_character(character: str) -> str:
display = character if character.isprintable() else f"U+{ord(character):04X}"
display = display.replace("\\", "\\\\").replace("|", "\\|")
return f"{display} (U+{ord(character):04X})"
def render_markdown(counts: dict[str, Counter[str]]) -> str:
characters = set().union(*(counter.keys() for counter in counts.values()))
ordered = sorted(
characters,
key=lambda character: (
-sum(
counts[key][character]
for _label, source_keys in REPORT_GROUPS
for key in source_keys
),
character,
),
)
headers = ["Character", *(label for label, _source_keys in REPORT_GROUPS), "Total"]
lines = [
"| " + " | ".join(headers) + " |",
"| " + " | ".join(":---" for _ in headers) + " |",
]
for character in ordered:
values = [
sum(counts[key][character] for key in source_keys)
for _label, source_keys in REPORT_GROUPS
]
total = sum(values)
lines.append(
"| "
+ " | ".join(
[markdown_character(character), *(str(value) for value in values), str(total)]
)
+ " |"
)
return (
"# Special characters used in Battlefield 2 player names\n\n"
"Table shows the number of unique player names containing each character. "
"Names are deduplicated within each provider and PID group.\n"
"Players on BF2Hub and PlayBF2 with original GameSpy PIDs are counted in the GameSpy column, "
"not in the BF2Hub or PlayBF2 columns.\n\n"
+ "\n".join(lines)
+ "\n"
)
def main() -> None:
parser = argparse.ArgumentParser(
description="Count special characters in Registry player names."
)
parser.add_argument(
"-o",
"--output",
type=Path,
default=Path("special-character-table.md"),
help="Markdown output path (default: special-character-table.md)",
)
args = parser.parse_args()
all_counts: dict[str, Counter[str]] = {}
for provider in GROUPS_BY_PROVIDER:
all_counts.update(count_provider(provider))
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(render_markdown(all_counts), encoding="utf-8")
print(f"Wrote character counts to {args.output}")
if __name__ == "__main__":
main()

Special characters used in Battlefield 2 player names

Table shows the number of unique player names containing each character. Names are deduplicated within each provider and PID group. Players on BF2Hub and PlayBF2 with original GameSpy PIDs are counted in the GameSpy column, not in the BF2Hub or PlayBF2 columns.

Character GameSpy BF2Hub PlayBF2 OpenSpy B2BF2 Gameppy Total
_ (U+005F) 230515 60660 12779 203 185 38 304380
- (U+002D) 134272 19904 2489 56 46 86 156853
. (U+002E) 111163 24181 2939 65 49 40 138437
[ (U+005B) 93370 4838 628 15 0 10 98861
] (U+005D) 93142 4773 609 15 0 10 98549
= (U+003D) 46590 8187 525 21 0 84 55407
| (U+007C) 40442 3084 300 14 0 9 43849
) (U+0029) 25607 2295 471 7 0 1 28381
( (U+0028) 24868 1861 379 7 0 2 27117
* (U+002A) 21416 4255 413 6 0 17 26107
@ (U+0040) 14905 3302 403 5 0 5 18620
< (U+003C) 14916 1196 166 0 0 2 16280
> (U+003E) 14907 1129 156 2 0 4 16198
{ (U+007B) 14099 494 90 1 0 0 14684
} (U+007D) 14087 490 93 1 0 0 14671
$ (U+0024) 9284 1164 171 3 0 0 10622
^ (U+005E) 7931 1532 116 5 0 4 9588
~ (U+007E) 6038 868 71 0 0 4 6981
: (U+003A) 5980 828 102 3 0 0 6913
/ (U+002F) 4742 1270 195 3 2 0 6212
# (U+0023) 2733 894 89 5 0 2 3723
+ (U+002B) 1807 662 81 2 0 1 2553
& (U+0026) 1722 445 41 0 0 0 2208
? (U+003F) 1052 645 102 2 0 0 1801
; (U+003B) 733 290 28 0 0 0 1051
% (U+0025) 521 188 28 1 0 0 738
, (U+002C) 0 629 85 0 0 1 715
` (U+0060) 159 72 9 0 0 0 240
" (U+0022) 0 26 0 0 0 0 26
§ (U+00A7) 0 0 6 0 0 0 6
! (U+0021) 2 0 0 0 0 0 2
' (U+0027) 0 2 0 0 0 0 2
С (U+0421) 1 0 0 0 0 0 1
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment