The creation UI
Creation is version-independent, since a new game is always created at the newest
version your build ships, so it lives on the Dart GameModule rather than on a
GameRules unit. Three members, none of which you write a dialog for: the shell
renders the dialog from what you declare.
class RpsModule extends GameModule {
const RpsModule();
Map<int, GameRules> get versions => const {1: RpsRulesV1()};
GameCreationSpec get creationSpec => const GameCreationSpec(
timingConfigs: {
'Per move': PerActionConfig(maxSeconds: 300, presets: [30, 60, 120]),
'Untimed': UntimedConfig(),
},
defaultConfig: {'targetWins': 3},
);
Widget? buildCreationConfig({
required ValueChanged<Map<String, dynamic>> onChanged,
}) => _TargetWinsPicker(onChanged: onChanged);
Widget buildRules(BuildContext context) => const RpsRulesPage();
}
creationSpec
timingConfigskeys become segmented-button labels, in insertion order, so the first entry is the default.PerActionConfigrenders presets plus a slider;BudgetConfigadds an increment slider. Floors are enforced on both sides (kMinTurnSeconds30 s,kMinBudgetSeconds120 s).BudgetConfigis only valid for strictly sequential games. The server rejects a hook envelope with more than one pending seat in a budget-timed game as a game bug. If any phase has multiple pending seats, use a per-action mode for it, or aturnSecondsoverride on that envelope.defaultConfigseeds the config map before the player touches anything, so a game with no custom UI still creates a valid game.- Seat counts are not here. They come from the versioned
GameRules.playerLimitstwin, because the server derives them per version and refuses a create whose range it cannot seat. Read them viaplayersForConfig, which delegates to the latest version's twin by default. OverrideplayersForConfigonly to narrow what the dialog offers; a wider range is a failed create, not a bigger game.
buildCreationConfig
Returns a widget for game-specific options, or null if timing and player count
are the whole story. It calls onChanged on every edit; the dialog stores the
latest value in a plain field (not state, since it is never displayed) and
sends it with the create request at submit time.
Whatever it produces is the game's config, and the server validates it
against your configSchema. An out-of-range value is rejected there, so this
widget is a convenience, not a gate.
buildRules
Non-scrolling how-to-play content for the About page. The page supplies the
scroll container, padding and chrome. It is free to be interactive (an animated
board example) and to read Theme.of.
Two constraints from elsewhere
Server-seated bots imply a timed game
If a game can seat a server-dispatched bot, its creation UI must require a turn or budget clock. Bot dispatch is single-attempt, so the turn deadline firing the server's alarm is the only thing that resolves a bot which never moves. The engine enforces this at seating; declaring an untimed-only game that also allows server bots just produces a rejection later.
This does not apply to a local game: "untimed" in the creation UI's solo picker is what routes a bot game to the device instead of the server. Nothing dispatches a local game's bots, so there is no deadline to need. See Offline play.
rated is a validated assertion, not a preference
The client computes rated from the Dart ratingPool twin plus its own guest
status, and sends a concrete value. The server recomputes it and rejects a
mismatch with a 422 rather than coercing.
That is deliberate: coercion would silently paper over a drifted twin or a forged
client, and the twin drifting is exactly the failure this design wants to be
loud. Keep the two ratingPool implementations in agreement, and let
twin fixtures prove it.