Appearance
Analysis & PR
Any finished match can be analysed. For each decision, the analysis gives the rank of the play made, the equity it lost, the engine's best alternatives and a tag. For each player, it gives error counts and a Performance Rating (PR). The server runs no strong engine itself. Volunteer gammonet workers supply the compute, and the server stores each answer once per match and serves it to everyone from then on. The code is in src/analysis/ and bin/gammonet.js.
Two tiers
| fast | deep | |
|---|---|---|
| request | GET /api/analysis/<matchId> | GET /api/analysis/<matchId>?deep=1 |
| engine | built-in heuristic (builtin-heuristic-1), run by the server | whatever engine DEEP_ENGINE names, run by a volunteer worker |
| search | 1 ply | 2 ply |
| PR | none: marked provisional | yes |
Both tiers are computed once per match and stored in ANNOTATIONS_DIR. If several people request the same analysis at once, they share one computation. After that it is served from disk to both players, spectators and every later visitor. Nothing is analysed until someone asks.
The built-in engine reports no PR at all. It gets the order of moves roughly right, but its equity scale is arbitrary, so a PR number from it would be worse than none. The response says so: Move ranking only. PR needs a calibrated engine — run deep analysis.
Deep analysis needs a connected worker
A deep request queues each position for DEEP_ENGINE and waits up to 20 seconds for a worker to answer. If no worker is connected, or no connected worker runs that engine, the request fails with 503 and the hint no gammonet worker is connected for deep analysis. The engine id must match exactly: the gnubg adapter reports gnubg-1.07, which is what docker-compose.prod.yml and the Dockerfile set. The server's own default, bgblitz/de.bgblitz.ai.TachiAI, only matches a BGBlitz worker.
What is measured
For every checker play where more than one legal play existed:
- loss: equity given up compared with the engine's best play (see below)
- tag:
inaccuracyif loss ≥ 0.02,errorif ≥ 0.04,blunderif ≥ 0.08 - rank: the played move's position in the engine's ranking, plus the top five alternatives
Plays with only one legal option are recorded as forced. They cost nothing and do not count as decisions. Cube actions (double, beaver, take, pass) are listed with the engine's cube evaluation, but they do not add to loss or PR.
PR is the mean loss per non-forced decision, multiplied by 500, the scale the backgammon community uses:
PR = (total loss / decisions) × 500Errors priced at the score
A match is not a sequence of independent games, so a mistake's cost depends on the score. lossAt() in src/analysis/annotate.js prices each error as follows:
- Money game: cubeless equity, taken directly from the engine:
best.eq − chosen.eq. - Match: the difference in match-winning chances between the best play and the play made, at the actual score, cube value and Crawford state, divided by the value of one point of the current stake at that score.
That division puts every error on the same scale PR is defined over. The README's example: the same slip costs 1.84 points of match equity at 2-away/2-away and 0.57 at 15-away/15-away. Without normalising, a PR would flatter anyone who plays long matches. If the engine returns no probability distribution for a play, there is nothing to convert, and the code falls back to equity.
The match equity table: generated, not transcribed
src/analysis/met.js computes its table instead of copying a published one. Published tables (Kazaross-XG2, Woolsey-Heinrich, the ones gnubg ships) come from rollouts and carry their own provenance and licences. Hand-copying a few hundred numbers is also how a silent typo ends up in a corner nobody checks. The generator is a short recursion over game outcomes. According to the README it builds a 25×25 table in under a millisecond.
The model, as the file states it:
- Each game is won by either player with probability ½. Wins split into 73.5% single, 25.5% gammon, 1.0% backgammon, the standard money-play distribution.
- Games before the Crawford game are scored cubeless. Correct cube use slightly inflates the leader's advantage, so the table is a little conservative at lopsided scores compared with a rollout-based table.
- The Crawford game is scored with the cube dead. This is exact, because that is the rule.
- Post-Crawford, the trailer doubles immediately in every game, so each game is worth double the stake. This is what actually happens in play, and what the books recommend.
The table is checked against facts that hold for any correct table (test/met.js):
| check | result |
|---|---|
| level scores | 50.0% at every away-score |
| 2-away vs 1-away | 31.6% (published tables: about 30%) |
| take point, 7-away/7-away | 25.3% (money play: 25%) |
| take point, 2-away/2-away | 31.6%, the drop equity with no recube vig |
| take point, 3-away vs 1-away | 0%: you must take everything |
Post-Crawford needs to be stated explicitly
A 1-away score looks the same whether the Crawford game is next or already past. The table cannot tell the two apart from the score alone. So every caller passes crawford and postCrawford explicitly (postCrawford = !isCrawfordGame && crawfordGamePlayed). Scoring post-Crawford decisions against the pre-Crawford table would inflate the cost of every mistake made in them, and post-Crawford games make up a large share of a short match. The bot's take decisions use the same table with the same flags.
gammonet: volunteer workers
The design follows Lichess's fishnet. The server holds a queue of positions and a cache of answers. Workers pull positions, evaluate them and push results back. The server's own cost is close to zero, and a deep analysis runs on someone else's CPU.
bash
node bin/gammonet.js --engine builtin # no install
node bin/gammonet.js --engine gnubg --ply 2
node bin/gammonet.js --engine bgblitz --classpath build:/opt/bgblitz/BGBlitz.jar \
--class <TachiAI class> --ply 3 --threads 2
npm run worker -- --server https://your-instance --engine gnubg --ply 2| option | env | default | meaning |
|---|---|---|---|
--server | GAMMONET_SERVER | http://localhost:8080 | the gammonchain server |
--key | GAMMONET_KEY | none | the operator's worker key. Prefer the env var: an argv is visible to other users via ps |
--name | GAMMONET_NAME | random anon-xxxxxxxx | worker id sent on acquire and submit (max 64 chars) |
--engine | builtin | builtin, gnubg or bgblitz | |
--ply | 2 | search depth (the built-in engine is capped at 1) | |
--batch | 8 | positions per request (the server caps it at 64) | |
--idle | 2000 | ms to wait when there is no work | |
--cmd | gnubg binary (GNUBG_CMD otherwise) | ||
--classpath, --class, --threads | build, none, 1 | BGBlitz bridge settings |
Workers only ever see positions, never users, accounts or games. BGBlitz is commercial, so it cannot be bundled: the operator installs a licensed copy, and the bridge in bridge/GepBridge.java binds to it by reflection. See ANALYSIS.md for the bridge protocol.
GAMMONET_KEY: the door is closed by default
Workers write into a cache that every later reader treats as authoritative, so an unauthenticated worker could poison other people's analysis. The worker endpoints (POST /gammonet/acquire, POST /gammonet/submit) therefore require the x-gammonet-key header to equal GAMMONET_KEY:
GAMMONET_KEYunset →503 this instance accepts no volunteer workers. Deep analysis is unavailable, deliberately: forgetting the key fails closed, not open.- wrong or missing key →
401.
Give the key only to people whose workers you actually want.
Worker results are not trusted
AnalysisQueue.submit() validates every result before it can enter the cache:
- A worker may only answer positions it was leased. The lease token is bound to the worker id that acquired it. Before this check, one acquire could submit answers for the whole queue.
- Every equity must be a finite number between −4 and 4, every play at most 4 hops, and every win probability between 0 and 1.
- Every play must be legal. Its hops are replayed and the resulting position must match one of
legalPlays()for that position and roll. An illegal play is refused however plausible its equities look. - Leases expire after 60 seconds, so a worker that disappears releases its work.
Strict mode
With STRICT_ANALYSIS=1, a result enters the cache only after two different workers agree on the same top play, with equities within 0.02. One worker cannot supply both opinions: a job is never leased twice to the same worker id, and the agreement check counts distinct ids.
Every worker sends the same workerId on acquire and on submit: --name (or GAMMONET_NAME) if given, otherwise a random anon-xxxxxxxx id chosen when the process starts. Two stock workers therefore count as two different workers, and a restarted worker without --name comes back as a new one. Give each machine its own --name if you want stable ids in logs.
What caching is worth, measured
ANALYSIS.md records that position-level caching deduplicates only about 1% of positions: 40 machine-played matches gave 2705 decisions and 2679 distinct positions. Midgame positions essentially never repeat. What actually saves work is analysing each match once and serving it from then on, only analysing on request, and keeping deep analysis off the server. test/analysis.js asserts the deduplication figure, so the architecture cannot quietly drift back to the assumption that caching positions pays.
Luck
annotate.js includes rollLuck(): the equity of the best play with the roll you got, minus the average of that over all 21 rolls. A positive value means the dice helped.
Luck is not computed by the server today
annotateMatch() computes luck only when it is called with luck: true and a luckEngine. The /api/analysis route passes neither, so the luck figures in responses stay at 0 and luckPerRoll is null.