- Status: accepted — owner batch acceptance 2026-09-25; implementing
- Author: Marco (drafted by Claude)
- Created: 2026-09-25
- Design refs:
design/00-vision.md(pillars, anti-goals, no real money),design/02 §1/§5/§6/§7(golden opportunities, Fiscal Quarters, Clout, the moral meters "all visible and published"),design/04-pets.md §1+ tone guard (the pet layer is sincere, never a joke),design/08 §1(voice rules, curtain always pulled),design/11-ux-writing.md §1/§2/§5/§6/§7(return sequence, progressive disclosure, copy inventory and production, one screen/tabs/depth ≤2, accessibility baseline, sincerity islands) - Depends on: archived Game UI Screens (
docs/game-ui.md— the host, U2 contract, andruntime.tsownership), archived UI Foundation (docs/ui-foundation.md), archived Copy Pipeline, the implemented backends below, Minigame & Recovery API + Surface (accepted — this RFC proposes its MA-C9 surface enumeration; see GS7), API Foundation (accepted; its generated client is queue item 3g), and draft Accessibility of Player Workflows (its A1–A5 floor is consumed here as a proposed acceptance floor; it is not yet accepted) - Parent / amends: follow-up to archived Game UI Screens (whose U3 names these surfaces as
later screen sets on the same U2 contract); does not edit the archive. Proposes body text for
rfc/minigame-api-and-surface.mdMA3/MA-C9 for that RFC's ruling author to reconcile. - Supersedes / superseded by: —
- Planning:
planning/garage-player-surfaces/after acceptance - Source coordinate: repository HEAD
c2d9bbc(2026-09-25). Every code path cited below was read at this coordinate.
Seven implemented v0.1 backends — Fiscal Quarters, Achievements, Meters, Pet care, active-play
opportunities/buffs, purchasable content (provisioning caps) and The Pitch — have no player-facing
surface, and one (the cosmetic shop) has no backend at all beyond a static stub. This RFC specifies
one coordinated set of Game UI surfaces over the backends as they exist: one additive read
projection (game_ui_snapshot v4), one runtime correction (intent receipts are returned instead of
discarded), closed event decoders for announcements, and one section per surface enumerating its
surface ID, exact data source, props, states, intents, error mapping, keyboard map,
focus/announcement behavior, reduced motion, 320 px reflow and copy keys. It invents no mechanic:
where a surface would need behavior the backend does not have, the section stops at a numbered
DESIGN-GAP with options. GS7 is the Pitch/minigame_session enumeration that execution-queue
item 3h requires before rfc/minigame-api-and-surface.md AC5 can be implemented.
planning/roadmap-1.0.md names v0.1 The Garage as "Fiscal/Clout/shop, three named minigames, pet
care … era UI", requiring "a tested default player journey for every named feature". The server
and replay work for most of that is archived; the player cannot see any of it. Archived Game UI
Screens U3 deferred exactly these surfaces to "a later screen-set RFC on the same U2 contract", and
U2 binds that successor: "A screen needing a field the wire lacks is a DESIGN-GAP escalation, never
a client-side derivation of authoritative data." The current wire lacks every field these surfaces
need (finding F1), so this RFC must specify the projection as well as the components.
Scope boundary. In: the surfaces listed above, the v4 projection that feeds them, runtime receipt/error handling, event decoding for announcements, and the Pitch surface enumeration. Out: the Phase-0 Playable Preview manifest (D-001/D-007 own it; nothing here widens the preview); the soul-recovery surface (MA-C9's second half — see OD-14); pet identity, species, naming, acquisition, battles and the house (no backend — DG-4); cosmetic items, crates, hats and any purchasable cosmetic (no backend — DG-5); the return-sequence diorama/modal (see OD-3); Clout minting (the Achievements foundation deliberately does not mint Clout); boards/feed/Commons panels (their own RFCs); any balance retune (DG-1/DG-2 route to a balance mint, not to this RFC).
These are the load-bearing facts the specification is built on. Each is a read of current code or data, not an inference.
| ID | Finding | Evidence |
|---|---|---|
| F1 | The live snapshot is game_ui_snapshot v3 with exactly 13 top-level keys; it carries no Fiscal, achievement, meter, pet, opportunity/buff, minigame-availability or provisioning-cap data. The client parser rejects any extra key. |
server/gameui/projector.go (snapshot struct, projectSnapshot); client/src/game-ui/contracts.ts (parseGameUISnapshot exact field list); server/account/game_ui_api_schema.go |
| F2 | runtime.intent() returns Promise<void> and discards the receipt; the server answers a rejected intent with HTTP 200 and {intent_id,outcome:"rejected",current_revision,rejection:{category,detail}}. GameUIApp.act() maps every thrown error to offline = true. Rejection reasons are therefore invisible to every surface. |
client/src/game-ui/runtime.ts (intent); server/production/intents.go (marshalRejection); server/account/api.go (submitIntent); client/src/game-ui/GameUIApp.svelte (act) |
| F3 | Player-channel events of every kind are published, but decodeGameUIEvent returns undefined for any kind other than gate/offer/run-end, so achievement_earned.v1, meter_band_changed.v1, pet_care_applied.v1, pet_status_changed.v1, fiscal_period_harvested.v1, fiscal_credit_spent.v1, opportunity_*, buff_* and minigame_resolved.v1 are received and dropped. |
client/src/game-ui/events.ts; server/transport/wire.go (event envelope); server/save/intent.go (AllEventKinds, payload validators) |
| F4 | No pet exists or can be created. Epoch 6 activates the pet policy with an empty pet map and "no fabricated starter pet"; no intent, route or content creates a pet ID. care_action on any ID rejects unknown_id/unknown_pet. |
changelog/epoch-6.md; docs/pet-care.md; server/production/founder_replay.go (unknown_pet) |
| F5 | No cosmetic backend exists. The only cosmetic is presentation row cosmetic.horse_armor_free with purchasable:false, stateful:false, rendered statically on the Desk. |
client/src/game-ui/presentation.generated.json; GameUIApp.svelte |
| F6 | Fiscal Quarters and The Pitch are live since epoch 6 (current artifact list includes fiscal, minigames, minigame_api, pitch), contradicting docs/fiscal-quarters.md ("no production epoch currently contains the optional fiscal artifact") and docs/minigame-the-pitch.md ("no production epoch pins the content"). Docs drift; not fixed here. |
balance/epochs/phase0.json; changelog/epoch-6.md |
| F7 | The live Fiscal clock policy is early_ms:100, guaranteed_ms:200, auto_ms:300 (milliseconds) with credit_per_period:3, hardcap:1000 — a period ripens ~3×/second and credit saturates in under two minutes of wall time. The archived Fiscal RFC annotates these fields "design: ~20 h / ~24 h". |
balance/fiscal/first-content.json; rfc/archive/fiscal-quarters-foundation.md |
| F8 | The live opportunity schedule is seconds-scale (minimum_interval_ms:1000, scale_ms:500, lifetime_ms:4500; buff durations 1–5 s) versus design/02 §1's 300–900 s spawns and ~77 s frenzies. It carries an owner ratification (33418ba), so it may be deliberate for the T0 curriculum. |
balance/opportunities/t0-t1.json; changelog/epoch-7.md |
| F9 | The Game UI wind_down.eligible preview does not consult the active-minigame predicate, but Exit rejects not_eligible/minigame_session_active while a Pitch session is active (MA-C12). Combined with F2, the Wind Down button can be enabled and silently do nothing. |
server/gameui/projector.go (previewPhaseATransitions call); server/production/replay.go (minigame_session_active) |
| F10 | Reason/copy keys these surfaces must render are absent from the generated client copy catalog: cap.fiscal_credit, cap.fiscal_level.beige_tower, cap.active_combo, cap.cash. GameUIApp.capFor throws on a missing cap key. Pitch hack copy is title-only (e.g. pitch.hack.dark_pattern = "Dark Pattern"): no effect text and no curtain. No copy exists for meters, pets or Fiscal. |
client/src/copy/generated/catalog.json; balance/fiscal/first-content.json; balance/opportunities/t0-t1.json; balance/pitch.json |
| F11 | act() binds expected_revision to the Company revision. care_action, harvest_fiscal_period and spend_fiscal_credit are Founder commands whose expected_revision is the Founder revision (ApplyFounderLogged). |
GameUIApp.svelte (act); server/production/intents.go (handleFounderCare, handleFounderFiscal) |
| F12 | Fiscal unlock row unlock.arcade (cost 5) has no consumer anywhere in server or client code; spending on it succeeds and changes nothing. |
balance/fiscal/first-content.json; repository grep |
| F13 | While a Soul-recovery session is active, every intent on /api/v1/intents except buy_route_hint, harvest_fiscal_period and spend_fiscal_credit (so including care_action and claim_opportunity) is answered with the rejection not_eligible/exclusive_activity (optionally session_expired:true). |
server/production/intents.go (Handle, marshalExclusiveActivityRejection) |
The server projection gains schema version 4, served by the existing operation
get_game_ui_snapshot (GET /api/v1/founder/state) and the bootstrap receipt, registered in the
same private registry (server/account/game_ui_api_schema.go) and generated into
client/src/api/generated/types.ts. v4 = the exact v3 envelope plus one new key, features,
an object with exactly six keys, each either null or the exact arm defined in its surface section:
features: {
active_play: null | ActivePlayArm (GS5)
achievements: null | AchievementsArm (GS2)
fiscal: null | FiscalArm (GS1)
meters: null | MetersArm (GS3)
minigames: null | MinigamesArm (GS7)
pets: null | PetsArm (GS4)
}
and one extension to the existing generators[] row: provision_cap: null | {amount, reason_key}
(GS6).
Rules:
- An arm is
nullexactly when the pinned bundle lacks the artifact or the save's feature schema is below the version that activates it (Meters/Achievements: Company v16; active play: Company v18; pets: Founder v18; Fiscal: Founder v19; minigames: Founder v21 with theminigame_apiartifact). The projector never consults deploy-current catalogs (the existingResolveReplayCatalogs(constantsHash)rule). - Every value is either a persisted field or a read-only derivation computed by the existing
kernel function on a discarded clone — the same precedent as v3's Gate/Wind Down preview
(
previewPhaseATransitions). No new arithmetic path is written for the projection. If a needed derivation has no exported pure function (e.g. pet decay-to-now separated from a care command), the implementer files a blocker; it does not re-implement the math. - All arrays are byte-sorted by their mechanical ID; all integers are exact safe integers; all big numbers are canonical Decimal strings.
- v1–v3 remain decodable for stored bootstrap receipts (existing rule). Live sync requires v4
once this RFC is implemented, exactly as v3 is required today (
runtime.ts loadSnapshot). - Facts: the projection's
factsarray adds exactlyfeature.active_play,feature.achievements,feature.fiscal,feature.meters,feature.minigame.pitchandfeature.pets(boolean;trueiff the corresponding arm is non-null, and for pets iff at least one pet exists, and for the Pitch iff the minigames arm contains apitchrow). These are the surfaces' unlock facts (GS0.4);GAME_UI_FACT_IDSinclient/src/game-ui/surface-catalog.tsgains them.
The alternative of one new read operation per system is OD-1.
client/src/game-ui/runtime.ts remains the only network owner. Changes:
-
intent(body)returnsPromise<IntentOutcome>:type IntentOutcome = | Readonly<{ outcome: "applied"; receipt: Readonly<Record<string, unknown>> }> | Readonly<{ outcome: "rejected"; category: string; detail: string; currentRevision: number; sessionExpired: boolean }>;
A 200 body whose
outcomeis neither value, or whose rejection is not exactly{category,detail}(+ optionalsession_expired), throwsSyntaxError(fail closed). -
Non-2xx responses throw
GameUIRequestError { status, category, detail }parsed from the API error body; only a network failure or unparsable body throws a transport error. -
GameUIApp.act()accepts ascope: "company" | "founder"and sendsexpected_revision = snapshot.revisionorfounderRevisionrespectively (fixes F11). Founder intents are disabled whilefounderRevisionis undefined. -
Surfaces receive outcomes through a shared mapper; no surface parses HTTP.
Shared error mapping (every surface uses this table, then its own rejection rows):
| Result | Surface behavior | Copy key |
|---|---|---|
200 applied |
Clear pending; await authoritative refresh (existing receipt → refresh() path); announce the surface's success line if it has one. |
per surface |
200 rejected, surface-specific pair |
Clear pending; show the pair's text in the surface status region; keep focus on the triggering control if it is still enabled, else on the surface heading. | per surface |
200 rejected not_eligible/exclusive_activity |
As above; controls stay disabled until the next snapshot. | intent.rejection.exclusive_activity |
200 rejected, unlisted pair |
Show generic line; report one client invariant; never render the mechanical pair. | intent.rejection.unknown |
409 conflict/intent (stale revision) |
Trigger one authoritative refresh(); show the "books changed, try again" line; do not auto-retry (a new intent needs a new intent_id and the player's fresh consent). |
intent.conflict |
400 invalid/* |
Client defect: report invariant, show generic line. | intent.rejection.unknown |
| 401/404 on the intent route | Existing offline/credential path. | existing settings.save_status.offline |
429 rate_limited/* |
Show rate line; re-enable after the next snapshot. | intent.rate_limited |
| 5xx / transport failure | Existing offline = true path. |
existing |
client/src/game-ui/events.ts gains exact, fail-closed decoders (same exact() discipline, same
field domains as server/save/intent.go validateEventPayload) for:
achievement_earned.v1, meter_band_changed.v1, pet_status_changed.v1,
fiscal_period_harvested.v1 (both automatic and manual arms) and
buff_started.v1 (schema v1 and v2). Decoded events drive announcements only (GS0.6); every
displayed value still comes from the next snapshot. A decoder failure takes the existing
authoritativeResync() path (as today for malformed lifecycle events). Other kinds remain ignored.
client/src/game-ui/surfaces.json gains these rows (byte-sorted with the existing five):
| surface_id | mount_id | unlock |
|---|---|---|
achievements |
game.surface.achievements |
fact_equals feature.achievements true |
fiscal |
game.surface.fiscal |
fact_equals feature.fiscal true |
meters |
game.surface.meters |
fact_equals feature.meters true |
minigame_session |
game.surface.minigame_session |
fact_equals feature.minigame.pitch true |
pet |
game.surface.pet |
fact_equals feature.pets true |
Active play and purchasable content are Desk regions, not surfaces (GS5, GS6). GameUISurfaceID
grows by the five IDs. Each new surface is its own Svelte component with the props declared in its
section; GameUIApp.svelte only hosts, navigates and owns act(). The persistent nav renders one
native <button> per unlocked surface in registry order after Desk, before Settings, with
aria-current="page" on the active one. Lifecycle preemption (Offer/Run-End) keeps its existing
precedence over every new surface (navigation.ts); none of the new surfaces is a destructive
confirmation, so none defers preemption.
Every surface implements this closed state set, derived from host state it does not own:
| State | Condition | Rendering |
|---|---|---|
loading |
No snapshot bound yet, or the surface's first authoritative read is in flight. | Heading + common.loading in role="status"; controls absent. |
empty |
The arm is non-null but has no rows to act on (defined per surface). | Heading + the surface's empty key. Never a fabricated placeholder row. |
active |
Arm non-null, transport ready or HTTP-synced. | Full surface. |
reconnect |
`offline | |
error |
The arm fails to decode, or a required presentation/copy row is missing. | Surface-level role="alert" with common.surface_error; controls absent; one invariant reported. A missing arm never crashes the host (other surfaces keep working). |
The arm going null while mounted (e.g. new run on an older bundle) unmounts the surface and the
host returns to Desk with focus on the Desk heading (A1.1 rule for a context change the player did
not choose).
Proposed as the acceptance floor, consuming draft rfc/accessibility-player-workflows.md A1–A5:
- Heading/focus: one
<h1 tabindex="-1">per surface; a player-selected tab change keeps focus on the nav button; a forced change focuses the new heading. Intent completion never moves focus except when the triggering control is removed (then: the nearest surviving control in the same region, else the heading). - Announcements: each surface owns exactly one polite
role="status"region for its own intent outcomes and onerole="alert"for itserrorstate. Cross-surface announcements (achievement earned) go to one polite chrome region owned by the host, deduplicated by eventcursor; replay of the same cursor after reconnect announces nothing. Countdowns are never inside a live region. - Keyboard: native controls only (
button,input[type=checkbox],details/summary); logical DOM order; no global hotkeys, noaccesskey, no key interception, no Escape handler, no timed key sequences. Enter/Space per native semantics. - Non-color: every state (eligible, cooldown, locked, owned, capped, earned, band) has text.
<meter>/progress visuals always carry the numeric text beside them. - Reduced motion: no new animation is introduced by any surface; transitions use theme
duration tokens only, which are zero in
era_1995and under reduced motion (UI Foundation). The liveMediaQueryListlistener required by A4 is inherited from the host once that RFC lands; surfaces may not add their own. - Reflow: every grid uses
repeat(auto-fit, minmax(min(<n>rem, 100%), 1fr)); no fixed widths; at 320 CSS px (and 400% zoom on 1280 px) the full mounted surface including persistent chrome and the grown nav has no horizontal page scroll and no clipped control. Tables (Meters) collapse to one labeled row per value below 30rem. - Target size: every control ≥ 24×24 CSS px on coarse pointers.
- Every player-visible string is a Copy Pipeline key; zero literals (enforced by the existing
make verify-client-boundaryscan). Keys listed per surface are key reservations only; all prose is owner-authored or Claude-drafted for Marco's editorial cut (design/11 §6) and passes the copy linter, the statistic detector and provenance rules. Params use onlystring, safeintegerandcanonical_decimal. - Tones: Fiscal/Meters/Active/Pitch use the existing
corporate/diegetic; achievements useachievement; pet copy usesdiegeticand must be sincere (design/04 tone guard; the Soul-recovery rows are the precedent). A dedicated sincere tone is OD-11. - Mechanical-ID → copy-key mappings not owned by an artifact's own
copy_keyfield live in the presentation catalog, which bumps to schema v4 with new row families declared per section. Unknown IDs are withheld and reported, never rendered mechanically (the existing network-slot/payout precedent indocs/game-ui.md). - Every parodied dark pattern on these surfaces pulls its curtain in visible text or tooltip
(design law 10): early Fiscal harvest risk, the
dark_patternPitch hack, Lucky's bank formula.
The existing act() single-flight queue governs all new intents: at most one in-flight intent in
the whole Game UI; a click of the same kind while pending is dropped; a different kind waits. Every
control whose intent is pending shows common.pending text and aria-disabled state while
remaining focusable (so focus is not lost).
Backend: server/fiscal/transition.go, server/production/founder_replay.go
(applyFounderFiscalHarvestResolved, applyFounderFiscalSpendResolved),
server/production/intents.go (handleFounderFiscal); client kernel client/src/fiscal.ts.
Design: design/02 §5 (Earnings Calls → Investor Confidence; the real-time clock nothing can buy).
Surface: fiscal / game.surface.fiscal / unlock feature.fiscal.
Data source — FiscalArm (all from Founder state + pinned fiscal artifact):
{
credit: int, // FiscalCredit (persisted)
credit_cap: {amount: int, reason_key: id}, // credit_policy.hardcap / hardcap_reason_key
credit_per_period: int,
period: {opened_wall_ms: int, seq: int, // FiscalPeriodOpenedWallMS / FiscalPeriodSequence
early_ms: int, guaranteed_ms: int, auto_ms: int, early_success_ppm: int},
sweep_preview: {periods: int, credited: int, credit_after: int, saturated: bool},
// fiscal auto-sweep at server_now_ms on a clone
hoard: {preview_ppm: int, cap_credits: int, reason_note: "next_run"},
// hoard contribution the NEXT run would freeze
generator_levels: [{generator_id, level: int, level_cap: {amount: int, reason_key},
ppm_per_level: int, next_level_cost: null | int}],
unlocks: [{unlock_id, cost: int, owned: bool}]
}
next_level_cost is the server's GeneratorLevelCost(id, level, 1); null at the level cap.
hoard.preview_ppm is the value the run-genesis frozen-contribution path would compute from the
post-sweep credit (read-only call of the same function). Unlock rows without a presentation row
are withheld (F12, DG-3).
Props:
type FiscalSurfaceProps = Readonly<{
arm: FiscalArm; serverNowMs: () => number; // host's estimatedServerNowMS
era: CopyEra; pending: boolean; controlsEnabled: boolean;
onHarvest(): void;
onSpendLevel(generatorId: string): void; // always levels: 1 (OD-4)
onSpendUnlock(unlockId: string): void;
lastOutcome: IntentOutcome | undefined;
}>;States: GS0.5; empty never occurs (the credit panel always exists). Harvest phase is derived
display-only from serverNowMs() - period.opened_wall_ms against the three pinned windows:
ripening (disabled, shows remaining time), early (enabled, risk stated), guaranteed
(enabled). The receipt decides; a stale estimate that sends early produces an honest rejection.
Intents (Founder scope, expected_revision = founderRevision):
{kind:"harvest_fiscal_period"},
{kind:"spend_fiscal_credit", target:{kind:"generator_level", generator_id, levels:1}},
{kind:"spend_fiscal_credit", target:{kind:"unlock", unlock_id}}.
Error mapping (in addition to GS0.2):
| Rejection | Copy key |
|---|---|
not_eligible/period_not_ripe |
fiscal.rejection.period_not_ripe |
unaffordable/fiscal_credit |
fiscal.rejection.unaffordable |
not_eligible/already_unlocked |
fiscal.rejection.already_unlocked |
cap_exceeded/<generator_id> |
the row's level_cap.reason_key |
unknown_id/<target id> |
intent.rejection.unknown + invariant |
Applied harvest receipts carry harvest_outcome ∈ {early_succeeded, early_failed, guaranteed, consumed_by_auto}; each maps to fiscal.outcome.<outcome> in the status region. early_failed
text states plainly that the period was consumed and nothing was credited.
Keyboard map: Tab order: harvest button → per-level rows (one "buy +1 level" button each) → unlock rows (one button each). No other bindings.
Focus/announcement: outcome line in the surface status region. A ripening → ripe transition is
not announced (it would be a timer in a live region); the nav button text gains the
fiscal.nav_ripe_badge suffix instead (OD-3).
Reduced motion: countdown text updates at most 1 Hz; no progress animation.
320 px: credit panel, harvest panel, levels list and unlocks list stack vertically; each row is a two-line card.
Copy keys: surface.fiscal.title, fiscal.credit_label, fiscal.credit_frame {credit:integer, cap:integer}, fiscal.period.ripening_frame {remaining:string}, fiscal.period.early_frame {success_percent:integer}, fiscal.period.guaranteed, fiscal.period.auto_note {remaining:string},
fiscal.harvest, fiscal.harvest_tooltip (curtain: early-harvest risk and that wall time cannot be
bought), fiscal.sweep_preview_frame {periods:integer, credited:integer}, fiscal.next_run_note
(spends and hoard affect the next run only — docs/fiscal-quarters.md), fiscal.hoard_frame {percent:string, cap:integer}, fiscal.levels_label, fiscal.level_frame {level:integer, cap:integer}, fiscal.level_buy {cost:integer}, fiscal.unlocks_label, fiscal.unlock_buy {cost:integer}, fiscal.unlock_owned, fiscal.outcome.early_succeeded, fiscal.outcome.early_failed,
fiscal.outcome.guaranteed, fiscal.outcome.consumed_by_auto, fiscal.rejection.period_not_ripe,
fiscal.rejection.unaffordable, fiscal.rejection.already_unlocked, fiscal.nav_ripe_badge;
catalog reason keys cap.fiscal_credit, cap.fiscal_level.beige_tower (F10). Presentation v4 row
family fiscal_unlocks: [{id, title_key, description_key}] (initially minigame.pitch only).
Acceptance (each with a failing case):
- GS1-A1 projection: Go and TS tests decode a v4 fixture with a non-null Fiscal arm and a
real-Postgres projection test asserts
sweep_previewequals the credit the next harvest receipt reports. Fails if the projector reads persisted credit without the sweep (seeded: pass the unswept state) or the TS decoder accepts an extra key. - GS1-A2 revision scope: browser test with a runtime double asserts harvest sends the Founder
revision. Fails if
scopeis dropped (the double returns 409 and the test asserts the applied path). - GS1-A3 phases: fixture tests at
early_ms − 1,early_ms,guaranteed_msrender disabled / risk-stated / guaranteed with the correct text. Fails if the phase is color-only (text assertion) or off-by-one. - GS1-A4 rejection visibility: each rejection row renders its key in
role="status". Fails if the F2 behavior is restored (receipt discarded → no status text). - GS1-A5 axe/keyboard/reflow: zero serious/critical axe findings in all states × three engines;
keyboard-only harvest + spend; no horizontal scroll at 320 px. Fails on a seeded
unlabeled button and a seeded
min-width: 40rem. - GS1-A6 composed: real gameserver + Postgres, DOM-only: harvest → spend
minigame.pitch→feature.minigame.pitchbecomes true and the Pitch nav button appears. Blocked by OD-5 if the pinned clock is retuned to hours (no fixture clock is permitted).
Backend: server/achievements/evaluate.go, server/achievements/catalog.go; save fields
AchievementsEarnedRun, AchievementScoreRun, AchievementsEarnedLifetime,
AchievementScoreLifetime (server/save/state.go); event achievement_earned.v1.
Design: design/02 §6, design/11 §5 (name + flavor line), design/08 §1.
Surface: achievements / game.surface.achievements / unlock feature.achievements.
Data source — AchievementsArm:
{
score: {run: int, lifetime: int}, // Company run score / Founder lifetime score
rows: [{achievement_id, copy_key, condition_scope: "run"|"career",
proof_kind: "provenance"|"burn"|"possession", score_grant: int,
earned: "run"|"lifetime"|null}]
}
copy_key and proof_kind come from the pinned achievements artifact row; earned is run if in
the Company run set, lifetime if in the Founder lifetime set, else null (the kernels reject
overlap, so exactly one applies).
Props: { arm: AchievementsArm; era: CopyEra } — read-only; no intents, no callbacks.
States: GS0.5; empty = zero rows in the pinned catalog (achievements.empty).
Intents: none. Achievements are earned by the server during ordinary commands only.
Error mapping: none beyond GS0.5 (error if a row's copy_key is missing from the catalog).
Keyboard map: the list is static content; only nav/tab stops. If OD-7 selects a run/career filter, it is a native radio group.
Focus/announcement: host chrome announces achievements.earned_announcement {achievement:string}
once per achievement_earned.v1 cursor, on any surface (design/11 §2: one contextual line; not a
modal). Rows with proof_kind: "possession" show the existing achievement.possession_warning.
Reduced motion: no unlock animation in any mode.
320 px: single-column list; score header wraps.
Copy keys: surface.achievements.title, achievements.score_frame {run:integer, lifetime:integer}, achievements.progress_frame {earned:integer, total:integer},
achievements.scope.run, achievements.scope.career, achievements.state.earned_run,
achievements.state.earned_lifetime, achievements.state.locked, achievements.grant_frame {score:integer}, achievements.earned_announcement {achievement:string}, achievements.empty;
per-row text is the artifact's existing copy_key (12 rows present in the catalog today);
existing achievement.possession_warning.
Acceptance:
- GS2-A1: v4 fixture with run-earned, lifetime-earned and locked rows renders three distinct text states. Fails if state is conveyed only by color/opacity (text assertion on each row).
- GS2-A2 announcement once: replaying the same
achievement_earned.v1envelope after a simulated reconnect produces exactly one announcement. Fails with a seeded decoder that ignores the cursor. - GS2-A3 score is not Clout: a source test asserts no achievements copy key or component binds
CloutLifetimeor labels score as Clout until OD-6 rules otherwise. Fails on a seeded binding. - GS2-A4 composed: DOM-only first generator purchase on the real server earns
achievement.generators_purchased_1; the chrome announcement fires and the row reads earned after refresh. Fails if the event decoder is severed (no announcement) or the arm is severed (row stays locked). - GS2-A5: axe/keyboard/320 px as GS1-A5.
Backend: server/meters/transition.go, server/meters/catalog.go; Company MeterValues,
MeterBands; event meter_band_changed.v1. Design: design/02 §7 — "All meters visible and
published … Cannot be bought"; p(doom) is a pressure meter, not a moral quantity.
Surface: meters / game.surface.meters / unlock feature.meters.
Data source — MetersArm:
{
meters: [{meter_id, value: int, min: int, max: int, band_id,
bands: [{band_id, floor_value: int}]}]
}
Values are the committed Company values as of evaluated_through_ms (Meters advance only on
commands; offline time gives zero elapsed — docs/meters.md). The projection does not
extrapolate decay; the surface labels values "as of last update" (meters.as_of_note).
Props: { arm: MetersArm; era: CopyEra } — read-only.
Layout: a 5 × 2 table (constituencies users/employees/regulators/press/investors ×
Standing/Grievance) plus a separate p(doom) row (OD-8 governs its visibility). Each cell: native
<meter min max value> + numeric text + band text.
States: GS0.5; empty impossible (the artifact requires all eleven IDs).
Intents: none — meters are not spendable (no intent may exist; the loader already enforces disjointness from economy resources).
Keyboard map: none beyond tab stops; the table uses <th scope> headers.
Focus/announcement: while the Meters surface is mounted, meter_band_changed.v1 announces
meters.band_changed_announcement {meter:string, band:string} politely; on other surfaces the nav
button gains meters.nav_changed_badge text until visited (no global announcement — design/11 §1
"everything else is a badge").
Reduced motion: no bar animation.
320 px: below 30rem the table becomes one labeled row per value (<dl>), preserving header
text for each value.
Copy keys: surface.meters.title, meters.constituency.users|employees|regulators|press|investors,
meters.axis.standing, meters.axis.grievance, meters.doom.title, meters.doom.tooltip
(pressure meter, not a moral score), meters.band.low, meters.band.high (presentation v4 row
family meter_bands: [{band_id, title_key}] so future bands fail closed), meters.value_frame {value:integer, max:integer}, meters.as_of_note, meters.curtain (visible, published, never for
sale), meters.band_changed_announcement {meter:string, band:string}, meters.nav_changed_badge.
Presentation v4 row family meters: [{id, title_key}] for the eleven IDs.
Acceptance:
- GS3-A1: decoder rejects a meter value outside
[min,max], a missing ID or an unsorted array. Fails with a seeded lenient decoder. - GS3-A2 non-color: every cell has numeric and band text. Fails on a seeded band-by-color variant.
- GS3-A3 reflow: 320 px collapses to the labeled list with every header preserved. Fails on a seeded fixed-width table.
- GS3-A4 composed: the real server's initial values (Standing 50, Grievance 0, p(doom) 50 from the pinned artifact) render as text after DOM bootstrap. Fails if the arm is severed.
- GS3-A5: axe/keyboard as GS1-A5.
Backend: server/pet/transition.go (ApplyCareTransition, CareStatus), server/pet/state.go
(CareState), server/production/intents.go (handleFounderCare), server/production/founder_replay.go
(care + Soul gate); client kernel client/src/pet/. Design: design/04 §1 and tone guard.
Mounting today: because no pet can exist (F4), feature.pets is always false and the
surface never mounts in production. This section specifies the complete contract so it can be
implemented and tested against fixtures now and mounted the moment a pet producer ships (DG-4).
No placeholder pet, no "adopt" button and no empty-state pitch is shown to players.
Surface: pet / game.surface.pet / unlock feature.pets.
Data source — PetsArm:
{
attended_now_ms: int, // Founder attended time at server_now_ms (existing
// ResolveFounderAttendance sample, read-only)
human_content_locked: bool, // soul.Catalog.HumanContentLocked(Founder.Soul)
pets: [{pet_id,
stats: [{stat_id, value_ppm: int, floor_ppm: int}], // decayed to attended_now_ms
status_band, mood, behavior_state, // CareStatus on the decayed clone
actions: [{action_id, stat_id, cooldown_until_attended_ms: int,
soul_gate: "essential"|"recovery"|"ordinary"}]}]
}
Decay-to-now reuses the kernel's decay step on a discarded clone (GS0.1 rule 2). Trust is not projected until OD-10 rules.
Props:
type PetSurfaceProps = Readonly<{
arm: PetsArm; era: CopyEra; pending: boolean; controlsEnabled: boolean;
onCare(petId: string, actionId: string): void;
lastOutcome: IntentOutcome | undefined;
}>;States: GS0.5; empty cannot mount (unlock fact requires ≥1 pet).
Per action: available, cooldown (cooldown_until_attended_ms > attended_now_ms; remaining shown
as attended time with pet.action.attended_note — cooldowns pause while away), soul_locked
(soul_gate == "ordinary" && human_content_locked). Eligibility and saturation are decided by the
server; the UI does not pre-evaluate min_eligible_ppm or diminishing returns.
Intents (Founder scope): {kind:"care_action", pet_id, action_id}.
Error mapping:
| Rejection | Copy key |
|---|---|
not_eligible/cooldown |
pet.rejection.cooldown |
not_eligible/ineligible |
pet.rejection.ineligible |
not_eligible/saturated |
pet.rejection.saturated |
not_eligible/human_content_locked |
pet.rejection.soul_locked |
unknown_id/unknown_pet, unknown_id/unknown_action |
intent.rejection.unknown + invariant |
Applied receipt: pet.care_applied {action:string} in the status region; mood/band text changes
come from the refreshed snapshot.
Keyboard map: one button per action in catalog order; no other bindings.
Focus/announcement: outcome in the surface status region. pet_status_changed.v1 announces
pet.status_changed_announcement {band:string} only while the surface is mounted.
Reduced motion: the design's visible-behavior cues (walk speed, ears, desaturation) are not
rendered in this RFC (no renderer exists; A4.3 forbids inventing one). behavior_state is text.
320 px: single column; stat rows are label + <meter> + percent text.
Tone: all pet copy is sincere (diegetic), never ironic, never a dark-pattern parody. The
Soul lock text states the fact gently; it does not shame. Visual treatment may only use existing
tokens (OD-11).
Copy keys: surface.pet.title, pet.stat.hunger|energy|cleanliness|affection,
pet.stat_frame {percent:integer}, pet.band.floor|low|normal|high,
pet.mood.withdrawn|restless|neutral|engaged, pet.behavior.idle|care_response|active|resting,
pet.action.cooldown_frame {remaining:string}, pet.action.attended_note, pet.soul_locked_note,
pet.care_applied {action:string}, pet.status_changed_announcement {band:string},
pet.rejection.cooldown|ineligible|saturated|soul_locked. Presentation v4 row family
pet_actions: [{id, title_key, description_key}] for care.feed, care.groom, care.pet,
care.play, care.rest. Pet name/species keys are not reserved (DG-4).
Acceptance:
- GS4-A1 fixture contract: browser tests over v4 fixtures cover available / cooldown / soul-locked states and each rejection row. Fails if soul-locked is conveyed by greying only.
- GS4-A2 never mounts without a pet: with the real epoch-8 bundle and a fresh Founder, the pet
nav button is absent. Fails if a seeded projector emits
feature.pets: truefor an empty map. - GS4-A3 Founder revision: as GS1-A2.
- GS4-A4 tone gate: copy-lint row (new) rejects the
satire/banned-winking list inpet.*keys. Fails on a seeded ironic row. - GS4-A5 composed: explicit blocker until DG-4 ships a pet producer; recorded as blocked, not skipped or vacuously passed.
Backend: server/production/active_play.go (schedule, applyClaimOpportunity,
activePlayContributions, combo clamp), Company PendingOpportunity, ActiveBuffs
(server/save/state.go); client client/src/active-play.ts. Design: design/02 §1 golden
opportunities; design/10 active playstyle.
Placement: a fixed region inside the Desk, immediately after the manual-action section. The region element is always present (empty when nothing is pending) so DOM order never shifts. Not a surface (OD-9): an opportunity on a separate tab would be structurally unclaimable.
Data source — ActivePlayArm:
{
attended_now_ms: int, // ResolveRateProjectionAttendedMS (already computed
// by the v3 projector for rates)
pending: null | {opportunity_id, effect_row_id, selected_generator_id: null|id,
expires_attended_ms: int},
buffs: [{buff_instance_id, effect_row_id, selected_target: null|id,
expires_attended_ms: int}],
combo: {cap: decimal, reason_key: id, saturated: bool} // combo_policy + clamp result
}
pending is emitted only when expires_attended_ms > attended_now_ms; expired buffs are omitted.
Opportunities spawn only inside Company commands (lazy scheduler); the UI never sends a
synthetic command, polls, or otherwise tries to cause a spawn.
Props:
type OpportunityRegionProps = Readonly<{
arm: ActivePlayArm; era: CopyEra; pending: boolean; controlsEnabled: boolean;
onClaim(opportunityId: string): void;
lastOutcome: IntentOutcome | undefined;
}>;States: GS0.5 within the region; empty = no pending and no buffs (region renders nothing
visible but keeps its landmark label).
Remaining time: shown as attended seconds as of the last snapshot
(expires_attended_ms − attended_now_ms), not extrapolated, with desk.opportunity.attended_note
(the window pauses while you are away). Rationale: attended time advances only under command
cadence, so a wall-clock countdown would be false (OD-12).
Intents (Company scope): {kind:"claim_opportunity", opportunity_id}.
Error mapping:
| Rejection | Copy key |
|---|---|
not_eligible/opportunity_expired |
desk.opportunity.rejection.expired |
not_eligible/opportunity_not_pending |
desk.opportunity.rejection.not_pending |
unknown_id/opportunity_id |
desk.opportunity.rejection.not_pending + invariant |
Applied Lucky receipt with saturated:true renders desk.opportunity.lucky_capped plus the
receipt's cap_reason_key text (cap.cash); buff receipts with a non-null cap_reason_key render
cap.active_combo (hardcap visible — design law 5).
Keyboard map: one "claim" button in the region; Tab reaches it in DOM order after the manual action. Focus is never moved to it on spawn (no unexpected context change).
Focus/announcement: a spawn is announced once politely (desk.opportunity.spawned_announcement {effect:string}), keyed by opportunity_id, only while the Desk is mounted. Claim outcome goes to
the Desk status region. The time limit is a server rule accessibility may not extend (A2.2); the
announcement and a reachable control are the accommodation (OD-12).
Reduced motion: no pulse/float/particle; appearance is instant in every mode (the design's golden-cookie animation has no renderer and is not invented).
320 px: region is one card; buff list stacks.
Copy keys: desk.opportunity.region_label, desk.opportunity.claim,
desk.opportunity.remaining_frame {seconds:integer}, desk.opportunity.attended_note,
desk.opportunity.spawned_announcement {effect:string}, desk.opportunity.rejection.expired,
desk.opportunity.rejection.not_pending, desk.opportunity.lucky_frame {amount:canonical_decimal},
desk.opportunity.lucky_capped, desk.opportunity.lucky_tooltip (curtain: the published bank
formula min(fraction × bank, cap × rate) + ε), desk.buffs_label, desk.buff.remaining_frame {seconds:integer}, desk.buff.combo_capped; catalog reason keys cap.active_combo, cap.cash
(F10). Presentation v4 row family opportunity_effects: [{id, title_key, description_key}] for
active.building, active.click, active.lucky, active.production; an unknown effect row
withholds the opportunity and reports an invariant.
Acceptance:
- GS5-A1 stable DOM order: inserting a pending opportunity does not change the index of any existing Desk control and does not move focus. Fails on a seeded conditional region.
- GS5-A2 no synthetic commands: a runtime double counts intents during 60 s of idle Desk with a pending opportunity: zero. Fails on a seeded poll/keepalive intent.
- GS5-A3 cap visibility: fixture Lucky receipt with
saturated:truerenders the cap reason text. Fails if receipts are discarded (F2 regression). - GS5-A4 composed: DOM-only manual clicks on the real server until an opportunity is projected, then a DOM claim; the next snapshot shows a buff or credited Lucky delta. Depends on OD-13 (the current seconds-scale schedule makes this feasible; an hours-scale retune would need a test epoch, not a fixture clock).
- GS5-A5: axe on region states; 320 px; keyboard claim.
Backend reality: purchasable content = generators, upgrades, ladders, pools, provisioning
(docs/purchasable-content.md) — already surfaced on the Desk except provisioning caps. Cosmetics
have no backend (F5). This section therefore specifies only what exists and records the shop
as DG-5.
GS6.1 Provisioned counts and caps. v4 generators[] rows add
provision_cap: null | {amount: int, reason_key: id} from the pinned economy catalog's per-target
provisioning cap (the receipt already exports it). The Desk generator card renders
desk.provisioned_frame {count:integer} when provisioned > 0, and when
provisioned >= provision_cap.amount renders the cap reason text (for generator.beige_tower the
existing generator.beige_tower.provisioned_cap key from presentation). A frozen counter is never
shown without its reason (design law 5).
GS6.2 Upgrade state text. Owned upgrades render desk.upgrade.owned text instead of relying on
a disabled button (non-color state, A3.2). The v3 row has no ineligibility reason; OD-15 decides
whether v4 adds one.
GS6.3 Horse Armor shelf. Unchanged: static, free, on the Desk, curtain intact
(cosmetic.horse_armor_free.*, owner-ruled GU-C14 copy). No shop surface, no nav entry, no
purchase control, no stateful ownership is introduced.
States/intents/keyboard: unchanged Desk behavior; buy_generator/buy_upgrade gain the GS0.2
rejection display (e.g. not_eligible/owned, unaffordable/* → desk.rejection.*).
Copy keys: desk.provisioned_frame {count:integer}, desk.upgrade.owned,
desk.rejection.owned, desk.rejection.unaffordable, desk.rejection.not_in_window.
Acceptance:
- GS6-A1: fixture with
provisioned == provision_cap.amountrenders the reason text. Fails on a seeded card that shows only the number. - GS6-A2: no component submits any intent whose kind is not in the production intent list for a cosmetic ID (source test). Fails on a seeded "buy horse armor" button.
- GS6-A3: Desk 320 px regression (the measured 647 px defect in the accessibility draft) is owned by that RFC; this RFC's additions must not widen the Desk (fixture measurement before/after).
Status of this section: a proposal for the ruling author of
rfc/minigame-api-and-surface.md to reconcile into MA3/MA-C9 (execution-queue item 3h). Until
that RFC's body adopts it, this section is not implementation authority for AC5. It binds only the
Pitch half of MA-C9; the recovery surface is OD-14.
Backend (already mounted through the registry): server/account/minigame_api.go,
server/account/minigame_api_schema.go (operations create_minigame_session,
get_current_minigame_session, play_minigame_command, resolve_minigame_session; exact
APIError pairs per status); generated DTOs in client/src/api/generated/types.ts
(MinigameSessionResponse*, MinigameCurrentResponse, PitchSnapshot, MinigameTenantCommand,
MinigameResolutionReceipt); engine server/pitch/, client client/src/pitch/.
Surface: minigame_session / game.surface.minigame_session / unlock feature.minigame.pitch.
Availability — MinigamesArm (v4):
{ rows: [{minigame_id: "pitch", unlocked: bool, // FiscalUnlocks["minigame.pitch"]
human_content_locked: bool, // soul gate human_hobby at Founder Soul
active_session: bool}] } // same read-only predicate as MA-C12
active_session also feeds the v3/v4 transitions.wind_down.eligible preview (fixes F9 — the
preview becomes false while a session is active, so the Desk shows the Wind Down reason instead of
a silent rejection).
API-client binding (the 3h dependency): components never call fetch. runtime.ts exposes
interface MinigameSessionPort {
current(): Promise<MinigameCurrentResponse>;
create(minigameId: string, idempotencyKey: string): Promise<MinigameSessionResponseActive>;
command(sessionId: string, request: MinigameCommandRequest): Promise<MinigameSessionResponse>;
resolve(sessionId: string): Promise<MinigameSessionResponseTerminal>;
}
// Non-2xx → throws MinigameAPIError { status, category, detail } validated against the exact
// registry error pairs; network failure → throws MinigameTransportError.typed exclusively with the generated DTOs. When API Foundation's generated client (queue item 3g) lands, the port's implementation delegates to it with no change to this interface (OD-2). A second hand-written wire type is forbidden.
Tenant registry: client/src/game-ui/minigame-tenants.generated.ts, generated from the pinned
balance/minigame-api/first-content.json tenants rows, maps (engine_ref, engine_version) →
child component key. Pitch ("pitch","1.0.0") → PitchTable. An unregistered tuple renders the
error state with unknown_id/minigame_tenant (fail closed); no hand-maintained second registry.
Pitch content binding: the snapshot carries only instance IDs and pitch_content_hash. The
client bundles the pinned balance/pitch.json bytes, verifies SHA-256 equals
snapshot.pitch_content_hash, and resolves card base_metric/copy_key, hack price/effect arm
and policy caps from it. Mismatch → error state (minigame.error.content_mismatch); no
deploy-current fallback (OD-16 offers the alternative).
Surface props and state (TypeScript):
type MinigameSessionSurfaceProps = Readonly<{
port: MinigameSessionPort;
availability: MinigamesArm["rows"][number]; // the pitch row
era: CopyEra;
transportReady: boolean;
onExitToHost(): void; // leave table; session persists server-side
onTerminal(receipt: MinigameResolutionReceipt): void; // host runs one authoritative refresh()
}>;
type MinigameSurfaceState =
| { kind: "loading" }
| { kind: "none" } // current() → {kind:"none"}: launcher
| { kind: "active"; session: MinigameSessionDescriptor; snapshot: PitchSnapshot;
inFlight: null | { commandId: string; request: MinigameCommandRequest } }
| { kind: "paused_reconnect"; last: null | { session: MinigameSessionDescriptor;
snapshot: PitchSnapshot }; retry: null | { commandId: string; request: MinigameCommandRequest } }
| { kind: "required_terminal"; response: MinigameSessionResponseTerminal }
| { kind: "error"; category: string; detail: string };
type PitchTableProps = Readonly<{
snapshot: PitchSnapshot; content: PitchContent; // hash-verified bundled artifact
era: CopyEra; pending: boolean;
onPlayHand(cardIds: readonly string[]): void; // 1..policy.play_size instance IDs
onBuyHack(offerId: string): void;
onEndShop(): void;
}>;MA-C9's ruled closed set is loading|active|paused_reconnect|required_terminal|error. This
proposal adds none as the launcher state because MA-C6 ruled {kind:"none"} as a first-class
current-session answer; if the ruling author prefers, none becomes a host-level launcher outside
the surface and the five-state set stands unchanged (OD-17).
Lifecycle and transitions:
| From | Trigger | To |
|---|---|---|
| mount | current() in flight |
loading |
loading |
{kind:"none"} |
none |
loading |
{kind:"active", session.status:"active"} |
active |
loading |
{kind:"active", session.status:"claimed"} |
paused_reconnect (another request holds the claim); retry current() after 1 s, at most 5 times, then error conflict/minigame_session |
none |
Start pressed | create("pitch", key) with a fresh opaque key held in memory until success → active |
active |
command sent | active with inFlight set; all table controls aria-disabled |
active |
response status:"active" |
active with new snapshot, inFlight cleared |
active |
response status:"resolved" |
required_terminal; onTerminal(receipt) |
active |
transport error with inFlight |
paused_reconnect with retry = inFlight |
paused_reconnect |
host transportReady or Retry pressed |
resend the same command_id and body (MA-C13 idempotent receipt); if none pending, current() |
| any | 409 conflict/minigame_revision |
current() then active (selection cleared, status line) |
required_terminal |
Return pressed | onExitToHost() |
| any | unmapped error | error |
resolve() is used only as the MA-C4 retry/read: when a terminal command's response was lost and
the retried command reports the session terminal, or when the surface holds a session_id whose
last snapshot phase was terminal without a receipt. After a page reload a resolved session's
receipt is not re-shown (current returns none); its payout is already in the Game UI snapshot.
Retries never mint a new command_id for the same player action.
Commands: play_hand {card_ids} (selected instance IDs <card_id>#<ordinal>),
buy_hack {offer_id}, end_shop, each wrapped as
{command_id: newIntentID(), expected_revision: session.revision, command}.
Error mapping (exact registry pairs from minigameErrorJSON):
| Status | Pair | Surface result | Copy key |
|---|---|---|---|
| 409 | not_eligible/fiscal_unlock_required |
none + status |
minigame.rejection.fiscal_unlock_required |
| 409 | not_eligible/human_content_locked |
none + status |
minigame.rejection.human_content_locked |
| 409 | not_eligible/exclusive_activity |
none/active + status |
intent.rejection.exclusive_activity |
| 409 | not_eligible/illegal_phase |
refetch via current() |
pitch.rejection.illegal_phase |
| 409 | not_eligible/hand_too_large |
stay active |
pitch.rejection.hand_too_large |
| 409 | not_eligible/duplicate_card, unknown_card |
stay active, clear selection, invariant |
pitch.rejection.bad_selection |
| 409 | not_eligible/insufficient_currency |
stay active |
pitch.rejection.insufficient_currency |
| 409 | not_eligible/hack_slots_full |
stay active |
pitch.rejection.hack_slots_full (+ cap.pitch_hack_slots) |
| 409 | not_eligible/unknown_offer |
refetch | pitch.rejection.unknown_offer |
| 409 | conflict/minigame_revision |
refetch | minigame.conflict.revision |
| 409 | conflict/minigame_session |
paused_reconnect |
minigame.state.paused_reconnect |
| 409 | idempotency_conflict/minigame_command |
error + invariant (client defect: same ID, different body) |
minigame.error.generic |
| 409 | idempotency_conflict/minigame_session |
error + invariant |
minigame.error.generic |
| 404 | unknown_id/minigame_session |
current() |
— |
| 404 | unknown_id/minigame_tenant, unknown_id/founder |
error |
minigame.error.generic |
| 400 | invalid/* |
error + invariant |
minigame.error.generic |
| 401 | unauthorized/access_token |
host credential/offline path | existing |
| 429 | rate_limited/* |
stay; re-enable after 1 s | intent.rate_limited |
| 500/503 | internal_invariant/*, not_configured/minigame_api |
error |
minigame.error.unavailable |
Keyboard map: launcher: Start button. Table: hand as a <fieldset> of native checkboxes (Space
toggles; once play_size are checked, the remainder become aria-disabled with
cap.pitch_play text visible — never silently ignored), then "Pitch these cards" button; shop:
one button per offer, then "Close the shop" (end_shop); "Leave the table" button last
(onExitToHost, session persists). No hotkeys, no Escape binding, no drag.
Focus/announcement: on each new snapshot, focus stays on the pressed control if still present;
if the hand is replaced, focus moves to the hand fieldset legend (tabindex=-1). Round result and
phase change are announced once in the surface status region (pitch.phase_announcement {phase:string, round:integer}); the terminal state focuses the terminal heading (A1.1 forced
context change).
Terminal rendering: from MinigameSessionResponseTerminal only: round reached (snapshot
round), round_best_valuation vs funding_target (both Amounts), credited_delta of
credited_resource_id, the cap_reason_key text when configured_cap_forfeit_units > 0, and the
quality grade change. A "funded / not funded" headline is not rendered: the terminal outcome
fact is not on the wire (DG-6).
Reduced motion: no card deal/flip animation in any mode.
320 px: hand as a wrapping grid minmax(min(8rem,100%),1fr); shop stacks; status header wraps.
Curtain and formulas: each hack shows a mechanically generated effect line from its effect arm
(pitch.effect.* keys with the factor/amount as canonical_decimal params — design law 9); the
dark_pattern hack additionally shows its curtain (pitch.hack.dark_pattern.disclosure). Card
tiles show base_metric via pitch.card.metric_frame.
Copy keys: surface.minigame_session.title, minigame.launcher.pitch.title,
minigame.launcher.pitch.description, minigame.launcher.start, minigame.launcher.locked_fiscal,
minigame.launcher.locked_soul, minigame.launcher.resume, minigame.state.loading,
minigame.state.paused_reconnect, minigame.leave_table, minigame.retry,
minigame.conflict.revision, minigame.error.generic, minigame.error.unavailable,
minigame.error.content_mismatch, minigame.rejection.fiscal_unlock_required,
minigame.rejection.human_content_locked, pitch.round_frame {round:integer, rounds:integer},
pitch.target_frame {target:canonical_decimal}, pitch.best_frame {valuation:canonical_decimal},
pitch.hands_remaining_frame {count:integer}, pitch.currency_frame {amount:integer},
pitch.deck_frame {count:integer}, pitch.hand_label, pitch.play_selected {count:integer, max:integer}, pitch.shop_label, pitch.buy_hack {price:integer}, pitch.end_shop,
pitch.slotted_label, pitch.card.metric_frame {metric:canonical_decimal},
pitch.effect.card_factor {factor:canonical_decimal}, pitch.effect.flat_add {amount:canonical_decimal}, pitch.effect.shape_factor.pair {factor:canonical_decimal},
pitch.effect.shape_factor.full_hand {factor:canonical_decimal}, pitch.effect.chain_factor {partner:string, factor:canonical_decimal}, pitch.hack.dark_pattern.disclosure,
pitch.phase_announcement {phase:string, round:integer}, pitch.phase.playing|shop|terminal,
pitch.terminal.heading, pitch.terminal.round_frame {round:integer},
pitch.terminal.payout_frame {amount:canonical_decimal}, pitch.terminal.return,
pitch.rejection.illegal_phase|hand_too_large|bad_selection|insufficient_currency|hack_slots_full|unknown_offer;
existing pitch.card.*, pitch.hack.*, cap.pitch_*, cap.minigame_faucet.
Acceptance (these are proposed AC5 sub-gates for the Minigame RFC):
- GS7-A1 port boundary: boundary scan proves no component under
client/src/game-ui/importsfetchor declares a minigame wire type outsideapi/generated. Fails on a seeded raw fetch. - GS7-A2 idempotent retry: runtime double drops the response to a committed
play_hand; the surface's retry carries the identicalcommand_idand body and renders the stored response. Fails on a seeded implementation that mints a newcommand_id(double records two IDs). - GS7-A3 registry fail-closed: a snapshot with
(pitch, 9.9.9)renderserror. Fails on a seeded default-to-Pitch fallback. - GS7-A4 content hash: a bundled artifact whose SHA differs from
pitch_content_hashrenderserror content_mismatch. Fails on a seeded skip of the comparison. - GS7-A5 states: each of
loading|none|active|paused_reconnect|required_terminal|errorand every error-table row has a browser test with its copy key. Fails if any row maps to the generic line when a specific key is declared. - GS7-A6 Wind Down honesty: with an active session the Desk Wind Down button is disabled with its reason. Fails on the current projector (F9) — the test is the discriminating witness.
- GS7-A7 composed: DOM-only on the real server: Fiscal unlock (GS1-A6) → Start → play hands →
end_shop/next round → terminal → Return; the Game UI snapshot'scompany.cashreflectscredited_delta; a mid-run socket close exercisespaused_reconnectand resumes. Fails if the command route is severed or the terminal refresh is skipped. - GS7-A8: axe (all states × three engines), keyboard-only full run, 320 px.
- Return-sequence modal (design/11 §1). Design allows one modal on return — the ripe Fiscal Quarter prompt. This RFC ships a nav badge instead, because the return sequence (diorama fast-forward, offline-gains line) it belongs to is unbuilt; a lone modal outside that sequence would be an unexplained interruption (OD-3).
- Pet visible-behavior cues (design/04 §1). Walk speed, ears, desaturation and cursor-seeking
are not rendered;
behavior_state/mood are text. No renderer exists and A4.3 forbids inventing one here. - Golden-opportunity presentation (design/02 §1). No animated golden object; a plain region and button. Same reason.
- "Clout" naming (design/02 §6). The achievements score is not labeled Clout, because the Achievements foundation does not mint Clout (docs/achievements.md) (OD-6).
- The shop (design/04 §4, roadmap v0.1 "shop"). Not delivered: no cosmetic backend (DG-5).
- OD-1 Projection shape. Recommended: one additive
game_ui_snapshotv4 with afeaturesobject (one revision coordinate, one bind path, one refresh). Alternative: one read operation per system (more requests, independent caching, more drift surface). - OD-2 API-client binding for the Pitch. Recommended: runtime-owned
MinigameSessionPorttyped with generated DTOs now; swap implementation to the 3g generated client when it lands, with an exact-error conformance test. Alternative: block GS7 on 3g. - OD-3 Ripe-quarter modal. Recommended: nav badge now; modal deferred to a Return-Sequence RFC. Alternative: ship the single modal now.
- OD-4 Fiscal multi-level spend. Recommended: UI offers
levels: 1only (server-resolved cost shown). Alternative: a level-count input; requires a server-projected cost table (no client triangular arithmetic). - OD-5 Fiscal composed proof under a retuned clock. Recommended: if DG-1 retunes to hours, add a dedicated test epoch with short windows used only by the composed lane. Alternative: accept a fixture-level proof for the harvest window and composed proof for spend only.
- OD-6 Achievement score vs Clout. Recommended: label it "score" until a Clout RFC mints Clout. Alternative: call it Clout now (a copy-level claim the backend does not honor).
- OD-7 Achievements visibility. Recommended: show all rows, locked ones with their text (the current copy states the condition; nothing is secret) and an earned/total count. Alternative: hide locked rows; or add a run/career filter.
- OD-8 p(doom) at T0–T1. Recommended: show it (design/02: all meters visible; the backend
already moves it on
externality.emitted), with the pressure-meter tooltip. Alternative: withhold it until its design tier (Tier 5+), accepting a hidden moving number. - OD-9 Active play placement. Recommended: Desk region only. Alternative: an additional chrome badge visible from every surface.
- OD-10 Pet Trust display. Recommended: not shown numerically; mood/band text carry it (sincerity, not a score to optimize). Alternative: show Trust as a visible bar (hardcap-visibility reading).
- OD-11 Sincere tone. Recommended: use
diegetic+ a pet-scoped lint rule now; add asinceretone to the copy schema only if the lint proves insufficient. Alternative: add the tone now (Copy Pipeline amendment). - OD-12 Opportunity timing display. Recommended: attended seconds as of last snapshot, not extrapolated, with the "pauses while away" note; announce spawn once. Alternative: no remaining time at all; or a wall-clock extrapolation (rejected by this draft as potentially false).
- OD-13 Opportunity schedule scale (F8). Recommended: owner confirms the seconds-scale schedule is intended for T0–T1 before GS5 ships; otherwise route a retune to a balance mint.
- OD-14 Soul-recovery surface. Recommended: the Minigame RFC's ruling author enumerates it in the same MA-C9 reconciliation, using GS7's state/port pattern; this RFC stays Pitch-only. Alternative: extend this RFC with a GS8.
- OD-15 Upgrade ineligibility reason. Recommended: v4 adds
ineligible_reason: null|"owned"|"window"|"requirement"|"unaffordable"derived from the existing projector checks. Alternative: keep boolean-only and show only "owned". - OD-16 Pitch presentation content. Recommended: bundle the pinned
pitch.jsonbytes and verify by hash. Alternative: presentation v4 carries Pitch rows (copy keys only; metrics and prices would then be missing from the table). - OD-17
nonestate. Recommended: addnoneto the MA-C9 closed set. Alternative: keep the five ruled states and move the launcher into the host.
- DG-1 Fiscal clock policy (F7). The live pinned windows are 100/200/300 ms. A Fiscal surface over them shows a quarter ripening three times a second. Options: (a) owner confirms and the surface ships as-is; (b) a balance mint retunes to design/02 §5 values before GS1 ships (recommended); (c) ship GS1 behind the unlock fact but hold the nav button until (b).
- DG-2 Docs drift (F6).
docs/fiscal-quarters.mdanddocs/minigame-the-pitch.mdsay the mechanics are inactive; epoch 6 activated them. Options: (a) correct the docs in a separate change now (recommended; doc-only); (b) correct in this RFC's closeout. - DG-3 Consumer-less unlock (F12).
unlock.arcadecosts credit and does nothing. Options: (a) withhold rows without a presentation row (this draft's behavior); (b) remove the row in the next balance mint; (c) specify the arcade consumer in a design/03 §8 RFC. - DG-4 Pet identity. No producer creates a pet; design/04 says every founder gets one at Tier 0. Options: (a) a Pet Identity RFC (species, name, acquisition, starter-pet activation at a new-run boundary) — recommended, and required before GS4 mounts; (b) keep GS4 unmounted indefinitely.
- DG-5 Cosmetics/shop. No cosmetic items, ownership, grant or intent exists. Options: (a) a Cosmetics Foundation RFC ($0.00, curtain-pulled, stateful ownership, collection book) before any shop surface — recommended; (b) keep the static Horse Armor shelf as v0.1's shop and amend the roadmap row.
- DG-6 Pitch terminal outcome.
funded | funding_failedexists in the certified result but not inMinigameResolutionReceipt. Options: (a) additive receipt field via an API amendment; (b) render round/valuation/payout only (this draft). - DG-7 Missing reason copy (F10).
cap.fiscal_credit,cap.fiscal_level.beige_tower,cap.active_combo,cap.cashmust be owner-authored and registered throughcopy/references.v1.jsonbefore their surfaces can render caps;capForthrows today. - DG-8 Opportunity spawns require commands. A fully idle player never sees an opportunity (lazy scheduler). This is backend behavior, surfaced honestly, not fixed here. Options: (a) accept (active play rewards presence — recommended); (b) a design ruling on an idle-visible spawn, which would be a new scheduler mechanic and its own RFC.
- Per-surface gates: GS1-A1–A6, GS2-A1–A5, GS3-A1–A5, GS4-A1–A5 (A5 recorded as an explicit blocker), GS5-A1–A5, GS6-A1–A3, and — once adopted by the Minigame RFC — GS7-A1–A8, each with its seeded failing case executed and recorded (evidence discipline 1).
- Projection parity: Go projector and TS decoder share v4 fixtures; the TS decoder rejects each of: extra key, missing arm key, unsorted rows, out-of-domain integer, non-canonical Decimal (five seeded fixtures, each must fail).
- Runtime receipts: a unit test proves
intent()returns both outcome arms and throws on an unknownoutcome; restoringPromise<void>fails GS1-A4/GS5-A3. - Boundary:
make verify-client-boundarypasses with the new components (no transport/replay imports, no literals, governed styles only); a seeded literal fails it. - Accessibility:
make test-browseraxe (WCAG 2.2 AA, zero serious/critical) over every new surface/region state in Chromium, Firefox and WebKit; 320 px and 400 % zoom checks over the full mounted page including chrome and the grown nav; keyboard-only completion of every built task. - Composed:
make test-game-ui-composedextended with the GS1-A6 → GS7-A7 Fiscal-to-Pitch journey and GS2-A4/GS3-A4/GS5-A4, DOM-only, no intent bypass or fixture clock. GS4-A5 is listed as blocked, never vacuously green. - Performance: the existing 60-second observable scenario passes unchanged with all regions mounted on the Desk (no new long task > 200 ms).
- Closeout:
docs/game-ui.md(and DG-2 docs if still stale) updated; per-change review recorded; cross-party designated review cites the exact range before archival.
- Whether the draft Accessibility RFC is accepted before this one; if not, GS0.6 is this RFC's own floor and the two are reconciled at acceptance.
- Whether the Phase-0 manifest (D-007) pulls any of these surfaces into the preview. This draft assumes none.
- 2026-09-25: created (draft) from a source read at
c2d9bbc. Records findings F1–F13, proposes the v4 projection, runtime receipt handling, seven surface/region contracts, and the GS7 Pitch enumeration for Minigame RFC MA-C9 reconciliation. No implementation authorized.
Marco accepted this RFC in the 2026-09-25 batch (the answer to a direct batch question in-session, recorded by Claude). Every owner decision this RFC lists is ruled at its stated recommended default. Where the text offers alternatives, the recommended option is the normative one and the alternatives are rejected. Provisional numbers stay provisional and are ratified by harness measurement and SHA, as the RFC already requires. Owner-authored copy stays owner-authored: implementation ships copy keys, with any placeholder or candidate text clearly marked.