A game's answers are data a fan must not be able to see. This is where they live, how you get them there, and what it costs you to keep them current.
The shape, in one picture
Three things, and only the middle one is secret.
Studio what a fan reads: "Michael Jordan"
| the board, the labels, every word
|
| entry_key the join: jordami01
v
Dataset what a fan must not read: 32292
| a private file, yours, never served to a browser
v
The Engine holds what provisioning joined,
and never lets a value outThe producer types a name. You supply what the name is worth. Provisioning puts them together at the moment a round is built, the Engine holds the result, and the number never reaches the page.
Why it is not a live feed
A round must not change while somebody is playing it. If a value moves between one fan's pick and another's, the two of them played different games, the leaderboard compares values that were never comparable, and a dispute cannot be settled.
So a dataset is fresh when a round is provisioned and frozen while it is played. Push whenever you like. A round already open keeps the values it opened with; the next one picks up your change. That is a property of the Engine, not something you have to arrange.
The file
JSON, one document, one or more measures. A measure is a thing you can count a choice on.
{
"corpus": "1",
"measures": [
{
"key": "career_points",
"label": "Career Points",
"values": { "jordami01": 32292, "birdla01": 21791 }
}
]
}values maps a key to a number. The key is the join, and it is the only part that has to agree with anything: whatever a producer types as a choice's reference in Studio must appear here, spelled the same way.
Use identifiers your source already has. Inventing your own means maintaining a second mapping for ever.
What the file has to satisfy, all of it checked before anything is uploaded:
corpusis the string"1". It is the document's schema version rather than yours. The file is yours, hosted by you and changed without a deploy, so the one thing we rely on is that it says what it is.- At least one measure, and no two sharing a
key. A round names the measure it is counted on, so two measures under one key is a round that cannot say what scored it. - Every value is a whole number, zero or above. Not a decimal, not negative, not a string. A total that arrives as
21.5is refused. labelis optional. It exists so a refusal can say the dataset holds Career Points and Career Assists rather thanptsandast.
A dataset with one measure needs nothing more. One with several needs the round to name the one it counts on, which a producer sets on the round's Measure field.
Dataset is not a round's Content source. A Content source is a URL to the round JSON itself, including its required shape and access rules, in Game formats.
Scoring a push round by how close it came
By default, a push round scores its raw total and a bust scores the overshoot as a negative number: The Limit depends on this. Add a scoring block to a measure to score by proximity instead:
{
"key": "career_points",
"label": "Career Points",
"values": { "jordami01": 32292, "birdla01": 21791 },
"scoring": { "mode": "proximity", "pointsPer": 1000, "exactBonus": 500 }
}Points are floor(total / pointsPer), a bust scores 0, and a total landing exactly on the target adds exactBonus and marks the play exact, which the leaderboard shows as a badge. Against a £1,000,000 target with the defaults above, a squad at £877,000 scores 877 and a perfect £1,000,000 scores 1,500. Leave scoring out and nothing changes.
A measure can also set its own rank bands, replacing The Limit's defaults (2%, 5%, 10%, 18%, 28% of the target):
{
"key": "career_points",
"tiers": [
{ "within": 0.05, "label": "Hall of Fame" },
{ "within": 0.15, "label": "Contender" },
{ "within": 0.3, "label": "Also Ran" }
]
}within is a fraction of the target, ordered closest first, and at least one tier is required if you set tiers at all. The names a fan reads still come from a producer's Rank names in Studio when they set any; these labels are only what shows when that is empty.
Pushing it
$ gamestage reference push the-limit/careers.json ./careers.jsonThe first argument is the name a producer types in Studio, on the game's Reference data field: that is the name on screen, though this book calls the same thing a dataset. The second is the file on your machine.
push is the one command in this chapter that needs an account. Run gamestage login first, and the account has to be approved before it will go through. check below runs entirely on your own machine and needs neither.
It validates before anything leaves, so a malformed file fails here rather than through a round refusing to provision hours later. Then it writes two objects: a pointer at the name, and an immutable copy keyed by the digest of exactly these bytes. A round records that digest, so there is always an answer to "what was this round scored on".
Check your keys before a producer meets them:
$ gamestage reference check ./careers.json jordami01 birdla01A key the file does not hold refuses the whole round rather than counting as nothing. That is deliberate, because a choice silently worth zero reads as a hard round rather than a broken one, and nothing downstream would notice. This command is the same check, run by the person who can fix it.
Keeping it current
Run the push again. Each one is a new version, and old versions stay readable, so a round pinned to last week's values can still say what they were.
Put it on your own schedule rather than asking us for one. You know when your source updates and we do not, the credentials are yours, and a one-line command in your CI is smaller than a scheduler somebody has to operate on your behalf.
A push that would drop a measure's scoring is refused. Each push replaces the whole file, so pushing from an older copy quietly removes anything added since, including a scoring block: a live round would go back to scoring a bust as a large negative number instead of 0. push compares against what is live and refuses, naming the measure:
$ gamestage reference push wages/players.json ./players.json
This push would stop career_points scoring rounds.Add the block back, or pass --drop-scoring if removing it is the point.
A worked example, with free data
Fantasy Premier League publishes every player with a current price, free, with no key and no registration. One request, and it is a whole dataset.
The shape below is ours, not theirs. We test what we accept and not what anybody else serves, so read the payload and check the field names before running this: it is a public API nobody promised us and it is free to move things. The part worth copying is everything after const reference =.
const bootstrap = await fetch(
"https://fantasy.premierleague.com/api/bootstrap-static/",
).then((r) => r.json());
const reference = {
corpus: "1",
measures: [
{
key: "player_price",
label: "Price, in tenths of a million",
// Their own player id is the key, so nothing has to be mapped.
//
// `now_cost` is 155 for a player worth 15.5m, and it stays in tenths.
// A value has to be a whole number, and a budget compared in tenths is
// exact where one compared after dividing is a rounding argument on a
// game you lose by going a penny over.
values: Object.fromEntries(
bootstrap.elements
.filter((p) => typeof p.now_cost === "number")
.map((p) => [p.id, p.now_cost]),
),
},
],
};Note the filter. A player with no price is left out rather than given a zero. Zero is a free pick in a game about a budget, and a fan who picks one finds a round that looks hard rather than broken. Leaving them out means the round refuses to build until somebody notices, which is the failure you want.
Then in Studio, a choice on the board carries the same id as its reference:
Slate: Forward
Choice: Haaland reference 351A fan sees "Haaland". The Engine knows what he costs. Nothing in the page carries the number.
Where the data comes from
Decide this before you design the game. Your own data costs nothing to license. A provider's can take months.
Where the data comes from covers the options, the sources we offer, who makes each request, and what happens when a step fails.
What never goes in the file
Only what a choice is worth. Not a round, not a board, not the words a fan reads: those are the producer's and they live in Studio, so they can be changed without a deploy.
And nothing about a person beyond what the game needs. Dataset is copied onto our infrastructure and kept, so a file holding more than the game reads is a liability with no upside.
