Variants
Game authors declare optional rules in gameinfo.variants. The front end uses group to render radio groups (mutually exclusive) versus independent checkboxes. Declarative constraint fields tell the challenge picker, API validation, and game constructor when combinations are invalid.
See also Flags — flags describe session capabilities; variant constraints describe which uids may be selected together.
Overview
Each entry in gameinfo.variants is a Variant object (see gameinfo.json):
| Field | Purpose |
|---|---|
uid |
Stable identifier stored in state.variants and sent to the API |
group |
When present, mutually exclusive with other variants in the same group (radio UI) |
default |
true on the default radio choice for that group, or on a checkbox to start checked in the challenge picker |
#group sentinel |
{ uid: "#board" } marks the implicit default for group board when no member uid is in the submitted array |
experimental |
Omitted from production gameinfo and challengeVariants() (see Flags) |
unrated |
Challenge is not rated when this variant is active |
fans |
Optional rules added by the Abstract Play community or implementation team, not part of the credited designer’s published rules; the front end may show a “Community” chip |
Radio vs checkbox: a group field → one active uid per group (radio). No group → optional checkbox.
Submitted arrays: clients send variants: string[] with concrete uids only — never #group sentinels. The evaluator derives implicit group defaults from missing group members.
Who consumes constraints:
| Layer | Behaviour |
|---|---|
Front GameVariants |
Disables invalid controls; auto-sanitizes selection |
| Challenge / tournament / solo API | Rejects invalid combinations (INVALID_VARIANT_COMBINATION) |
| Game constructor (fresh init) | applyVariantConstraints() drops invalid uids |
Declaring constraints
| Field | Type | Meaning |
|---|---|---|
enabledWhen |
Record<group, uid[]> |
Selectable only when every listed group's current choice is in the allowed list (include #group for default). Back-pressure: while this variant is active, options in gated groups that would violate its enabledWhen are disabled in the UI. |
conflictsWith |
uid[] |
Not selectable while any listed uid is active (evaluator treats this as symmetric) |
requires |
uid[] |
Selectable only when all listed uids are also active |
implies |
uid[] |
When this variant is active, sanitize / the picker add the listed uids (soft auto-select). Not applied in API assert validation. |
impliesLock |
boolean |
With implies: implied checkboxes cannot be unchecked while this variant is active; sanitize re-adds them. |
Evaluation rule (all must pass for a uid to be selectable):
selectable(uid) =
enabledWhen gates pass
AND requires ⊆ active
AND conflictsWith ∩ active = ∅
Baseline group rule: at most one active uid per group. If multiple uids from the same group appear in a submitted array, sanitizeVariantSelection keeps the last-listed; assertValidVariantSelection fails. This covers games that previously threw manually when two board variants were passed.
LOA — radio disables another group's option
variants: [
{ uid: "classic", group: "board", default: true },
{ uid: "#board" },
{ uid: "hex5", group: "board" },
{ uid: "hex6", group: "board" },
{
uid: "scrambled",
group: "setup",
enabledWhen: { board: ["#board", "classic"] },
},
],
scrambled is not offered when hex5 or hex6 is the board choice. While scrambled is active, hex boards are disabled automatically (back-pressure from enabledWhen — no extra metadata on hex variants).
Druid — optional rule gated by board shape
variants: [
{ uid: "size-8", group: "board" },
{ uid: "#board" },
{ uid: "size-12", group: "board" },
// … y-* and hex-* board options …
{
uid: "walk",
group: "ruleset",
enabledWhen: { board: ["#board", "size-8", "size-12"] },
},
],
walk applies only on rectangular boards (default or explicit size-* variants).
Frogger-shaped — checkbox requires checkbox
{ uid: "courts" },
{ uid: "courtpawns", requires: ["courts"] },
Pairwise conflict
{ uid: "advanced" },
{ uid: "beginner-mode", conflictsWith: ["advanced"] },
Magnate — checkbox requires checkbox
{ uid: "mega" },
{ uid: "stacked", requires: ["mega"] },
stacked only affects deck split when mega is also active.
Auto-select — implies / impliesLock
{ uid: "mega", implies: ["stacked"], impliesLock: true },
{ uid: "stacked", requires: ["mega"] },
Selecting mega auto-checks stacked. With impliesLock, the user cannot uncheck stacked while mega remains on.
Minefield — redundant tile sets
{ uid: "pinwheel", conflictsWith: ["cartwheel"] },
{ uid: "cartwheel", experimental: true },
cartwheel is a superset of pinwheel; selecting both is redundant.
Constructor helper
Game authors apply constraints when GameFactory(uid, state, variants) runs on a fresh game. Do not re-sanitize state.variants when deserializing a saved game — historical records may predate metadata or include combinations the engine already played.
| Layer | API | When |
|---|---|---|
| Fresh game init | this.applyVariantConstraints(variants) |
state === undefined in constructor |
| Loaded game | assign state.variants as-is |
deserialize / resume |
| Standalone (tests, tooling) | resolveIncomingVariants(gameinfo.variants, uids) |
outside GameBase |
Before / after (LOA)
// ❌ Manual filter — duplicates metadata, easy to drift
if (variants !== undefined && variants.length > 0) {
this.variants = [...variants];
if (this.variants.includes("hex5")) {
this.variants = this.variants.filter((v) => v !== "scrambled");
}
}
// ✅ Declarative metadata + shared helper
if (variants !== undefined && variants.length > 0) {
this.variants = this.applyVariantConstraints(variants);
}
Before / after (Druid)
// ❌ Private sanitizeVariants() reimplementing enabledWhen
this.variants = [...variants];
this.sanitizeVariants();
// ✅
this.variants = this.applyVariantConstraints(variants);
resolveIncomingVariants modes
import { resolveIncomingVariants } from "@abstractplay/gameslib";
// Default: sanitize (constructor behaviour)
const cleaned = resolveIncomingVariants(MyGame.gameinfo.variants, ["hex5", "scrambled"]);
// → ["hex5"] (scrambled dropped)
// Strict: throw if invalid (API guards, tests)
resolveIncomingVariants(MyGame.gameinfo.variants, ["hex5", "scrambled"], { mode: "assert" });
// → UserFacingError INVALID_VARIANT_COMBINATION
Authoring rules
- Declare rules once in
gameinfo.variants— do not duplicate in ad-hoc.filter()or privatesanitize*methods. - Call
applyVariantConstraintsonly on the fresh-game path. - Game-specific logic that metadata cannot express (for example parsing
size-*uids for board dimensions) stays in the constructor afterapplyVariantConstraints. - Optional: add a test that
GameFactory(uid, n, badCombo).variantsmatches expected sanitized output.
Consumer APIs
Exported from @abstractplay/gameslib (detail in API):
| API | Use |
|---|---|
evaluateAvailability(variants, selected) |
Per-control enable map for UI |
sanitizeVariantSelection(variants, selected) |
Interactive self-heal in pickers |
validateVariantSelection(variants, selected) |
Non-throwing check ({ ok: true } or { ok: false, errors }) |
assertValidVariantSelection(variants, selected) |
Throws UserFacingError with code INVALID_VARIANT_COMBINATION |
resolveIncomingVariants(variants, incoming, options?) |
Constructor / test entry point (sanitize or assert mode) |
assertChallengeVariants(gameinfo, selected) |
Production allowlist + combination check for new challenges |
assertChallengeVariantSelection(gameinfo, selected) |
Combination check only |
applyVariantConstraints(incoming?, options?) is a protected method on GameBase — not part of the package export surface.
On a game instance, allvariants() and challengeVariants() pass constraint metadata (enabledWhen, conflictsWith, requires, implies, impliesLock, unrated, fans) to the front end.
Community (fans) and unrated variants
Set fans: true only when the variant is not part of the credited designer’s published rules and was added by the AP community or implementation team. Confirm designer sign-off or documented community origin before marking.
Set unrated: true when the variant must not be rated (free setup, broken positions, etc.).
Variant names in English locale should stay neutral: do not embed “(unrated)”, “experimental”, or “designer preferred” in the name when unrated or fans flags and the front-end chips carry that meaning. Use description for longer explanation (e.g. that a game cannot be rated).
i18n
Variant names and descriptions stay in locales/en/apgames.json under variants.{gameUid}.{variantUid}.
Constraint violation messages are owned by consumers (front submit guards, backend API). v1 uses generic copy; structured per-reason keys may follow in a later release.