BareGit

Define games dynamically in the database

- Replace compiled definitions with version-1 database models, validated
  custom fields, transaction services, and administrator workflows.
- Add dynamic card and series forms, consistent clay styling, role-aware
  navigation, and authentication email logging.
- Start fresh databases without games and expand persistence, HTTP, rollback,
  JavaScript, and browser-focused regression coverage.
Author: MetroWind <chris.corsair@gmail.com>
Date: Thu Sep 10 14:21:30 2026 -0700
Commit: bacdce77a06ecbd1a0fb0342c0a6486513adbaa4

Changes

diff --git a/CMakeLists.txt b/CMakeLists.txt
index 4c29ae9..8def5fc 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -30,7 +30,8 @@ add_executable(
     src/email_address.cpp
     src/email_sender_file.cpp
     src/email_sender_mailjet.cpp
-    src/game_registry.cpp
+    src/game_field.cpp
+    src/game_service.cpp
     src/image_processor.cpp
     src/markdown_renderer.cpp
     src/main.cpp
@@ -149,6 +150,61 @@ if(CARD_COLLECTION_BUILD_TESTS)
     )
     gtest_discover_tests(mvp_primitives_test)
 
+    add_executable(
+        game_field_test
+        src/game_field.cpp
+        tests/game_field_test.cpp
+    )
+    target_compile_features(game_field_test PRIVATE cxx_std_23)
+    set_target_properties(game_field_test PROPERTIES CXX_EXTENSIONS OFF)
+    target_include_directories(
+        game_field_test
+        PRIVATE
+            ${libmw_SOURCE_DIR}/includes
+            src
+    )
+    target_link_libraries(
+        game_field_test
+        PRIVATE
+            GTest::gtest_main
+            mw::mw
+    )
+    gtest_discover_tests(game_field_test)
+
+    add_executable(
+        game_service_test
+        src/authorization.cpp
+        src/data.cpp
+        src/data_sqlite.cpp
+        src/game_field.cpp
+        src/game_service.cpp
+        src/markdown_renderer.cpp
+        src/startup.cpp
+        tests/game_service_test.cpp
+    )
+    target_compile_features(game_service_test PRIVATE cxx_std_23)
+    set_target_properties(
+        game_service_test
+        PROPERTIES CXX_EXTENSIONS OFF
+    )
+    target_include_directories(
+        game_service_test
+        PRIVATE
+            ${libmw_SOURCE_DIR}/includes
+            src
+    )
+    target_link_libraries(
+        game_service_test
+        PRIVATE
+            GTest::gtest_main
+            MacroDown::MacroDown
+            mw::mw
+            mw::sqlite
+            mw::url
+            spdlog::spdlog
+    )
+    gtest_discover_tests(game_service_test)
+
     add_executable(
         user_collection_test
         src/card_pool.cpp
@@ -156,7 +212,7 @@ if(CARD_COLLECTION_BUILD_TESTS)
         src/collection.cpp
         src/data.cpp
         src/data_sqlite.cpp
-        src/game_registry.cpp
+        src/game_field.cpp
         src/startup.cpp
         src/username.cpp
         src/user_service.cpp
@@ -192,7 +248,7 @@ if(CARD_COLLECTION_BUILD_TESTS)
         src/data.cpp
         src/data_sqlite.cpp
         src/email_address.cpp
-        src/game_registry.cpp
+        src/game_field.cpp
         src/secret_token.cpp
         src/startup.cpp
         tests/authentication_test.cpp
@@ -313,7 +369,8 @@ if(CARD_COLLECTION_BUILD_TESTS)
         src/email_address.cpp
         src/email_sender_file.cpp
         src/email_sender_mailjet.cpp
-        src/game_registry.cpp
+        src/game_field.cpp
+        src/game_service.cpp
         src/non_secret_random.cpp
         src/data_fake.cpp
         src/image_processor.cpp
@@ -375,7 +432,8 @@ if(CARD_COLLECTION_BUILD_TESTS)
         src/email_address.cpp
         src/email_sender_file.cpp
         src/email_sender_mailjet.cpp
-        src/game_registry.cpp
+        src/game_field.cpp
+        src/game_service.cpp
         src/image_processor.cpp
         src/markdown_renderer.cpp
         src/multipart_reader.cpp
@@ -451,7 +509,7 @@ if(CARD_COLLECTION_BUILD_TESTS)
         data_sqlite_test
         src/data.cpp
         src/data_sqlite.cpp
-        src/game_registry.cpp
+        src/game_field.cpp
         src/startup.cpp
         tests/data_sqlite_test.cpp
     )
@@ -480,7 +538,7 @@ if(CARD_COLLECTION_BUILD_TESTS)
         src/card_service.cpp
         src/data.cpp
         src/data_sqlite.cpp
-        src/game_registry.cpp
+        src/game_field.cpp
         src/image_processor.cpp
         src/markdown_renderer.cpp
         src/multipart_reader.cpp
@@ -528,5 +586,10 @@ if(CARD_COLLECTION_BUILD_TESTS)
             COMMAND ${NODE_EXECUTABLE} --test
                     ${CMAKE_CURRENT_SOURCE_DIR}/tests/card_preview_math_test.js
         )
+        add_test(
+            NAME GameFieldFormLogicTest
+            COMMAND ${NODE_EXECUTABLE} --test
+                    ${CMAKE_CURRENT_SOURCE_DIR}/tests/game_field_form_logic_test.js
+        )
     endif()
 endif()
diff --git a/designs/design-2-games.md b/designs/design-2-games.md
index 2159e7f..3df5e15 100644
--- a/designs/design-2-games.md
+++ b/designs/design-2-games.md
@@ -1,6 +1,6 @@
 # Database-defined games and custom card fields
 
-Status: proposed implementation design.
+Status: implemented.
 
 This document replaces the compiled-game architecture in
 [design-0-prototype.md](design-0-prototype.md) and the game-registration
@@ -42,14 +42,17 @@ These decisions keep old cards readable and prevent definition edits from
 silently deleting or reinterpreting their values. Bulk value conversion,
 option retirement, and changing field types are separate future features.
 
-Going Home becomes an ordinary database game with short name `gh`, display
-name `Going Home`, an empty description, and no custom fields. It receives
-no runtime special treatment.
+Fresh databases contain no games. Going Home may be created through the same
+administrator interface as any other game, using short name `gh` if desired;
+it receives no runtime special treatment.
 
-Schema migration and existing-data migration are explicitly out of scope.
-Implementation targets a fresh database. No legacy-game adapters, data
-conversion, or compatibility path are required. Definition-editing rules
-still apply to cards created after this feature is installed.
+Migrating existing application data is explicitly out of scope because this
+change is still in development. Implementation targets a fresh database and
+folds the new schema into version 1, so no version-1-to-version-2 migration is
+required. The migration framework remains in place for future released schema
+changes. No legacy-game adapters, data conversion, or compatibility path are
+required. Definition-editing rules still apply to cards created after this
+feature is installed.
 
 ## 2. Current implementation and change boundary
 
@@ -415,7 +418,7 @@ field exactly once. Missing, foreign, or duplicate IDs are invalid.
 Card custom controls are named `game.<key>`, preserving the existing namespace.
 For example, a hypothetical game may submit `game.score=-2`,
 `game.note=Near the station`, `game.route=Forest`, and `game_revision=4`.
-These are examples of the mechanism, not extra fields for Going Home.
+These are examples of the mechanism, not fields for a built-in game.
 
 Add `game_revision` to multipart allowed fields. Keep duplicate detection
 before maps are populated. When switching games on the create form, disable
@@ -488,40 +491,37 @@ unbounded submitted value.
 
 ## 11. Initialization and startup
 
-Create the complete new schema directly in an empty database within one
-transaction. Set `DB_SCHEMA_VERSION` and `user_version` to 2 so an older
-database cannot accidentally be treated as compatible. This is a format
-identifier, not a requirement to implement a version-1 conversion.
+Create the complete dynamic-game schema directly in an empty database within
+one transaction. Keep `DB_SCHEMA_VERSION` and `user_version` at 1. This work
+does not introduce a schema-version bump or a version-conversion path.
 
-Expose `initializeSchema()` without a `GameRegistry` argument. Replace the
-old migration dispatch with initialization for an empty database, acceptance
-of version 2, and a clear error for any other nonempty schema. Never erase
-or reset an existing database automatically. Using a fresh development
-database is a deployment step, not data conversion performed by the server.
+Keep `migrateToLatest()` but remove its `GameRegistry` argument. Replace the
+unreleased version-1 schema and `migrateSchema0To1()` implementation directly;
+do not add a version-2 schema or a version-1-to-2 migration. Preserve ordered
+migration dispatch so future released schema changes can add later migration
+steps. Never erase or reset an existing database automatically.
 
-Initialization creates `gh` with display name Going Home, empty description,
-revision 1, zero custom fields, and a sequence at zero. Seed only on fresh
-initialization. Later startups do not recreate a deleted unused game or
-overwrite edits. All numbering thereafter follows the normal transaction
-path; deleting cards never reduces a sequence.
+Initialization creates no games. Creating a game through the normal
+transaction path creates its sequence at zero. Later startups do not create
+or alter games. Deleting cards never reduces a sequence.
 
 Keep foreign keys enabled throughout initialization and normal operation.
-If initialization fails, roll back all schema and seed changes and refuse
+If initialization fails, roll back all schema changes and refuse
 startup. On reopening, perform ordinary integrity checks and administrator
 reconciliation.
 
 Remove runtime registry creation, compiled-schema calls, and checks requiring
 stored games to have a matching C++ class. Remove `GoingHome` from runtime
-code; its only built-in trace is initial seed data. Replace compiled
-`TestGame` fixtures with database definitions created by test setup. Tests
-need no historical extension tables or legacy conversion mappings.
+code and do not replace it with seed data. Replace compiled `TestGame`
+fixtures with database definitions created by test setup. Tests need no
+historical extension tables or legacy conversion mappings.
 
 ## 12. Implementation sequence
 
 1. Add generic models and decoding with integer/string/choice semantics.
    Verify edge cases independently of HTTP and SQLite.
-2. Add the fresh schema and initialization fixtures. Verify Going Home seed
-   data and reopening before replacing the runtime registration path.
+2. Add the fresh schema and initialization fixtures. Verify that fresh and
+   reopened databases contain no games before replacing the runtime path.
 3. Add game reads, transaction mutations, usage checks, and revisions to the
    persistence boundary, fake, and mock implementations.
 4. Implement `GameService`, including permissions and edit/delete conflicts.
@@ -563,7 +563,7 @@ need no historical extension tables or legacy conversion mappings.
 
 ### Initialization
 
-- A fresh database has the full schema and Going Home with zero fields.
+- A fresh database has the full schema and zero games.
 - Failed initialization rolls back schema and seed data together.
 - Reopening never reseeds or overwrites an administrator's edits.
 - Unsupported nonempty databases are rejected without modification.
@@ -579,12 +579,12 @@ need no historical extension tables or legacy conversion mappings.
 - Unset values remain absent, choices round-trip exactly, and integer values
   above JavaScript's safe-integer range retain precision.
 - Game switching disables inactive controls and sends the correct revision.
-- Going Home remains available with `gh-*` numbering and zero extra fields.
+- An administrator-created zero-field game supports ordinary card numbering.
 - Card pulls, rarity probabilities, assets, login, and role-aware links pass
   existing regression tests.
 - In Firefox and a Chromium browser, inspect desktop/mobile forms, keyboard
   navigation, error focus, choice reordering, and shared clay button styles.
 
 The feature is complete when a newly created database game can use all three
-field types through the normal administrator and card workflows, and Going
-Home works as an ordinary seeded game with zero custom fields.
+field types through the normal administrator and card workflows, while a
+fresh database starts with no game definitions.
diff --git a/src/app.cpp b/src/app.cpp
index ca7e4b5..746ffa0 100644
--- a/src/app.cpp
+++ b/src/app.cpp
@@ -22,9 +22,10 @@
 
 #include "email_sender_file.h"
 #include "email_sender_mailjet.h"
+#include "form_limits.h"
+#include "game_field.h"
 #include "public_id.h"
 #include "multipart_reader.h"
-#include "game_definition.h"
 #include "secret_token.h"
 
 namespace
@@ -173,6 +174,46 @@ const std::unordered_map<std::string, RouteDefinition> ROUTES = {
      {RouteKind::DYNAMIC, {literal("admin"), literal("users")}}},
     {"admin-games",
      {RouteKind::DYNAMIC, {literal("admin"), literal("games")}}},
+    {"game-new",
+     {RouteKind::DYNAMIC,
+      {literal("admin"), literal("games"), literal("new")}}},
+    {"games",
+     {RouteKind::DYNAMIC, {literal("admin"), literal("games")}}},
+    {"game-edit",
+     {RouteKind::DYNAMIC,
+      {literal("admin"), literal("games"), placeholder("short"),
+       literal("edit")}}},
+    {"game-update",
+     {RouteKind::DYNAMIC,
+      {literal("admin"), literal("games"), placeholder("short")}}},
+    {"game-delete",
+     {RouteKind::DYNAMIC,
+      {literal("admin"), literal("games"), placeholder("short"),
+       literal("delete")}}},
+    {"game-field-new",
+     {RouteKind::DYNAMIC,
+      {literal("admin"), literal("games"), placeholder("short"),
+       literal("fields"), literal("new")}}},
+    {"game-fields",
+     {RouteKind::DYNAMIC,
+      {literal("admin"), literal("games"), placeholder("short"),
+       literal("fields")}}},
+    {"game-field-edit",
+     {RouteKind::DYNAMIC,
+      {literal("admin"), literal("games"), placeholder("short"),
+       literal("fields"), placeholder("integer"), literal("edit")}}},
+    {"game-field-update",
+     {RouteKind::DYNAMIC,
+      {literal("admin"), literal("games"), placeholder("short"),
+       literal("fields"), placeholder("integer")}}},
+    {"game-field-delete",
+     {RouteKind::DYNAMIC,
+      {literal("admin"), literal("games"), placeholder("short"),
+       literal("fields"), placeholder("integer"), literal("delete")}}},
+    {"game-field-order",
+     {RouteKind::DYNAMIC,
+      {literal("admin"), literal("games"), placeholder("short"),
+       literal("field-order")}}},
     {"admin-promote",
      {RouteKind::DYNAMIC,
       {literal("admin"), literal("users"), placeholder("integer"),
@@ -272,6 +313,18 @@ bool indexCardLess(const IndexCard& left, const IndexCard& right)
     return left.card->id < right.card->id;
 }
 
+bool hasFieldKey(
+    const GameDefinitionSnapshot& definition,
+    const std::string& key)
+{
+    return std::ranges::any_of(
+        definition.fields,
+        [&key](const GameField& field)
+        {
+            return field.key == key;
+        });
+}
+
 void sortIndexCards(std::vector<IndexCard>& cards, bool descending)
 {
     if(descending)
@@ -323,6 +376,34 @@ void respondBadRequest(
     response.set_content(message + "\n", "text/plain; charset=utf-8");
 }
 
+bool enforceTextBudget(
+    const App::Request& request, App::Response& response)
+{
+    std::size_t total_size = 0;
+    for(const auto& [name, value] : request.params)
+    {
+        [[maybe_unused]] const std::string& ignored_name = name;
+        if(value.size() > MAX_FORM_TEXT_FIELD_SIZE)
+        {
+            response.status = 413;
+            response.set_content(
+                "A form field is too large\n",
+                "text/plain; charset=utf-8");
+            return false;
+        }
+        if(value.size() > MAX_FORM_TOTAL_TEXT_SIZE - total_size)
+        {
+            response.status = 413;
+            response.set_content(
+                "Form metadata is too large\n",
+                "text/plain; charset=utf-8");
+            return false;
+        }
+        total_size += value.size();
+    }
+    return true;
+}
+
 mw::E<std::string> renderWithHtml(
     inja::Environment& environment,
     const inja::Template& page_template,
@@ -424,6 +505,52 @@ std::optional<std::string> optionalText(
     return value;
 }
 
+std::string submittedText(
+    const std::unordered_map<std::string, std::string>& fields,
+    const std::string& name,
+    std::string fallback = {})
+{
+    const auto position = fields.find(name);
+    return position == fields.end()
+        ? std::move(fallback)
+        : position->second;
+}
+
+std::string renderableGameText(const std::string& value)
+{
+    return validGameText(value) ? value : std::string();
+}
+
+std::string gameControlId(std::string_view field_name)
+{
+    if(field_name == "short_name")
+    {
+        return "GameShortName";
+    }
+    if(field_name == "description")
+    {
+        return "GameDescription";
+    }
+    return "GameDisplayName";
+}
+
+std::string fieldControlId(std::string_view field_name)
+{
+    if(field_name == "key")
+    {
+        return "FieldKey";
+    }
+    if(field_name == "type")
+    {
+        return "FieldType";
+    }
+    if(field_name == "choice")
+    {
+        return "ChoiceEditor";
+    }
+    return "FieldLabel";
+}
+
 mw::E<std::int64_t> parseRarity(
     const std::unordered_map<std::string, std::string>& fields)
 {
@@ -466,6 +593,28 @@ mw::E<std::int64_t> parseRevision(
     return revision;
 }
 
+mw::E<std::int64_t> parseGameRevision(
+    const std::unordered_map<std::string, std::string>& fields)
+{
+    const auto position = fields.find("game_revision");
+    if(position == fields.end())
+    {
+        return std::unexpected(mw::httpError(
+            400, "Game revision is required"));
+    }
+    std::int64_t revision = 0;
+    const std::string& text = position->second;
+    const auto result = std::from_chars(
+        text.data(), text.data() + text.size(), revision);
+    if(text.empty() || result.ec != std::errc{} ||
+       result.ptr != text.data() + text.size() || revision < 1)
+    {
+        return std::unexpected(mw::httpError(
+            400, "Game revision must be a positive integer"));
+    }
+    return revision;
+}
+
 std::optional<std::int64_t> parsePositiveId(const std::string& value)
 {
     std::int64_t id = 0;
@@ -479,10 +628,61 @@ std::optional<std::int64_t> parsePositiveId(const std::string& value)
     return id;
 }
 
-FormFields gameFields(
+mw::E<std::string> scalarParameter(
+    const App::Request& request,
+    const std::string& name,
+    bool required = true)
+{
+    const auto values = request.get_param_values(name);
+    if(values.size() > 1)
+    {
+        return std::unexpected(mw::httpError(
+            400, "The form field '" + name + "' was supplied more than once"));
+    }
+    if(values.empty())
+    {
+        if(required)
+        {
+            return std::unexpected(mw::httpError(
+                400, "The form field '" + name + "' is required"));
+        }
+        return std::string();
+    }
+    return values.front();
+}
+
+mw::E<std::int64_t> positiveParameter(
+    const App::Request& request, const std::string& name)
+{
+    auto text = scalarParameter(request, name);
+    if(!text)
+    {
+        return std::unexpected(std::move(text.error()));
+    }
+    auto value = parsePositiveId(*text);
+    if(!value)
+    {
+        return std::unexpected(mw::httpError(
+            400, "The form field '" + name + "' must be a positive integer"));
+    }
+    return *value;
+}
+
+std::optional<std::string> pathParameter(
+    const App::Request& request, const std::string& name)
+{
+    const auto position = request.path_params.find(name);
+    if(position == request.path_params.end() || position->second.empty())
+    {
+        return std::nullopt;
+    }
+    return position->second;
+}
+
+SubmittedGameFields gameFields(
     const std::unordered_map<std::string, std::string>& fields)
 {
-    FormFields result;
+    SubmittedGameFields result;
     for(const auto& [name, value] : fields)
     {
         if(name.starts_with("game.") && name.size() > 5)
@@ -494,9 +694,7 @@ FormFields gameFields(
 }
 
 mw::E<std::vector<std::int64_t>> seriesMemberships(
-    const std::vector<std::string>& values,
-    const std::string& game_short_name,
-    DataSourceInterface& data_source)
+    const std::vector<std::string>& values)
 {
     std::vector<std::int64_t> result;
     result.reserve(values.size());
@@ -508,16 +706,6 @@ mw::E<std::vector<std::int64_t>> seriesMemberships(
             return std::unexpected(mw::httpError(
                 422, "Series IDs must be positive integers"));
         }
-        auto series = data_source.getSeries(*id);
-        if(!series)
-        {
-            return std::unexpected(std::move(series.error()));
-        }
-        if(!*series || (**series).game_short_name != game_short_name)
-        {
-            return std::unexpected(mw::httpError(
-                422, "A selected series does not belong to the card game"));
-        }
         if(std::ranges::find(result, *id) != result.end())
         {
             return std::unexpected(mw::httpError(
@@ -528,6 +716,103 @@ mw::E<std::vector<std::int64_t>> seriesMemberships(
     return result;
 }
 
+std::string fieldValue(
+    const std::vector<GameFieldValue>& values, std::int64_t field_id)
+{
+    const auto position = std::ranges::find_if(
+        values,
+        [field_id](const GameFieldValue& value)
+        {
+            return value.field_id == field_id;
+        });
+    if(position == values.end())
+    {
+        return {};
+    }
+    if(const auto integer = std::get_if<std::int64_t>(&position->value))
+    {
+        return std::to_string(*integer);
+    }
+    return std::get<std::string>(position->value);
+}
+
+inja::json gameFormJson(
+    const GameDefinitionSnapshot& definition,
+    const std::vector<GameFieldValue>& values,
+    const SubmittedGameFields* submitted = nullptr,
+    const GameFieldValidationError* error = nullptr)
+{
+    inja::json fields = inja::json::array();
+    for(const GameField& field : definition.fields)
+    {
+        inja::json choices = inja::json::array();
+        for(const GameChoice& choice : field.choices)
+        {
+            choices.push_back(choice.value);
+        }
+        std::string displayed_value = fieldValue(values, field.id);
+        if(submitted != nullptr)
+        {
+            const auto submitted_value = submitted->find(field.key);
+            if(submitted_value != submitted->end() &&
+               validGameText(submitted_value->second))
+            {
+                displayed_value = submitted_value->second;
+            }
+        }
+        const bool has_error = error != nullptr &&
+            error->field_key == field.key;
+        const std::string control_id =
+            "GameField-" + definition.game.short_name + "-" + field.key;
+        fields.push_back({
+            {"choices", std::move(choices)},
+            {"control_id", control_id},
+            {"error_id", control_id + "-error"},
+            {"error_message", has_error ? error->msg : std::string()},
+            {"has_error", has_error},
+            {"is_choice", field.type == GameFieldType::CHOICE},
+            {"is_integer", field.type == GameFieldType::INTEGER},
+            {"is_string", field.type == GameFieldType::STRING},
+            {"key", field.key},
+            {"label", field.label},
+            {"value", std::move(displayed_value)},
+        });
+    }
+    return {
+        {"display_name", definition.game.display_name},
+        {"fields", std::move(fields)},
+        {"revision", definition.game.revision},
+        {"short_name", definition.game.short_name},
+    };
+}
+
+mw::E<std::vector<GameDefinitionSnapshot>> loadGameDefinitions(
+    DataSourceInterface& data_source)
+{
+    auto games = data_source.getGames();
+    if(!games)
+    {
+        return std::unexpected(std::move(games.error()));
+    }
+    std::vector<GameDefinitionSnapshot> definitions;
+    definitions.reserve(games->size());
+    for(const Game& game : *games)
+    {
+        auto definition = data_source.getGameDefinition(game.short_name);
+        if(!definition)
+        {
+            return std::unexpected(std::move(definition.error()));
+        }
+        if(!*definition)
+        {
+            return std::unexpected(mw::runtimeError(
+                "A listed game disappeared while rendering a form"));
+        }
+        definitions.push_back(std::move(**definition));
+    }
+    return definitions;
+}
+
 bool isRegularFile(const std::filesystem::path& path, std::int64_t card_id)
 {
     std::error_code filesystem_error;
@@ -549,20 +834,18 @@ bool isRegularFile(const std::filesystem::path& path, std::int64_t card_id)
 App::App(
     const Config& config,
     std::unique_ptr<DataSourceInterface> data_source,
-    std::unique_ptr<GameRegistry> games,
     std::unique_ptr<NonSecretRandom> random)
         : mw::HTTPServer(config.listen_address),
           config_(config),
           data_source_(std::move(data_source)),
-          games_(std::move(games)),
           random_(std::move(random)),
           url_builder_(config.base_url),
           templates_(config.static_root.parent_path() / "templates")
 {
-    if(!data_source_ || !games_ || !random_)
+    if(!data_source_ || !random_)
     {
         throw std::invalid_argument(
-            "App requires data, games, and a random generator");
+            "App requires data and a random generator");
     }
 
     clock_ = std::make_unique<SystemClock>();
@@ -588,7 +871,8 @@ App::App(
         ImageProcessor(config_.avif_quality, config_.thumbnail_long_side),
         AssetStore(config_.card_storage_root));
     series_service_ = std::make_unique<SeriesService>(
-        *data_source_, *games_);
+        *data_source_);
+    game_service_ = std::make_unique<GameService>(*data_source_);
 
     templates_.set_html_autoescape(true);
     templates_.add_callback(
@@ -624,6 +908,7 @@ App::App(
     card_delete_template_ = templates_.parse_template("card_delete.html");
     card_index_template_ = templates_.parse_template("card_index.html");
     card_view_template_ = templates_.parse_template("card_view.html");
+    game_admin_template_ = templates_.parse_template("game_admin.html");
     series_delete_template_ = templates_.parse_template(
         "series_delete.html");
     series_form_template_ = templates_.parse_template("series_form.html");
@@ -743,122 +1028,506 @@ void App::addNavigationData(inja::json& data, const User& user) const
     };
 }
 
-void App::handleWelcome(const Request& request, Response& response)
-{
-    auto token = cookieValue(request, "card_collection_session");
-    if(token)
-    {
-        auto session = authentication_service_->session(*token);
-        if(!session)
-        {
-            respondInternalError(response);
-            return;
-        }
-        if(*session)
-        {
-            response.status = 303;
-            response.set_header(
-                "Location",
-                (**session).user.username
-                    ? urlFor("collection")
-                    : urlFor("onboarding"));
-            return;
-        }
-        setCookie(response, "card_collection_session", "", 0, false);
-    }
-    respondTemplate(templates_, "welcome.html", {
-        {"authentication_url", urlFor("authentication")},
-        {"example_url", urlFor("static", {"card_placeholder.svg"})},
-        {"title", "Card Collection"},
-    }, response);
-}
-
-void App::handleAuthentication(
-    [[maybe_unused]] const Request& request,
-    Response& response)
+void App::respondMutationError(
+    Response& response,
+    const mw::Error& error,
+    std::string_view operation,
+    const std::string& reload_url,
+    const User& user,
+    bool files_need_reselection)
 {
-    auto nonce = generateSecretToken(*crypto_);
-    if(!nonce)
-    {
+    const mw::HTTPError* http_error = error.as<mw::HTTPError>();
+    const GameFieldValidationError* field_error =
+        error.as<GameFieldValidationError>();
+    const GameDefinitionValidationError* definition_error =
+        error.as<GameDefinitionValidationError>();
+    if(field_error == nullptr && definition_error == nullptr &&
+       (http_error == nullptr || http_error->code >= 500))
+    {
+        spdlog::error("{}: {}", operation, error.msg());
         respondInternalError(response);
         return;
     }
-    setCookie(
-        response, "card_collection_auth_form", nonce->value, 600, true);
-    response.set_header("Cache-Control", "no-store");
-    respondTemplate(templates_, "authentication.html", {
-        {"action_url", urlFor("authentication-email")},
-        {"form_nonce", nonce->value},
-        {"title", "Sign in · Card Collection"},
-    }, response);
+    const int status = field_error == nullptr && definition_error == nullptr
+        ? http_error->code : 422;
+    const bool conflict = status == 409;
+    inja::json data = {
+        {"conflict", conflict},
+        {"files_need_reselection", files_need_reselection},
+        {"message", error.msg()},
+        {"reload_url", reload_url},
+        {"title", conflict
+            ? "Changes conflicted · Card Collection"
+            : "Check your changes · Card Collection"},
+    };
+    addNavigationData(data, user);
+    response.status = status;
+    respondTemplate(templates_, "mutation_error.html", data, response);
 }
 
-void App::handleAuthenticationEmail(
-    const Request& request, Response& response)
+void App::respondCardFieldError(
+    Response& response,
+    const GameFieldValidationError& error,
+    const CardUpload& upload,
+    const RequestIdentity& identity,
+    const Card* card)
 {
-    const auto nonce = cookieValue(request, "card_collection_auth_form");
-    if(!nonce || request.get_param_value_count("form_nonce") != 1 ||
-       !constantTimeEqual(*nonce, request.get_param_value("form_nonce")))
-    {
-        response.status = 403;
-        response.set_content("Invalid form token\n", "text/plain");
-        return;
-    }
-    if(request.get_param_value_count("email") != 1)
+    auto definitions = loadGameDefinitions(*data_source_);
+    auto all_series = data_source_->getSeries();
+    if(!definitions || !all_series)
     {
-        respondBadRequest(response, "Email is required");
+        respondInternalError(response);
         return;
     }
-    auto sent = authentication_service_->requestEmail(
-        request.get_param_value("email"));
-    if(!sent)
+    const std::string selected_game = card != nullptr
+        ? card->identity.game_short_name.value_or("")
+        : submittedText(upload.fields, "game");
+    const SubmittedGameFields submitted = gameFields(upload.fields);
+    inja::json games = inja::json::array();
+    std::string error_control_id;
+    for(const GameDefinitionSnapshot& definition : *definitions)
     {
-        const auto* limited = sent.error().as<AuthenticationRateLimitError>();
-        if(limited != nullptr)
+        const bool selected =
+            definition.game.short_name == selected_game;
+        if(selected && hasFieldKey(definition, error.field_key))
         {
-            response.status = 429;
-            response.set_header(
-                "Retry-After", std::to_string(limited->retry_after));
-            response.set_content(limited->msg + "\n", "text/plain");
-            return;
+            error_control_id = "GameField-" + selected_game + "-" +
+                error.field_key;
         }
-        respondOperationError(
-            response, sent.error(), "Failed to send authentication email");
-        return;
+        games.push_back(gameFormJson(
+            definition,
+            {},
+            selected ? &submitted : nullptr,
+            selected ? &error : nullptr));
     }
-    setCookie(response, "card_collection_auth_form", "", 0, true);
-    response.status = 303;
-    response.set_header("Location", urlFor("authentication-sent"));
-}
-
-void App::handleAuthenticationSent(
-    [[maybe_unused]] const Request& request,
-    Response& response)
-{
-    response.set_header("Cache-Control", "no-store");
-    respondTemplate(templates_, "authentication_sent.html", {
-        {"title", "Check your email · Card Collection"},
-    }, response);
-}
 
-void App::handleAuthenticationConfirm(
-    const Request& request, Response& response)
-{
-    const auto parameter = request.path_params.find("token");
-    if(parameter == request.path_params.end())
+    inja::json series = inja::json::array();
+    for(const Series& item : *all_series)
     {
-        respondBadRequest(response, "Invalid authentication link");
-        return;
+        const std::string id = std::to_string(item.id);
+        series.push_back({
+            {"game", item.game_short_name},
+            {"id", item.id},
+            {"name", item.name},
+            {"selected", std::ranges::find(
+                upload.series_ids, id) != upload.series_ids.end()},
+        });
     }
-    auto challenge = authentication_service_->validate(parameter->second);
-    if(!challenge)
+
+    const bool editing = card != nullptr;
+    std::string public_id;
+    std::string front_url = urlFor(
+        "static", {"card_placeholder.svg"});
+    std::string foil_url;
+    if(editing)
     {
-        const auto* http_error = challenge.error().as<mw::HTTPError>();
-        if(http_error != nullptr && http_error->code == 400)
+        auto formatted = formatPublicId(card->identity);
+        if(!formatted)
         {
-            respondBadRequest(response, "Invalid authentication link");
+            respondInternalError(response);
+            return;
         }
-        else
+        public_id = std::move(*formatted);
+        front_url = urlFor(
+            "card-asset",
+            {public_id + "/front-art." + card->front_extension},
+            {{"v", std::to_string(card->revision)}});
+        if(card->foil_extension)
+        {
+            foil_url = urlFor(
+                "card-asset",
+                {public_id + "/foil." + *card->foil_extension},
+                {{"v", std::to_string(card->revision)}});
+        }
+    }
+
+    inja::json data = {
+        {"action_url", editing
+            ? urlFor("card-edit", {public_id})
+            : urlFor("cards")},
+        {"back_url", editing
+            ? urlFor("card", {public_id})
+            : urlFor(identity.session.user.role == UserRole::ADMINISTRATOR
+                ? "card-index" : "creator-cards")},
+        {"csrf_token", identity.session.csrf_token},
+        {"display_id", editing ? uppercaseAscii(public_id) : "New addition"},
+        {"error_control_id", error_control_id},
+        {"error_message", error.msg},
+        {"files_need_reselection", true},
+        {"foil_action", "keep"},
+        {"foil_url", foil_url},
+        {"front_url", front_url},
+        {"games", std::move(games)},
+        {"has_errors", true},
+        {"has_foil", editing && card->foil_extension.has_value()},
+        {"heading", editing ? "Edit card" : "Create card"},
+        {"long_description", submittedText(
+            upload.fields,
+            "long_description",
+            editing ? card->long_description.value_or("") : "")},
+        {"mode", editing ? "edit" : "create"},
+        {"model_url", urlFor("static", {"foil/model/card.obj"})},
+        {"name", submittedText(
+            upload.fields, "name", editing ? card->name : "")},
+        {"preview_script_url",
+         urlFor("static", {"foil/card_preview.js"})},
+        {"rarity", submittedText(
+            upload.fields,
+            "rarity",
+            editing ? std::to_string(card->rarity) : "0")},
+        {"revision", editing ? card->revision : 0},
+        {"selected_game", selected_game},
+        {"series", std::move(series)},
+        {"shader_fragment_url",
+         urlFor("static", {"foil/frag-shader.glsl"})},
+        {"shader_vertex_url",
+         urlFor("static", {"foil/vert-shader.glsl"})},
+        {"short_description", submittedText(
+            upload.fields,
+            "short_description",
+            editing ? card->short_description.value_or("") : "")},
+        {"show_rarity",
+         identity.session.user.role == UserRole::ADMINISTRATOR},
+        {"spectral_lut_url",
+         urlFor("static", {"foil/spectral_xyz.bin"})},
+        {"submit_label", editing ? "Save changes" : "Create card"},
+        {"thumbnail_long_side", config_.thumbnail_long_side},
+        {"title", editing
+            ? "Edit " + card->name + " · Card Collection"
+            : "Create card · Card Collection"},
+    };
+    addNavigationData(data, identity.session.user);
+    try
+    {
+        response.status = 422;
+        response.set_content(
+            templates_.render(card_form_template_, data),
+            "text/html; charset=utf-8");
+    }
+    catch(const std::exception& render_error)
+    {
+        spdlog::error(
+            "Failed to re-render card validation: {}",
+            render_error.what());
+        respondInternalError(response);
+    }
+}
+
+void App::respondGameDefinitionError(
+    Response& response,
+    const GameDefinitionValidationError& error,
+    const RequestIdentity& identity,
+    const std::string& short_name,
+    const std::string& display_name,
+    const std::string& description,
+    bool editing)
+{
+    std::optional<GameDefinitionSnapshot> definition;
+    if(editing)
+    {
+        auto loaded = data_source_->getGameDefinition(short_name);
+        if(!loaded)
+        {
+            respondInternalError(response);
+            return;
+        }
+        if(!*loaded)
+        {
+            respondNotFound(response);
+            return;
+        }
+        definition = std::move(**loaded);
+    }
+
+    inja::json fields = inja::json::array();
+    if(definition)
+    {
+        for(std::size_t index = 0;
+            index < definition->fields.size();
+            ++index)
+        {
+            const GameField& field = definition->fields[index];
+            std::vector<std::int64_t> up_order;
+            std::vector<std::int64_t> down_order;
+            for(const GameField& candidate : definition->fields)
+            {
+                up_order.push_back(candidate.id);
+                down_order.push_back(candidate.id);
+            }
+            if(index > 0)
+            {
+                std::swap(up_order[index], up_order[index - 1]);
+            }
+            if(index + 1 < down_order.size())
+            {
+                std::swap(down_order[index], down_order[index + 1]);
+            }
+            fields.push_back({
+                {"choice_count", field.choices.size()},
+                {"delete_url", urlFor(
+                    "game-field-delete",
+                    {short_name, std::to_string(field.id)})},
+                {"down_order", down_order},
+                {"edit_url", urlFor(
+                    "game-field-edit",
+                    {short_name, std::to_string(field.id)})},
+                {"id", field.id},
+                {"is_first", index == 0},
+                {"is_last", index + 1 == definition->fields.size()},
+                {"key", field.key},
+                {"label", field.label},
+                {"type", std::string(gameFieldTypeName(field.type))},
+                {"up_order", up_order},
+            });
+        }
+    }
+
+    const std::int64_t revision = definition
+        ? definition->game.revision : 0;
+    inja::json data = {
+        {"action_url", editing
+            ? urlFor("game-update", {short_name})
+            : urlFor("games")},
+        {"add_field_url", editing
+            ? urlFor("game-field-new", {short_name}) : ""},
+        {"back_url", urlFor("admin-games")},
+        {"csrf_token", identity.session.csrf_token},
+        {"delete_url", editing
+            ? urlFor("game-delete", {short_name}) : ""},
+        {"description", renderableGameText(description)},
+        {"display_name", renderableGameText(display_name)},
+        {"error_control_id", gameControlId(error.field_name)},
+        {"error_field", error.field_name},
+        {"error_message", error.msg},
+        {"field_order_url", editing
+            ? urlFor("game-field-order", {short_name}) : ""},
+        {"fields", std::move(fields)},
+        {"game_revision", revision},
+        {"has_errors", true},
+        {"heading", editing
+            ? "Edit " + definition->game.display_name
+            : "Create game"},
+        {"mode", editing ? "edit" : "create"},
+        {"short_name", renderableGameText(short_name)},
+        {"submit_label", editing ? "Save game" : "Create game"},
+        {"title", editing
+            ? "Edit " + definition->game.display_name +
+                " · Card Collection"
+            : "Create game · Card Collection"},
+    };
+    addNavigationData(data, identity.session.user);
+    response.status = 422;
+    respondTemplate(templates_, "game_form.html", data, response);
+}
+
+void App::respondGameFieldDefinitionError(
+    Response& response,
+    const GameDefinitionValidationError& error,
+    const RequestIdentity& identity,
+    const std::string& short_name,
+    std::optional<std::int64_t> field_id,
+    const std::string& key,
+    const std::string& label,
+    const std::string& type,
+    const std::vector<std::string>& choices)
+{
+    auto loaded = data_source_->getGameDefinition(short_name);
+    if(!loaded)
+    {
+        respondInternalError(response);
+        return;
+    }
+    if(!*loaded)
+    {
+        respondNotFound(response);
+        return;
+    }
+    const GameDefinitionSnapshot& definition = **loaded;
+    const GameField* stored_field = nullptr;
+    if(field_id)
+    {
+        const auto position = std::ranges::find_if(
+            definition.fields,
+            [field_id](const GameField& field)
+            {
+                return field.id == *field_id;
+            });
+        if(position == definition.fields.end())
+        {
+            respondNotFound(response);
+            return;
+        }
+        stored_field = &*position;
+    }
+
+    inja::json choice_data = inja::json::array();
+    for(const std::string& value : choices)
+    {
+        const bool existing = stored_field != nullptr &&
+            std::ranges::any_of(
+                stored_field->choices,
+                [&value](const GameChoice& choice)
+                {
+                    return choice.value == value;
+                });
+        choice_data.push_back({
+            {"existing", existing},
+            {"value", renderableGameText(value)},
+        });
+    }
+    const bool editing = field_id.has_value();
+    const std::string id = editing ? std::to_string(*field_id) : "";
+    const std::string displayed_key = stored_field == nullptr
+        ? renderableGameText(key) : stored_field->key;
+    const std::string displayed_type = stored_field == nullptr
+        ? type : std::string(gameFieldTypeName(stored_field->type));
+    inja::json data = {
+        {"action_url", editing
+            ? urlFor("game-field-update", {short_name, id})
+            : urlFor("game-fields", {short_name})},
+        {"back_url", urlFor("game-edit", {short_name})},
+        {"choices", std::move(choice_data)},
+        {"csrf_token", identity.session.csrf_token},
+        {"delete_url", editing
+            ? urlFor("game-field-delete", {short_name, id}) : ""},
+        {"error_control_id", fieldControlId(error.field_name)},
+        {"error_field", error.field_name},
+        {"error_message", error.msg},
+        {"field_type", displayed_type},
+        {"game_name", definition.game.display_name},
+        {"game_revision", definition.game.revision},
+        {"has_errors", true},
+        {"heading", editing
+            ? "Edit " + stored_field->label : "Add custom field"},
+        {"key", displayed_key},
+        {"label", renderableGameText(label)},
+        {"mode", editing ? "edit" : "create"},
+        {"submit_label", editing ? "Save field" : "Add field"},
+        {"title", editing
+            ? "Edit field · Card Collection"
+            : "Add field · Card Collection"},
+    };
+    addNavigationData(data, identity.session.user);
+    response.status = 422;
+    respondTemplate(templates_, "game_field_form.html", data, response);
+}
+
+void App::handleWelcome(const Request& request, Response& response)
+{
+    auto token = cookieValue(request, "card_collection_session");
+    if(token)
+    {
+        auto session = authentication_service_->session(*token);
+        if(!session)
+        {
+            respondInternalError(response);
+            return;
+        }
+        if(*session)
+        {
+            response.status = 303;
+            response.set_header(
+                "Location",
+                (**session).user.username
+                    ? urlFor("collection")
+                    : urlFor("onboarding"));
+            return;
+        }
+        setCookie(response, "card_collection_session", "", 0, false);
+    }
+    respondTemplate(templates_, "welcome.html", {
+        {"authentication_url", urlFor("authentication")},
+        {"example_url", urlFor("static", {"card_placeholder.svg"})},
+        {"title", "Card Collection"},
+    }, response);
+}
+
+void App::handleAuthentication(
+    [[maybe_unused]] const Request& request,
+    Response& response)
+{
+    auto nonce = generateSecretToken(*crypto_);
+    if(!nonce)
+    {
+        respondInternalError(response);
+        return;
+    }
+    setCookie(
+        response, "card_collection_auth_form", nonce->value, 600, true);
+    response.set_header("Cache-Control", "no-store");
+    respondTemplate(templates_, "authentication.html", {
+        {"action_url", urlFor("authentication-email")},
+        {"form_nonce", nonce->value},
+        {"title", "Sign in · Card Collection"},
+    }, response);
+}
+
+void App::handleAuthenticationEmail(
+    const Request& request, Response& response)
+{
+    const auto nonce = cookieValue(request, "card_collection_auth_form");
+    if(!nonce || request.get_param_value_count("form_nonce") != 1 ||
+       !constantTimeEqual(*nonce, request.get_param_value("form_nonce")))
+    {
+        response.status = 403;
+        response.set_content("Invalid form token\n", "text/plain");
+        return;
+    }
+    if(request.get_param_value_count("email") != 1)
+    {
+        respondBadRequest(response, "Email is required");
+        return;
+    }
+    auto sent = authentication_service_->requestEmail(
+        request.get_param_value("email"));
+    if(!sent)
+    {
+        const auto* limited = sent.error().as<AuthenticationRateLimitError>();
+        if(limited != nullptr)
+        {
+            response.status = 429;
+            response.set_header(
+                "Retry-After", std::to_string(limited->retry_after));
+            response.set_content(limited->msg + "\n", "text/plain");
+            return;
+        }
+        respondOperationError(
+            response, sent.error(), "Failed to send authentication email");
+        return;
+    }
+    setCookie(response, "card_collection_auth_form", "", 0, true);
+    response.status = 303;
+    response.set_header("Location", urlFor("authentication-sent"));
+}
+
+void App::handleAuthenticationSent(
+    [[maybe_unused]] const Request& request,
+    Response& response)
+{
+    response.set_header("Cache-Control", "no-store");
+    respondTemplate(templates_, "authentication_sent.html", {
+        {"title", "Check your email · Card Collection"},
+    }, response);
+}
+
+void App::handleAuthenticationConfirm(
+    const Request& request, Response& response)
+{
+    const auto parameter = request.path_params.find("token");
+    if(parameter == request.path_params.end())
+    {
+        respondBadRequest(response, "Invalid authentication link");
+        return;
+    }
+    auto challenge = authentication_service_->validate(parameter->second);
+    if(!challenge)
+    {
+        const auto* http_error = challenge.error().as<mw::HTTPError>();
+        if(http_error != nullptr && http_error->code == 400)
+        {
+            respondBadRequest(response, "Invalid authentication link");
+        }
+        else
         {
             spdlog::error(
                 "Failed to validate authentication link: {}",
@@ -1102,131 +1771,944 @@ void App::handleCollection(const Request& request, Response& response)
             {"url", urlFor("card", {public_id})},
         });
     }
-    const bool pull_disabled = pool->empty() || user->stored_pulls == 0;
-    inja::json template_data = {
-        {"available_pulls", user->stored_pulls},
+    const bool pull_disabled = pool->empty() || user->stored_pulls == 0;
+    inja::json template_data = {
+        {"available_pulls", user->stored_pulls},
+        {"csrf_token", identity->session.csrf_token},
+        {"entries", std::move(entries)},
+        {"pool_empty", pool->empty()},
+        {"pull_disabled", pull_disabled},
+        {"pull_url", urlFor("collection-pull")},
+        {"title", "Collection · Card Collection"},
+    };
+    addNavigationData(template_data, identity->session.user);
+    respondTemplate(
+        templates_, "collection.html", template_data, response);
+}
+
+void App::handleCollectionPull(const Request& request, Response& response)
+{
+    auto identity = requireIdentity(request, response, false);
+    if(!identity || !verifyCsrf(request, *identity, response))
+    {
+        return;
+    }
+    auto pulled = collection_service_->pull(identity->session.user.id);
+    if(!pulled)
+    {
+        respondOperationError(response, pulled.error(), "Failed card pull");
+        return;
+    }
+    response.status = 303;
+    auto public_id = formatPublicId(pulled->card.identity);
+    if(!public_id)
+    {
+        respondInternalError(response);
+        return;
+    }
+    response.set_header(
+        "Location", urlFor("card", {*public_id}));
+}
+
+void App::handleAdminUsers(const Request& request, Response& response)
+{
+    auto identity = requireIdentity(request, response, true);
+    if(!identity)
+    {
+        return;
+    }
+    AuthorizationService authorization;
+    if(!authorization.canAdminister(identity->session.user))
+    {
+        response.status = 403;
+        return;
+    }
+    auto users = data_source_->getUsers();
+    if(!users)
+    {
+        respondInternalError(response);
+        return;
+    }
+    inja::json template_users = inja::json::array();
+    for(const User& user : *users)
+    {
+        template_users.push_back({
+            {"can_promote", user.role == UserRole::PLAYER},
+            {"email", user.email},
+            {"promote_url", urlFor(
+                "admin-promote", {std::to_string(user.id)})},
+            {"role", roleName(user.role)},
+            {"username", user.username.value_or("Onboarding")},
+        });
+    }
+    inja::json template_data = {
+        {"csrf_token", identity->session.csrf_token},
+        {"title", "Users · Card Collection"},
+        {"users", std::move(template_users)},
+    };
+    addNavigationData(template_data, identity->session.user);
+    respondTemplate(
+        templates_, "user_admin.html", template_data, response);
+}
+
+void App::handleAdminGames(const Request& request, Response& response)
+{
+    auto identity = requireIdentity(request, response, true);
+    if(!identity)
+    {
+        return;
+    }
+    AuthorizationService authorization;
+    if(!authorization.canAdminister(identity->session.user))
+    {
+        response.status = 403;
+        return;
+    }
+
+    auto games = data_source_->getGames();
+    if(!games)
+    {
+        respondInternalError(response);
+        return;
+    }
+    inja::json template_games = inja::json::array();
+    std::vector<HtmlSubstitution> html_substitutions;
+    for(const Game& game : *games)
+    {
+        auto definition = data_source_->getGameDefinition(game.short_name);
+        if(!definition || !*definition)
+        {
+            respondInternalError(response);
+            return;
+        }
+        inja::json fields = inja::json::array();
+        for(const GameField& field : (**definition).fields)
+        {
+            fields.push_back({
+                {"input_type", std::string(gameFieldTypeName(field.type))},
+                {"key", field.key},
+                {"label", field.label},
+            });
+        }
+        const Game& current_game = (**definition).game;
+        std::string description_marker;
+        if(!current_game.description.empty())
+        {
+            auto rendered = MarkdownRenderer().render(
+                current_game.description);
+            if(!rendered)
+            {
+                respondInternalError(response);
+                return;
+            }
+            description_marker =
+                "CARD_COLLECTION_HTML_" + random_->hex(16);
+            html_substitutions.push_back({
+                description_marker, std::move(*rendered)});
+        }
+        template_games.push_back({
+            {"description", description_marker},
+            {"display_name", current_game.display_name},
+            {"edit_url", urlFor(
+                "game-edit", {current_game.short_name})},
+            {"field_count", (**definition).fields.size()},
+            {"fields", std::move(fields)},
+            {"has_description", !current_game.description.empty()},
+            {"initial", current_game.short_name.substr(0, 1)},
+            {"short_name", current_game.short_name},
+        });
+    }
+    inja::json template_data = {
+        {"create_card_url", urlFor("card-new")},
+        {"create_url", urlFor("game-new")},
+        {"games", std::move(template_games)},
+        {"series_url", urlFor("series-index")},
+        {"title", "Games · Card Collection"},
+    };
+    addNavigationData(template_data, identity->session.user);
+    auto content = renderWithHtml(
+        templates_,
+        game_admin_template_,
+        template_data,
+        html_substitutions);
+    if(!content)
+    {
+        spdlog::error(
+            "Failed to render game administration: {}",
+            content.error().msg());
+        respondInternalError(response);
+        return;
+    }
+    response.status = 200;
+    response.set_content(*content, "text/html; charset=utf-8");
+}
+
+void App::handleGameNew(const Request& request, Response& response)
+{
+    auto identity = requireIdentity(request, response, true);
+    AuthorizationService authorization;
+    if(!identity)
+    {
+        return;
+    }
+    if(!authorization.canAdminister(identity->session.user))
+    {
+        response.status = 403;
+        return;
+    }
+    inja::json data = {
+        {"action_url", urlFor("games")},
+        {"back_url", urlFor("admin-games")},
+        {"csrf_token", identity->session.csrf_token},
+        {"description", ""},
+        {"display_name", ""},
+        {"heading", "Create game"},
+        {"error_control_id", ""},
+        {"error_field", ""},
+        {"error_message", ""},
+        {"has_errors", false},
+        {"mode", "create"},
+        {"short_name", ""},
+        {"submit_label", "Create game"},
+        {"title", "Create game · Card Collection"},
+    };
+    addNavigationData(data, identity->session.user);
+    respondTemplate(templates_, "game_form.html", data, response);
+}
+
+void App::handleGameCreate(const Request& request, Response& response)
+{
+    auto identity = requireIdentity(request, response, false);
+    if(!identity || !verifyCsrf(request, *identity, response))
+    {
+        return;
+    }
+    if(!enforceTextBudget(request, response))
+    {
+        return;
+    }
+    auto short_name = scalarParameter(request, "short_name");
+    auto display_name = scalarParameter(request, "display_name");
+    auto description = scalarParameter(request, "description", false);
+    if(!short_name || !display_name || !description)
+    {
+        const mw::Error& error = !short_name ? short_name.error()
+            : !display_name ? display_name.error() : description.error();
+        respondOperationError(response, error, "Invalid game form");
+        return;
+    }
+    auto created = game_service_->createGame(
+        identity->session.user.id, *short_name,
+        *display_name, *description);
+    if(!created)
+    {
+        const auto* validation =
+            created.error().as<GameDefinitionValidationError>();
+        if(validation != nullptr)
+        {
+            respondGameDefinitionError(
+                response,
+                *validation,
+                *identity,
+                *short_name,
+                *display_name,
+                *description,
+                false);
+            return;
+        }
+        respondMutationError(
+            response,
+            created.error(),
+            "Failed game create",
+            urlFor("game-new"),
+            identity->session.user);
+        return;
+    }
+    response.status = 303;
+    response.set_header("Location", urlFor("game-edit", {*created}));
+}
+
+void App::handleGameEdit(const Request& request, Response& response)
+{
+    auto identity = requireIdentity(request, response, true);
+    AuthorizationService authorization;
+    if(!identity)
+    {
+        return;
+    }
+    if(!authorization.canAdminister(identity->session.user))
+    {
+        response.status = 403;
+        return;
+    }
+    auto short_name = pathParameter(request, "short");
+    if(!short_name)
+    {
+        respondNotFound(response);
+        return;
+    }
+    auto definition = data_source_->getGameDefinition(*short_name);
+    if(!definition)
+    {
+        respondInternalError(response);
+        return;
+    }
+    if(!*definition)
+    {
+        respondNotFound(response);
+        return;
+    }
+    const GameDefinitionSnapshot& snapshot = **definition;
+    inja::json fields = inja::json::array();
+    for(std::size_t index = 0; index < snapshot.fields.size(); ++index)
+    {
+        const GameField& field = snapshot.fields[index];
+        std::vector<std::int64_t> up_order;
+        std::vector<std::int64_t> down_order;
+        for(const GameField& candidate : snapshot.fields)
+        {
+            up_order.push_back(candidate.id);
+            down_order.push_back(candidate.id);
+        }
+        if(index > 0)
+        {
+            std::swap(up_order[index], up_order[index - 1]);
+        }
+        if(index + 1 < down_order.size())
+        {
+            std::swap(down_order[index], down_order[index + 1]);
+        }
+        fields.push_back({
+            {"choice_count", field.choices.size()},
+            {"delete_url", urlFor(
+                "game-field-delete",
+                {*short_name, std::to_string(field.id)})},
+            {"down_order", down_order},
+            {"edit_url", urlFor(
+                "game-field-edit",
+                {*short_name, std::to_string(field.id)})},
+            {"id", field.id},
+            {"is_first", index == 0},
+            {"is_last", index + 1 == snapshot.fields.size()},
+            {"key", field.key},
+            {"label", field.label},
+            {"type", std::string(gameFieldTypeName(field.type))},
+            {"up_order", up_order},
+        });
+    }
+    inja::json data = {
+        {"action_url", urlFor("game-update", {*short_name})},
+        {"add_field_url", urlFor("game-field-new", {*short_name})},
+        {"back_url", urlFor("admin-games")},
+        {"csrf_token", identity->session.csrf_token},
+        {"delete_url", urlFor("game-delete", {*short_name})},
+        {"description", snapshot.game.description},
+        {"display_name", snapshot.game.display_name},
+        {"field_order_url", urlFor("game-field-order", {*short_name})},
+        {"fields", std::move(fields)},
+        {"game_revision", snapshot.game.revision},
+        {"heading", "Edit " + snapshot.game.display_name},
+        {"error_control_id", ""},
+        {"error_field", ""},
+        {"error_message", ""},
+        {"has_errors", false},
+        {"mode", "edit"},
+        {"short_name", snapshot.game.short_name},
+        {"submit_label", "Save game"},
+        {"title", "Edit " + snapshot.game.display_name +
+            " · Card Collection"},
+    };
+    addNavigationData(data, identity->session.user);
+    respondTemplate(templates_, "game_form.html", data, response);
+}
+
+void App::handleGameUpdate(const Request& request, Response& response)
+{
+    auto identity = requireIdentity(request, response, false);
+    if(!identity || !verifyCsrf(request, *identity, response))
+    {
+        return;
+    }
+    if(!enforceTextBudget(request, response))
+    {
+        return;
+    }
+    auto short_name = pathParameter(request, "short");
+    auto revision = positiveParameter(request, "game_revision");
+    auto display_name = scalarParameter(request, "display_name");
+    auto description = scalarParameter(request, "description", false);
+    if(!short_name || !revision || !display_name || !description)
+    {
+        if(!short_name)
+        {
+            respondNotFound(response);
+        }
+        else
+        {
+            const mw::Error& error = !revision ? revision.error()
+                : !display_name ? display_name.error()
+                                : description.error();
+            respondOperationError(response, error, "Invalid game form");
+        }
+        return;
+    }
+    auto updated = game_service_->updateGame(
+        identity->session.user.id, *short_name, *revision,
+        *display_name, *description);
+    if(!updated)
+    {
+        const auto* validation =
+            updated.error().as<GameDefinitionValidationError>();
+        if(validation != nullptr)
+        {
+            respondGameDefinitionError(
+                response,
+                *validation,
+                *identity,
+                *short_name,
+                *display_name,
+                *description,
+                true);
+            return;
+        }
+        respondMutationError(
+            response,
+            updated.error(),
+            "Failed game update",
+            urlFor("game-edit", {*short_name}),
+            identity->session.user);
+        return;
+    }
+    response.status = 303;
+    response.set_header("Location", urlFor("game-edit", {*short_name}));
+}
+
+void App::handleGameDeleteConfirm(
+    const Request& request, Response& response)
+{
+    auto identity = requireIdentity(request, response, true);
+    AuthorizationService authorization;
+    if(!identity)
+    {
+        return;
+    }
+    if(!authorization.canAdminister(identity->session.user))
+    {
+        response.status = 403;
+        return;
+    }
+    auto short_name = pathParameter(request, "short");
+    auto definition = short_name
+        ? data_source_->getGameDefinition(*short_name)
+        : mw::E<std::optional<GameDefinitionSnapshot>>(
+            std::optional<GameDefinitionSnapshot>{});
+    if(!definition || !*definition)
+    {
+        if(!definition)
+        {
+            respondInternalError(response);
+        }
+        else
+        {
+            respondNotFound(response);
+        }
+        return;
+    }
+    inja::json data = {
+        {"action_url", urlFor("game-delete", {*short_name})},
+        {"back_url", urlFor("game-edit", {*short_name})},
+        {"csrf_token", identity->session.csrf_token},
+        {"display_name", (**definition).game.display_name},
+        {"game_revision", (**definition).game.revision},
+        {"title", "Delete " + (**definition).game.display_name +
+            " · Card Collection"},
+    };
+    addNavigationData(data, identity->session.user);
+    respondTemplate(templates_, "game_delete.html", data, response);
+}
+
+void App::handleGameDelete(const Request& request, Response& response)
+{
+    auto identity = requireIdentity(request, response, false);
+    if(!identity || !verifyCsrf(request, *identity, response))
+    {
+        return;
+    }
+    if(!enforceTextBudget(request, response))
+    {
+        return;
+    }
+    auto short_name = pathParameter(request, "short");
+    auto revision = positiveParameter(request, "game_revision");
+    if(!short_name || !revision)
+    {
+        if(!short_name)
+        {
+            respondNotFound(response);
+        }
+        else
+        {
+            respondOperationError(
+                response, revision.error(), "Invalid game deletion");
+        }
+        return;
+    }
+    auto deleted = game_service_->removeGame(
+        identity->session.user.id, *short_name, *revision);
+    if(!deleted)
+    {
+        respondMutationError(
+            response,
+            deleted.error(),
+            "Failed game delete",
+            urlFor("game-edit", {*short_name}),
+            identity->session.user);
+        return;
+    }
+    response.status = 303;
+    response.set_header("Location", urlFor("admin-games"));
+}
+
+void App::handleGameFieldNew(const Request& request, Response& response)
+{
+    auto identity = requireIdentity(request, response, true);
+    AuthorizationService authorization;
+    if(!identity)
+    {
+        return;
+    }
+    if(!authorization.canAdminister(identity->session.user))
+    {
+        response.status = 403;
+        return;
+    }
+    auto short_name = pathParameter(request, "short");
+    auto definition = short_name
+        ? data_source_->getGameDefinition(*short_name)
+        : mw::E<std::optional<GameDefinitionSnapshot>>(
+            std::optional<GameDefinitionSnapshot>{});
+    if(!definition || !*definition)
+    {
+        if(!definition)
+        {
+            respondInternalError(response);
+        }
+        else
+        {
+            respondNotFound(response);
+        }
+        return;
+    }
+    inja::json data = {
+        {"action_url", urlFor("game-fields", {*short_name})},
+        {"back_url", urlFor("game-edit", {*short_name})},
+        {"choices", inja::json::array()},
+        {"csrf_token", identity->session.csrf_token},
+        {"field_type", "STRING"},
+        {"game_name", (**definition).game.display_name},
+        {"game_revision", (**definition).game.revision},
+        {"heading", "Add custom field"},
+        {"error_control_id", ""},
+        {"error_field", ""},
+        {"error_message", ""},
+        {"has_errors", false},
+        {"key", ""},
+        {"label", ""},
+        {"mode", "create"},
+        {"submit_label", "Add field"},
+        {"title", "Add field · Card Collection"},
+    };
+    addNavigationData(data, identity->session.user);
+    respondTemplate(templates_, "game_field_form.html", data, response);
+}
+
+void App::handleGameFieldCreate(
+    const Request& request, Response& response)
+{
+    auto identity = requireIdentity(request, response, false);
+    if(!identity || !verifyCsrf(request, *identity, response))
+    {
+        return;
+    }
+    if(!enforceTextBudget(request, response))
+    {
+        return;
+    }
+    auto short_name = pathParameter(request, "short");
+    auto revision = positiveParameter(request, "game_revision");
+    auto key = scalarParameter(request, "key");
+    auto label = scalarParameter(request, "label");
+    auto type_text = scalarParameter(request, "type");
+    if(!short_name || !revision || !key || !label || !type_text)
+    {
+        if(!short_name)
+        {
+            respondNotFound(response);
+        }
+        else
+        {
+            const mw::Error& error = !revision ? revision.error()
+                : !key ? key.error() : !label ? label.error()
+                : type_text.error();
+            respondOperationError(response, error, "Invalid field form");
+        }
+        return;
+    }
+    auto type = parseGameFieldType(*type_text);
+    if(!type)
+    {
+        respondGameFieldDefinitionError(
+            response,
+            {"type", "Unknown field type"},
+            *identity,
+            *short_name,
+            std::nullopt,
+            *key,
+            *label,
+            *type_text,
+            request.get_param_values("choice"));
+        return;
+    }
+    const std::vector<std::string> choices =
+        request.get_param_values("choice");
+    auto created = game_service_->createField(
+        identity->session.user.id, *short_name, *revision,
+        *key, *label, *type, choices);
+    if(!created)
+    {
+        const auto* validation =
+            created.error().as<GameDefinitionValidationError>();
+        if(validation != nullptr)
+        {
+            respondGameFieldDefinitionError(
+                response,
+                *validation,
+                *identity,
+                *short_name,
+                std::nullopt,
+                *key,
+                *label,
+                *type_text,
+                choices);
+            return;
+        }
+        respondMutationError(
+            response,
+            created.error(),
+            "Failed field create",
+            urlFor("game-field-new", {*short_name}),
+            identity->session.user);
+        return;
+    }
+    response.status = 303;
+    response.set_header("Location", urlFor("game-edit", {*short_name}));
+}
+
+void App::handleGameFieldEdit(
+    const Request& request, Response& response)
+{
+    auto identity = requireIdentity(request, response, true);
+    AuthorizationService authorization;
+    if(!identity)
+    {
+        return;
+    }
+    if(!authorization.canAdminister(identity->session.user))
+    {
+        response.status = 403;
+        return;
+    }
+    auto short_name = pathParameter(request, "short");
+    auto id_text = pathParameter(request, "id");
+    auto field_id = id_text ? parsePositiveId(*id_text) : std::nullopt;
+    if(!short_name || !field_id)
+    {
+        respondNotFound(response);
+        return;
+    }
+    auto definition = data_source_->getGameDefinition(*short_name);
+    if(!definition || !*definition)
+    {
+        if(!definition)
+        {
+            respondInternalError(response);
+        }
+        else
+        {
+            respondNotFound(response);
+        }
+        return;
+    }
+    const auto field = std::ranges::find_if(
+        (**definition).fields,
+        [field_id](const GameField& candidate)
+        {
+            return candidate.id == *field_id;
+        });
+    if(field == (**definition).fields.end())
+    {
+        respondNotFound(response);
+        return;
+    }
+    inja::json choices = inja::json::array();
+    for(const GameChoice& choice : field->choices)
+    {
+        choices.push_back({{"existing", true}, {"value", choice.value}});
+    }
+    inja::json data = {
+        {"action_url", urlFor(
+            "game-field-update", {*short_name, std::to_string(*field_id)})},
+        {"back_url", urlFor("game-edit", {*short_name})},
+        {"choices", std::move(choices)},
         {"csrf_token", identity->session.csrf_token},
-        {"entries", std::move(entries)},
-        {"pool_empty", pool->empty()},
-        {"pull_disabled", pull_disabled},
-        {"pull_url", urlFor("collection-pull")},
-        {"title", "Collection · Card Collection"},
+        {"delete_url", urlFor(
+            "game-field-delete", {*short_name, std::to_string(*field_id)})},
+        {"field_type", std::string(gameFieldTypeName(field->type))},
+        {"game_name", (**definition).game.display_name},
+        {"game_revision", (**definition).game.revision},
+        {"heading", "Edit " + field->label},
+        {"error_control_id", ""},
+        {"error_field", ""},
+        {"error_message", ""},
+        {"has_errors", false},
+        {"key", field->key},
+        {"label", field->label},
+        {"mode", "edit"},
+        {"submit_label", "Save field"},
+        {"title", "Edit field · Card Collection"},
     };
-    addNavigationData(template_data, identity->session.user);
-    respondTemplate(
-        templates_, "collection.html", template_data, response);
+    addNavigationData(data, identity->session.user);
+    respondTemplate(templates_, "game_field_form.html", data, response);
 }
 
-void App::handleCollectionPull(const Request& request, Response& response)
+void App::handleGameFieldUpdate(
+    const Request& request, Response& response)
 {
     auto identity = requireIdentity(request, response, false);
     if(!identity || !verifyCsrf(request, *identity, response))
     {
         return;
     }
-    auto pulled = collection_service_->pull(identity->session.user.id);
-    if(!pulled)
+    if(!enforceTextBudget(request, response))
     {
-        respondOperationError(response, pulled.error(), "Failed card pull");
         return;
     }
-    response.status = 303;
-    auto public_id = formatPublicId(pulled->card.identity);
-    if(!public_id)
+    auto short_name = pathParameter(request, "short");
+    auto id_text = pathParameter(request, "id");
+    auto field_id = id_text ? parsePositiveId(*id_text) : std::nullopt;
+    auto revision = positiveParameter(request, "game_revision");
+    auto label = scalarParameter(request, "label");
+    if(!short_name || !field_id || !revision || !label)
     {
-        respondInternalError(response);
+        if(!short_name || !field_id)
+        {
+            respondNotFound(response);
+        }
+        else
+        {
+            respondOperationError(
+                response, !revision ? revision.error() : label.error(),
+                "Invalid field form");
+        }
         return;
     }
-    response.set_header(
-        "Location", urlFor("card", {*public_id}));
+    const std::vector<std::string> choices =
+        request.get_param_values("choice");
+    auto updated = game_service_->updateField(
+        identity->session.user.id, *short_name, *field_id, *revision,
+        *label, choices);
+    if(!updated)
+    {
+        const auto* validation =
+            updated.error().as<GameDefinitionValidationError>();
+        if(validation != nullptr)
+        {
+            respondGameFieldDefinitionError(
+                response,
+                *validation,
+                *identity,
+                *short_name,
+                *field_id,
+                "",
+                *label,
+                "",
+                choices);
+            return;
+        }
+        respondMutationError(
+            response,
+            updated.error(),
+            "Failed field update",
+            urlFor(
+                "game-field-edit",
+                {*short_name, std::to_string(*field_id)}),
+            identity->session.user);
+        return;
+    }
+    response.status = 303;
+    response.set_header("Location", urlFor("game-edit", {*short_name}));
 }
 
-void App::handleAdminUsers(const Request& request, Response& response)
+void App::handleGameFieldDeleteConfirm(
+    const Request& request, Response& response)
 {
     auto identity = requireIdentity(request, response, true);
+    AuthorizationService authorization;
     if(!identity)
     {
         return;
     }
-    AuthorizationService authorization;
     if(!authorization.canAdminister(identity->session.user))
     {
         response.status = 403;
         return;
     }
-    auto users = data_source_->getUsers();
-    if(!users)
+    auto short_name = pathParameter(request, "short");
+    auto id_text = pathParameter(request, "id");
+    auto field_id = id_text ? parsePositiveId(*id_text) : std::nullopt;
+    if(!short_name || !field_id)
     {
-        respondInternalError(response);
+        respondNotFound(response);
         return;
     }
-    inja::json template_users = inja::json::array();
-    for(const User& user : *users)
+    auto definition = data_source_->getGameDefinition(*short_name);
+    if(!definition || !*definition)
     {
-        template_users.push_back({
-            {"can_promote", user.role == UserRole::PLAYER},
-            {"email", user.email},
-            {"promote_url", urlFor(
-                "admin-promote", {std::to_string(user.id)})},
-            {"role", roleName(user.role)},
-            {"username", user.username.value_or("Onboarding")},
+        if(!definition)
+        {
+            respondInternalError(response);
+        }
+        else
+        {
+            respondNotFound(response);
+        }
+        return;
+    }
+    const auto field = std::ranges::find_if(
+        (**definition).fields,
+        [field_id](const GameField& candidate)
+        {
+            return candidate.id == *field_id;
         });
+    if(field == (**definition).fields.end())
+    {
+        respondNotFound(response);
+        return;
     }
-    inja::json template_data = {
+    inja::json data = {
+        {"action_url", urlFor(
+            "game-field-delete", {*short_name, std::to_string(*field_id)})},
+        {"back_url", urlFor(
+            "game-field-edit", {*short_name, std::to_string(*field_id)})},
         {"csrf_token", identity->session.csrf_token},
-        {"title", "Users · Card Collection"},
-        {"users", std::move(template_users)},
+        {"field_label", field->label},
+        {"game_revision", (**definition).game.revision},
+        {"title", "Delete field · Card Collection"},
     };
-    addNavigationData(template_data, identity->session.user);
-    respondTemplate(
-        templates_, "user_admin.html", template_data, response);
+    addNavigationData(data, identity->session.user);
+    respondTemplate(templates_, "game_field_delete.html", data, response);
 }
 
-void App::handleAdminGames(const Request& request, Response& response)
+void App::handleGameFieldDelete(
+    const Request& request, Response& response)
 {
-    auto identity = requireIdentity(request, response, true);
-    if(!identity)
+    auto identity = requireIdentity(request, response, false);
+    if(!identity || !verifyCsrf(request, *identity, response))
     {
         return;
     }
-    AuthorizationService authorization;
-    if(!authorization.canAdminister(identity->session.user))
+    if(!enforceTextBudget(request, response))
     {
-        response.status = 403;
         return;
     }
+    auto short_name = pathParameter(request, "short");
+    auto id_text = pathParameter(request, "id");
+    auto field_id = id_text ? parsePositiveId(*id_text) : std::nullopt;
+    auto revision = positiveParameter(request, "game_revision");
+    if(!short_name || !field_id || !revision)
+    {
+        if(!short_name || !field_id)
+        {
+            respondNotFound(response);
+        }
+        else
+        {
+            respondOperationError(
+                response, revision.error(), "Invalid field deletion");
+        }
+        return;
+    }
+    auto deleted = game_service_->removeField(
+        identity->session.user.id, *short_name, *field_id, *revision);
+    if(!deleted)
+    {
+        respondMutationError(
+            response,
+            deleted.error(),
+            "Failed field delete",
+            urlFor(
+                "game-field-edit",
+                {*short_name, std::to_string(*field_id)}),
+            identity->session.user);
+        return;
+    }
+    response.status = 303;
+    response.set_header("Location", urlFor("game-edit", {*short_name}));
+}
 
-    inja::json template_games = inja::json::array();
-    for(const GameDefinition* game : games_->games())
+void App::handleGameFieldOrder(
+    const Request& request, Response& response)
+{
+    auto identity = requireIdentity(request, response, false);
+    if(!identity || !verifyCsrf(request, *identity, response))
     {
-        inja::json fields = inja::json::array();
-        const std::vector<GameFormField> form_fields = game->formFields();
-        for(const GameFormField& field : form_fields)
+        return;
+    }
+    if(!enforceTextBudget(request, response))
+    {
+        return;
+    }
+    auto short_name = pathParameter(request, "short");
+    auto revision = positiveParameter(request, "game_revision");
+    std::vector<std::int64_t> field_ids;
+    for(const std::string& text : request.get_param_values("field_id"))
+    {
+        auto field_id = parsePositiveId(text);
+        if(!field_id)
         {
-            fields.push_back({
-                {"input_type", field.input_type},
-                {"label", field.label},
-                {"name", field.name},
-            });
+            response.status = 422;
+            response.set_content("Invalid field order\n", "text/plain");
+            return;
         }
-        template_games.push_back({
-            {"description", std::string(game->description())},
-            {"display_name", std::string(game->displayName())},
-            {"field_count", form_fields.size()},
-            {"fields", std::move(fields)},
-            {"initial", std::string(game->shortName().substr(0, 1))},
-            {"short_name", std::string(game->shortName())},
-        });
+        field_ids.push_back(*field_id);
     }
-    inja::json template_data = {
-        {"create_card_url", urlFor("card-new")},
-        {"games", std::move(template_games)},
-        {"series_url", urlFor("series-index")},
-        {"title", "Games · Card Collection"},
-    };
-    addNavigationData(template_data, identity->session.user);
-    respondTemplate(
-        templates_, "game_admin.html", template_data, response);
+    if(!short_name || !revision)
+    {
+        if(!short_name)
+        {
+            respondNotFound(response);
+        }
+        else
+        {
+            respondOperationError(
+                response, revision.error(), "Invalid field order");
+        }
+        return;
+    }
+    auto reordered = game_service_->reorderFields(
+        identity->session.user.id, *short_name, *revision, field_ids);
+    if(!reordered)
+    {
+        respondMutationError(
+            response,
+            reordered.error(),
+            "Failed reorder",
+            urlFor("game-edit", {*short_name}),
+            identity->session.user);
+        return;
+    }
+    response.status = 303;
+    response.set_header("Location", urlFor("game-edit", {*short_name}));
 }
 
 void App::handleAdminPromote(const Request& request, Response& response)
@@ -1272,25 +2754,18 @@ void App::handleCardNew(
         return;
     }
     inja::json games = inja::json::array();
-    for(const GameDefinition* game : games_->games())
+    auto definitions = loadGameDefinitions(*data_source_);
+    if(!definitions)
     {
-        inja::json fields = inja::json::array();
-        for(const GameFormField& field : game->formFields())
-        {
-            fields.push_back({
-                {"input_type", field.input_type},
-                {"label", field.label},
-                {"minimum", field.minimum},
-                {"name", field.name},
-                {"required", field.required},
-                {"value", ""},
-            });
-        }
-        games.push_back({
-            {"display_name", std::string(game->displayName())},
-            {"fields", std::move(fields)},
-            {"short_name", std::string(game->shortName())},
-        });
+        spdlog::error(
+            "Failed to load games for card form: {}",
+            definitions.error().msg());
+        respondInternalError(response);
+        return;
+    }
+    for(const GameDefinitionSnapshot& definition : *definitions)
+    {
+        games.push_back(gameFormJson(definition, {}));
     }
     inja::json series = inja::json::array();
     auto series_result = data_source_->getSeries();
@@ -1322,7 +2797,12 @@ void App::handleCardNew(
         {"foil_action", "keep"},
         {"foil_url", ""},
         {"front_url", urlFor("static", {"card_placeholder.svg"})},
+        {"error_control_id", ""},
+        {"error_message", ""},
+        {"files_need_reselection", false},
+        {"game_revision", 0},
         {"games", std::move(games)},
+        {"has_errors", false},
         {"has_foil", false},
         {"heading", "Create card"},
         {"long_description", ""},
@@ -1431,18 +2911,11 @@ void App::handleCardEdit(
             {{"v", std::to_string(card.revision)}});
     }
 
-    FormFields current_game_values;
-    const GameDefinition* selected_game = nullptr;
+    std::vector<GameFieldValue> current_game_values;
+    std::optional<GameDefinitionSnapshot> selected_game;
     if(card.identity.game_short_name)
     {
-        selected_game = games_->find(*card.identity.game_short_name);
-        if(selected_game == nullptr)
-        {
-            respondInternalError(response);
-            return;
-        }
-        auto values = data_source_->getGameFormValues(
-            *selected_game, card.id);
+        auto values = data_source_->getCardFieldValues(card.id);
         if(!values)
         {
             spdlog::error(
@@ -1452,32 +2925,31 @@ void App::handleCardEdit(
             respondInternalError(response);
             return;
         }
-        current_game_values = std::move(*values);
+        if(!*values)
+        {
+            respondInternalError(response);
+            return;
+        }
+        selected_game = std::move((**values).definition);
+        current_game_values = std::move((**values).values);
     }
 
     inja::json games = inja::json::array();
-    for(const GameDefinition* game : games_->games())
+    auto definitions = loadGameDefinitions(*data_source_);
+    if(!definitions)
     {
-        inja::json fields = inja::json::array();
-        for(const GameFormField& field : game->formFields())
-        {
-            const auto value = current_game_values.find(field.name);
-            fields.push_back({
-                {"input_type", field.input_type},
-                {"label", field.label},
-                {"minimum", field.minimum},
-                {"name", field.name},
-                {"required", field.required},
-                {"value", value == current_game_values.end()
-                    ? std::string()
-                    : value->second},
-            });
-        }
-        games.push_back({
-            {"display_name", std::string(game->displayName())},
-            {"fields", std::move(fields)},
-            {"short_name", std::string(game->shortName())},
-        });
+        respondInternalError(response);
+        return;
+    }
+    for(const GameDefinitionSnapshot& definition : *definitions)
+    {
+        const bool selected = card.identity.game_short_name &&
+            definition.game.short_name == *card.identity.game_short_name;
+        const GameDefinitionSnapshot& displayed_definition =
+            selected && selected_game ? *selected_game : definition;
+        games.push_back(gameFormJson(
+            displayed_definition,
+            selected ? current_game_values : std::vector<GameFieldValue>{}));
     }
 
     auto all_series = data_source_->getSeries();
@@ -1508,7 +2980,11 @@ void App::handleCardEdit(
         {"foil_action", "keep"},
         {"foil_url", foil_url},
         {"front_url", front_url},
+        {"error_control_id", ""},
+        {"error_message", ""},
+        {"files_need_reselection", false},
         {"games", std::move(games)},
+        {"has_errors", false},
         {"has_foil", card.foil_extension.has_value()},
         {"heading", "Edit card"},
         {"long_description", card.long_description.value_or("")},
@@ -1521,6 +2997,9 @@ void App::handleCardEdit(
         {"show_rarity",
          actor->session.user.role == UserRole::ADMINISTRATOR},
         {"revision", card.revision},
+        {"game_revision", selected_game
+             ? selected_game->game.revision
+             : 0},
         {"selected_game", card.identity.game_short_name.value_or("")},
         {"series", std::move(series)},
         {"shader_fragment_url",
@@ -1579,8 +3058,13 @@ void App::handleCardCreate(
     auto upload = multipart_reader.read(content_reader);
     if(!upload)
     {
-        respondOperationError(
-            response, upload.error(), "Failed to receive a card upload");
+        respondMutationError(
+            response,
+            upload.error(),
+            "Failed to receive a card upload",
+            urlFor("card-new"),
+            actor->session.user,
+            true);
         return;
     }
     const auto csrf = upload->fields.find("csrf_token");
@@ -1651,10 +3135,19 @@ void App::handleCardCreate(
         mw::runtimeError("Card creation was not dispatched"));
     if(game_short_name.empty())
     {
-        if(!upload->series_ids.empty())
+        if(upload->fields.contains("game_revision") ||
+           !gameFields(upload->fields).empty() ||
+           !upload->series_ids.empty())
         {
-            respondBadRequest(
-                response, "Loose cards cannot belong to a series");
+            respondMutationError(
+                response,
+                mw::httpError(
+                    422,
+                    "Loose cards cannot have game fields or series"),
+                "Failed to validate loose card fields",
+                urlFor("card-new"),
+                actor->session.user,
+                true);
             return;
         }
         created = card_service_->createLooseCard(
@@ -1662,43 +3155,55 @@ void App::handleCardCreate(
     }
     else
     {
-        const GameDefinition* definition = games_->find(game_short_name);
-        if(definition == nullptr)
-        {
-            respondBadRequest(response, "Unknown compiled game");
-            return;
-        }
-        auto metadata = definition->validateMetadata(
-            gameFields(upload->fields));
-        if(!metadata)
+        auto game_revision = parseGameRevision(upload->fields);
+        if(!game_revision)
         {
-            respondOperationError(
+            respondMutationError(
                 response,
-                metadata.error(),
-                "Failed to validate game metadata");
+                game_revision.error(),
+                "Failed to parse game revision",
+                urlFor("card-new"),
+                actor->session.user,
+                true);
             return;
         }
-        auto memberships = seriesMemberships(
-            upload->series_ids, game_short_name, *data_source_);
+        auto memberships = seriesMemberships(upload->series_ids);
         if(!memberships)
         {
-            respondOperationError(
+            respondMutationError(
                 response,
                 memberships.error(),
-                "Failed to validate series memberships");
+                "Failed to validate series memberships",
+                urlFor("card-new"),
+                actor->session.user,
+                true);
             return;
         }
         created = card_service_->createGameCard(
             actor->session.user.id,
             std::move(input),
-            *definition,
-            **metadata,
+            game_short_name,
+            *game_revision,
+            gameFields(upload->fields),
             *memberships);
     }
     if(!created)
     {
-        respondOperationError(
-            response, created.error(), "Failed to create a card");
+        const auto* field_error =
+            created.error().as<GameFieldValidationError>();
+        if(field_error != nullptr)
+        {
+            respondCardFieldError(
+                response, *field_error, *upload, *actor);
+            return;
+        }
+        respondMutationError(
+            response,
+            created.error(),
+            "Failed to create a card",
+            urlFor("card-new"),
+            actor->session.user,
+            true);
         return;
     }
 
@@ -1749,6 +3254,7 @@ void App::handleCardUpdate(
         respondNotFound(response);
         return;
     }
+    const Card form_card = **card_result;
     if(!request.is_multipart_form_data())
     {
         respondBadRequest(response, "Expected a multipart form upload");
@@ -1759,8 +3265,13 @@ void App::handleCardUpdate(
     auto upload = multipart_reader.read(content_reader);
     if(!upload)
     {
-        respondOperationError(
-            response, upload.error(), "Failed to receive a card edit");
+        respondMutationError(
+            response,
+            upload.error(),
+            "Failed to receive a card edit",
+            urlFor("card-edit", {id_parameter->second}),
+            actor->session.user,
+            true);
         return;
     }
     const auto csrf = upload->fields.find("csrf_token");
@@ -1881,10 +3392,19 @@ void App::handleCardUpdate(
         mw::runtimeError("Card update was not dispatched"));
     if(!input.current_card.identity.game_short_name)
     {
-        if(!upload->series_ids.empty())
+        if(upload->fields.contains("game_revision") ||
+           !gameFields(upload->fields).empty() ||
+           !upload->series_ids.empty())
         {
-            respondBadRequest(
-                response, "Loose cards cannot belong to a series");
+            respondMutationError(
+                response,
+                mw::httpError(
+                    422,
+                    "Loose cards cannot have game fields or series"),
+                "Failed to validate loose card fields",
+                urlFor("card-edit", {id_parameter->second}),
+                actor->session.user,
+                true);
             return;
         }
         updated = card_service_->updateLooseCard(
@@ -1892,45 +3412,54 @@ void App::handleCardUpdate(
     }
     else
     {
-        const std::string& game_short_name =
-            *input.current_card.identity.game_short_name;
-        const GameDefinition* definition = games_->find(game_short_name);
-        if(definition == nullptr)
-        {
-            respondInternalError(response);
-            return;
-        }
-        auto metadata = definition->validateMetadata(
-            gameFields(upload->fields));
-        if(!metadata)
+        auto game_revision = parseGameRevision(upload->fields);
+        if(!game_revision)
         {
-            respondOperationError(
+            respondMutationError(
                 response,
-                metadata.error(),
-                "Failed to validate game metadata");
+                game_revision.error(),
+                "Failed to parse game revision",
+                urlFor("card-edit", {id_parameter->second}),
+                actor->session.user,
+                true);
             return;
         }
-        auto membership_ids = seriesMemberships(
-            upload->series_ids, game_short_name, *data_source_);
+        auto membership_ids = seriesMemberships(upload->series_ids);
         if(!membership_ids)
         {
-            respondOperationError(
+            respondMutationError(
                 response,
                 membership_ids.error(),
-                "Failed to validate series memberships");
+                "Failed to validate series memberships",
+                urlFor("card-edit", {id_parameter->second}),
+                actor->session.user,
+                true);
             return;
         }
         updated = card_service_->updateGameCard(
             actor->session.user.id,
             std::move(input),
-            *definition,
-            **metadata,
+            *game_revision,
+            gameFields(upload->fields),
             *membership_ids);
     }
     if(!updated)
     {
-        respondOperationError(
-            response, updated.error(), "Failed to update a card");
+        const auto* field_error =
+            updated.error().as<GameFieldValidationError>();
+        if(field_error != nullptr)
+        {
+            respondCardFieldError(
+                response, *field_error, *upload, *actor, &form_card);
+            return;
+        }
+        respondMutationError(
+            response,
+            updated.error(),
+            "Failed to update a card",
+            urlFor("card-edit", {id_parameter->second}),
+            actor->session.user,
+            true);
         return;
     }
 
@@ -2051,32 +3580,37 @@ void App::handleCardView(
     std::string game_name = "Loose card";
     if(card.identity.game_short_name)
     {
-        const GameDefinition* game = games_->find(
-            *card.identity.game_short_name);
-        if(game == nullptr)
+        auto fields = data_source_->getCardFieldValues(card.id);
+        if(!fields)
         {
             spdlog::error(
-                "Card {} uses an unregistered compiled game",
-                card.id);
+                "Failed to load game fields for card {}: {}",
+                card.id, fields.error().msg());
             respondInternalError(response);
             return;
         }
-        game_name = std::string(game->displayName());
-        auto fields = data_source_->getGameDisplayFields(*game, card.id);
-        if(!fields)
+        if(!*fields)
         {
-            spdlog::error(
-                "Failed to load game fields for card {}: {}",
-                card.id,
-                fields.error().msg());
             respondInternalError(response);
             return;
         }
-        for(const DisplayField& field : *fields)
+        game_name = (**fields).definition.game.display_name;
+        for(const GameFieldValue& value : (**fields).values)
         {
+            const auto definition = std::ranges::find_if(
+                (**fields).definition.fields,
+                [&value](const GameField& field)
+                {
+                    return field.id == value.field_id;
+                });
+            if(definition == (**fields).definition.fields.end())
+            {
+                respondInternalError(response);
+                return;
+            }
             game_fields.push_back({
-                {"label", field.label},
-                {"value", field.value},
+                {"label", definition->label},
+                {"value", fieldValue({value}, value.field_id)},
             });
         }
     }
@@ -2503,9 +4037,20 @@ void App::handleSeriesIndex(
     }
     inja::json series = inja::json::array();
     std::vector<HtmlSubstitution> html_substitutions;
+    auto games_result = data_source_->getGames();
+    if(!games_result)
+    {
+        respondInternalError(response);
+        return;
+    }
     for(const Series& item : *series_result)
     {
-        const GameDefinition* game = games_->find(item.game_short_name);
+        const auto game = std::ranges::find_if(
+            *games_result,
+            [&item](const Game& candidate)
+            {
+                return candidate.short_name == item.game_short_name;
+            });
         auto rendered = MarkdownRenderer().render(item.description);
         if(!rendered)
         {
@@ -2526,14 +4071,14 @@ void App::handleSeriesIndex(
             {"has_description", !item.description.empty()},
             {"edit_url",
              urlFor("series-edit", {std::to_string(item.id)})},
-            {"game", game == nullptr
+            {"game", game == games_result->end()
                  ? uppercaseAscii(item.game_short_name)
-                 : std::string(game->displayName())},
+                 : game->display_name},
             {"name", item.name},
         });
     }
     inja::json template_data = {
-        {"can_create", !games_->games().empty()},
+        {"can_create", !games_result->empty()},
         {"create_url", urlFor("series-new")},
         {"series", std::move(series)},
         {"title", "Series · Card Collection"},
@@ -2576,11 +4121,17 @@ void App::handleSeriesNew(
         return;
     }
     inja::json games = inja::json::array();
-    for(const GameDefinition* game : games_->games())
+    auto games_result = data_source_->getGames();
+    if(!games_result)
+    {
+        respondInternalError(response);
+        return;
+    }
+    for(const Game& game : *games_result)
     {
         games.push_back({
-            {"display_name", std::string(game->displayName())},
-            {"short_name", std::string(game->shortName())},
+            {"display_name", game.display_name},
+            {"short_name", game.short_name},
         });
     }
     inja::json template_data = {
@@ -2682,15 +4233,18 @@ void App::handleSeriesEdit(
         return;
     }
     const Series& series = **series_result;
-    const GameDefinition* game = games_->find(series.game_short_name);
+    auto game = data_source_->getGameDefinition(series.game_short_name);
+    if(!game || !*game)
+    {
+        respondInternalError(response);
+        return;
+    }
     inja::json template_data = {
         {"action_url", urlFor("series-item", {std::to_string(*id)})},
         {"back_url", urlFor("series-index")},
         {"csrf_token", actor->session.csrf_token},
         {"description", series.description},
-        {"game", game == nullptr
-             ? uppercaseAscii(series.game_short_name)
-             : std::string(game->displayName())},
+        {"game", (**game).game.display_name},
         {"games", inja::json::array()},
         {"heading", "Edit series"},
         {"mode", "edit"},
@@ -2923,6 +4477,45 @@ void App::setup()
     server.Get(
         getPath("admin-games"),
         std::bind_front(&App::handleAdminGames, this));
+    server.Get(
+        getPath("game-new"),
+        std::bind_front(&App::handleGameNew, this));
+    server.Post(
+        getPath("games"),
+        std::bind_front(&App::handleGameCreate, this));
+    server.Get(
+        getPath("game-edit", {"short"}),
+        std::bind_front(&App::handleGameEdit, this));
+    server.Post(
+        getPath("game-update", {"short"}),
+        std::bind_front(&App::handleGameUpdate, this));
+    server.Get(
+        getPath("game-delete", {"short"}),
+        std::bind_front(&App::handleGameDeleteConfirm, this));
+    server.Post(
+        getPath("game-delete", {"short"}),
+        std::bind_front(&App::handleGameDelete, this));
+    server.Get(
+        getPath("game-field-new", {"short"}),
+        std::bind_front(&App::handleGameFieldNew, this));
+    server.Post(
+        getPath("game-fields", {"short"}),
+        std::bind_front(&App::handleGameFieldCreate, this));
+    server.Get(
+        getPath("game-field-edit", {"short", "id"}),
+        std::bind_front(&App::handleGameFieldEdit, this));
+    server.Post(
+        getPath("game-field-update", {"short", "id"}),
+        std::bind_front(&App::handleGameFieldUpdate, this));
+    server.Get(
+        getPath("game-field-delete", {"short", "id"}),
+        std::bind_front(&App::handleGameFieldDeleteConfirm, this));
+    server.Post(
+        getPath("game-field-delete", {"short", "id"}),
+        std::bind_front(&App::handleGameFieldDelete, this));
+    server.Post(
+        getPath("game-field-order", {"short"}),
+        std::bind_front(&App::handleGameFieldOrder, this));
     server.Post(
         getPath("admin-promote", {"id"}),
         std::bind_front(&App::handleAdminPromote, this));
diff --git a/src/app.h b/src/app.h
index 4d5eacb..3cfe9a3 100644
--- a/src/app.h
+++ b/src/app.h
@@ -16,12 +16,15 @@
 #include "config.h"
 #include "data.h"
 #include "email_sender.h"
-#include "game_registry.h"
+#include "game_service.h"
 #include "non_secret_random.h"
 #include "series_service.h"
 #include "url_builder.h"
 #include "user_service.h"
 
+struct CardUpload;
+struct GameFieldValidationError;
+
 /// Card Collection HTTP application and named-route owner.
 class App : public mw::HTTPServer
 {
@@ -42,7 +45,6 @@ public:
     App(
         const Config& config,
         std::unique_ptr<DataSourceInterface> data_source,
-        std::unique_ptr<GameRegistry> games,
         std::unique_ptr<NonSecretRandom> random);
 
     /// Return the absolute URL for a named application route.
@@ -107,6 +109,46 @@ public:
     /// Render the administrator's installed-game inventory.
     void handleAdminGames(const Request& request, Response& response);
 
+    /// Render the create-game form.
+    void handleGameNew(const Request& request, Response& response);
+
+    /// Create a database-defined game.
+    void handleGameCreate(const Request& request, Response& response);
+
+    /// Render one game and its field-definition editor.
+    void handleGameEdit(const Request& request, Response& response);
+
+    /// Update one game's mutable metadata.
+    void handleGameUpdate(const Request& request, Response& response);
+
+    /// Render a game-delete confirmation.
+    void handleGameDeleteConfirm(const Request& request, Response& response);
+
+    /// Delete an unused game.
+    void handleGameDelete(const Request& request, Response& response);
+
+    /// Render the add-field form for one game.
+    void handleGameFieldNew(const Request& request, Response& response);
+
+    /// Add one custom field to a game.
+    void handleGameFieldCreate(const Request& request, Response& response);
+
+    /// Render one custom-field editor.
+    void handleGameFieldEdit(const Request& request, Response& response);
+
+    /// Update one custom field and its choices.
+    void handleGameFieldUpdate(const Request& request, Response& response);
+
+    /// Render a custom-field delete confirmation.
+    void handleGameFieldDeleteConfirm(
+        const Request& request, Response& response);
+
+    /// Delete one unused custom field.
+    void handleGameFieldDelete(const Request& request, Response& response);
+
+    /// Save the complete custom-field ordering for one game.
+    void handleGameFieldOrder(const Request& request, Response& response);
+
     /// Permanently promote one player to creator.
     void handleAdminPromote(const Request& request, Response& response);
 
@@ -196,6 +238,45 @@ private:
     /// Add role-aware authenticated navigation links to template data.
     void addNavigationData(inja::json& data, const User& user) const;
 
+    /// Render an expected mutation failure with a safe retry destination.
+    void respondMutationError(
+        Response& response,
+        const mw::Error& error,
+        std::string_view operation,
+        const std::string& reload_url,
+        const User& user,
+        bool files_need_reselection = false);
+
+    /// Re-render a card form after custom-field validation fails.
+    void respondCardFieldError(
+        Response& response,
+        const GameFieldValidationError& error,
+        const CardUpload& upload,
+        const RequestIdentity& identity,
+        const Card* card = nullptr);
+
+    /// Re-render a game metadata form after definition validation fails.
+    void respondGameDefinitionError(
+        Response& response,
+        const GameDefinitionValidationError& error,
+        const RequestIdentity& identity,
+        const std::string& short_name,
+        const std::string& display_name,
+        const std::string& description,
+        bool editing);
+
+    /// Re-render a field editor after definition validation fails.
+    void respondGameFieldDefinitionError(
+        Response& response,
+        const GameDefinitionValidationError& error,
+        const RequestIdentity& identity,
+        const std::string& short_name,
+        std::optional<std::int64_t> field_id,
+        const std::string& key,
+        const std::string& label,
+        const std::string& type,
+        const std::vector<std::string>& choices);
+
     /// Set or expire a host-only application cookie.
     void setCookie(
         Response& response,
@@ -206,7 +287,6 @@ private:
 
     Config config_;
     std::unique_ptr<DataSourceInterface> data_source_;
-    std::unique_ptr<GameRegistry> games_;
     std::unique_ptr<NonSecretRandom> random_;
     std::unique_ptr<ClockInterface> clock_;
     std::unique_ptr<mw::CryptoInterface> crypto_;
@@ -216,12 +296,14 @@ private:
     std::unique_ptr<CollectionService> collection_service_;
     std::unique_ptr<CardService> card_service_;
     std::unique_ptr<SeriesService> series_service_;
+    std::unique_ptr<GameService> game_service_;
     UrlBuilder url_builder_;
     inja::Environment templates_;
     inja::Template card_form_template_;
     inja::Template card_delete_template_;
     inja::Template card_index_template_;
     inja::Template card_view_template_;
+    inja::Template game_admin_template_;
     inja::Template series_delete_template_;
     inja::Template series_form_template_;
     inja::Template series_index_template_;
diff --git a/src/card.h b/src/card.h
index 5b81b44..bb01659 100644
--- a/src/card.h
+++ b/src/card.h
@@ -7,7 +7,7 @@
 /// Persisted components from which a public card ID is derived.
 struct CardIdentity
 {
-    /// Lowercase compiled game name, or null for a loose card.
+    /// Lowercase database game short name, or null for a loose card.
     std::optional<std::string> game_short_name;
 
     /// Sequential game number or random loose-card number.
diff --git a/src/card_service.cpp b/src/card_service.cpp
index 7edfe13..89ac64c 100644
--- a/src/card_service.cpp
+++ b/src/card_service.cpp
@@ -8,7 +8,7 @@
 #include <utility>
 
 #include "public_id.h"
-#include "game_definition.h"
+#include "game_field.h"
 
 namespace
 {
@@ -51,29 +51,32 @@ mw::E<std::string> CardService::createLooseCard(
     std::int64_t actor_user_id, CreateLooseCardInput input)
 {
     return createCard(
-        actor_user_id, std::move(input), nullptr, nullptr, {});
+        actor_user_id, std::move(input), std::nullopt, std::nullopt, {}, {});
 }
 
 mw::E<std::string> CardService::createGameCard(
     std::int64_t actor_user_id,
     CreateCardInput input,
-    const GameDefinition& game,
-    const GameCardMetadata& metadata,
+    std::string game_short_name,
+    std::int64_t expected_game_revision,
+    SubmittedGameFields submitted_fields,
     const std::vector<std::int64_t>& series_ids)
 {
     return createCard(
         actor_user_id,
         std::move(input),
-        &game,
-        &metadata,
+        std::move(game_short_name),
+        expected_game_revision,
+        std::move(submitted_fields),
         series_ids);
 }
 
 mw::E<std::string> CardService::createCard(
     std::int64_t actor_user_id,
     CreateCardInput input,
-    const GameDefinition* game,
-    const GameCardMetadata* metadata,
+    std::optional<std::string> game_short_name,
+    std::optional<std::int64_t> expected_game_revision,
+    SubmittedGameFields submitted_fields,
     const std::vector<std::int64_t>& series_ids)
 {
     if(input.short_description)
@@ -171,11 +174,59 @@ mw::E<std::string> CardService::createCard(
             400, "Creators cannot submit card rarity"));
     }
 
+    std::vector<GameFieldValue> field_values;
+    if(game_short_name)
+    {
+        if(!expected_game_revision)
+        {
+            return std::unexpected(mw::httpError(
+                400, "Game revision is required"));
+        }
+        auto definition = (*transaction)->getGameDefinitionForUpdate(
+            *game_short_name);
+        if(!definition)
+        {
+            return std::unexpected(std::move(definition.error()));
+        }
+        if(!*definition)
+        {
+            return std::unexpected(mw::httpError(422, "Unknown game"));
+        }
+        if((**definition).game.revision != *expected_game_revision)
+        {
+            return std::unexpected(mw::httpError(
+                409, "The game definition changed; reload and try again"));
+        }
+        auto decoded = decodeGameFields(**definition, submitted_fields);
+        if(!decoded)
+        {
+            return std::unexpected(std::move(decoded.error()));
+        }
+        field_values = std::move(*decoded);
+        auto memberships = (*transaction)->seriesBelongToGame(
+            *game_short_name, series_ids);
+        if(!memberships)
+        {
+            return std::unexpected(std::move(memberships.error()));
+        }
+        if(!*memberships)
+        {
+            return std::unexpected(mw::httpError(
+                422, "A selected series belongs to another game"));
+        }
+    }
+    else if(expected_game_revision || !submitted_fields.empty() ||
+            !series_ids.empty())
+    {
+        return std::unexpected(mw::httpError(
+            422, "Loose cards cannot have game fields or series"));
+    }
+
     std::uint64_t number = 0;
-    if(game != nullptr)
+    if(game_short_name)
     {
         auto allocated = (*transaction)->allocateGameNumber(
-            std::string(game->shortName()));
+            *game_short_name);
         if(!allocated)
         {
             return std::unexpected(std::move(allocated.error()));
@@ -209,10 +260,7 @@ mw::E<std::string> CardService::createCard(
 
     Card card = {
         0,
-        {game == nullptr
-             ? std::nullopt
-             : std::optional<std::string>(game->shortName()),
-         number},
+        {game_short_name, number},
         std::move(input.name),
         std::move(input.short_description),
         std::move(input.long_description),
@@ -224,7 +272,7 @@ mw::E<std::string> CardService::createCard(
         actor_user_id,
     };
     auto inserted = (*transaction)->insertCard(
-        card, game, metadata, series_ids);
+        card, field_values, series_ids);
     if(!inserted)
     {
         return std::unexpected(std::move(inserted.error()));
@@ -255,29 +303,29 @@ mw::E<std::string> CardService::updateLooseCard(
     std::int64_t actor_user_id, UpdateLooseCardInput input)
 {
     return updateCard(
-        actor_user_id, std::move(input), nullptr, nullptr, {});
+        actor_user_id, std::move(input), std::nullopt, {}, {});
 }
 
 mw::E<std::string> CardService::updateGameCard(
     std::int64_t actor_user_id,
     UpdateLooseCardInput input,
-    const GameDefinition& game,
-    const GameCardMetadata& metadata,
+    std::int64_t expected_game_revision,
+    SubmittedGameFields submitted_fields,
     const std::vector<std::int64_t>& series_ids)
 {
     return updateCard(
         actor_user_id,
         std::move(input),
-        &game,
-        &metadata,
+        expected_game_revision,
+        std::move(submitted_fields),
         series_ids);
 }
 
 mw::E<std::string> CardService::updateCard(
     std::int64_t actor_user_id,
     UpdateLooseCardInput input,
-    const GameDefinition* game,
-    const GameCardMetadata* metadata,
+    std::optional<std::int64_t> expected_game_revision,
+    SubmittedGameFields submitted_fields,
     const std::vector<std::int64_t>& series_ids)
 {
     auto stored = data_source_.getCard(input.current_card.identity);
@@ -307,13 +355,10 @@ mw::E<std::string> CardService::updateCard(
         }
     }
     const bool is_game_card = card.identity.game_short_name.has_value();
-    if(card.id <= 0 || is_game_card != (game != nullptr) ||
-       (game == nullptr) != (metadata == nullptr) ||
-       (game != nullptr &&
-        game->shortName() != *card.identity.game_short_name))
+    if(card.id <= 0 || is_game_card != expected_game_revision.has_value())
     {
         return std::unexpected(mw::httpError(
-            400, "Card identity does not match its compiled game"));
+            400, "Card identity does not match its game definition"));
     }
     if(input.expected_revision != card.revision)
     {
@@ -503,8 +548,51 @@ mw::E<std::string> CardService::updateCard(
         ? (**current).rarity
         : input.rarity;
 
+    std::vector<GameFieldValue> field_values;
+    if(card.identity.game_short_name)
+    {
+        auto definition = (*transaction)->getGameDefinitionForUpdate(
+            *card.identity.game_short_name);
+        if(!definition)
+        {
+            return std::unexpected(std::move(definition.error()));
+        }
+        if(!*definition)
+        {
+            return std::unexpected(mw::runtimeError(
+                "Card references a missing game"));
+        }
+        if((**definition).game.revision != *expected_game_revision)
+        {
+            return std::unexpected(mw::httpError(
+                409, "The game definition changed; reload and try again"));
+        }
+        auto decoded = decodeGameFields(**definition, submitted_fields);
+        if(!decoded)
+        {
+            return std::unexpected(std::move(decoded.error()));
+        }
+        field_values = std::move(*decoded);
+        auto memberships = (*transaction)->seriesBelongToGame(
+            *card.identity.game_short_name, series_ids);
+        if(!memberships)
+        {
+            return std::unexpected(std::move(memberships.error()));
+        }
+        if(!*memberships)
+        {
+            return std::unexpected(mw::httpError(
+                422, "A selected series belongs to another game"));
+        }
+    }
+    else if(!submitted_fields.empty() || !series_ids.empty())
+    {
+        return std::unexpected(mw::httpError(
+            422, "Loose cards cannot have game fields or series"));
+    }
+
     auto updated = (*transaction)->updateCard(
-        card, game, metadata, series_ids);
+        card, field_values, series_ids);
     if(!updated)
     {
         return std::unexpected(std::move(updated.error()));
diff --git a/src/card_service.h b/src/card_service.h
index b7ac375..9df173f 100644
--- a/src/card_service.h
+++ b/src/card_service.h
@@ -126,8 +126,9 @@ public:
     mw::E<std::string> createGameCard(
         std::int64_t actor_user_id,
         CreateCardInput input,
-        const GameDefinition& game,
-        const GameCardMetadata& metadata,
+        std::string game_short_name,
+        std::int64_t expected_game_revision,
+        SubmittedGameFields submitted_fields,
         const std::vector<std::int64_t>& series_ids);
 
     /// Update a loose card after re-reading role and authorship.
@@ -138,8 +139,8 @@ public:
     mw::E<std::string> updateGameCard(
         std::int64_t actor_user_id,
         UpdateLooseCardInput input,
-        const GameDefinition& game,
-        const GameCardMetadata& metadata,
+        std::int64_t expected_game_revision,
+        SubmittedGameFields submitted_fields,
         const std::vector<std::int64_t>& series_ids);
 
     /// Delete a card only after re-reading an administrator actor.
@@ -147,20 +148,21 @@ public:
         std::int64_t actor_user_id, const Card& card);
 
 private:
-    /// Create a loose or compiled-game card through the common pipeline.
+    /// Create a loose or database-defined game card through one pipeline.
     mw::E<std::string> createCard(
         std::int64_t actor_user_id,
         CreateCardInput input,
-        const GameDefinition* game,
-        const GameCardMetadata* metadata,
+        std::optional<std::string> game_short_name,
+        std::optional<std::int64_t> expected_game_revision,
+        SubmittedGameFields submitted_fields,
         const std::vector<std::int64_t>& series_ids);
 
-    /// Update a loose or compiled-game card through the common pipeline.
+    /// Update a loose or database-defined game card through one pipeline.
     mw::E<std::string> updateCard(
         std::int64_t actor_user_id,
         UpdateLooseCardInput input,
-        const GameDefinition* game,
-        const GameCardMetadata* metadata,
+        std::optional<std::int64_t> expected_game_revision,
+        SubmittedGameFields submitted_fields,
         const std::vector<std::int64_t>& series_ids);
 
     DataSourceInterface& data_source_;
diff --git a/src/data.cpp b/src/data.cpp
index b1c97f5..1c61c05 100644
--- a/src/data.cpp
+++ b/src/data.cpp
@@ -26,6 +26,126 @@ mw::Error migrationVersionError(
 
 } // namespace
 
+mw::E<std::int64_t> DataSourceTransactionInterface::insertCard(
+    [[maybe_unused]] const Card& card,
+    [[maybe_unused]] const std::vector<GameFieldValue>& field_values,
+    [[maybe_unused]] const std::vector<std::int64_t>& series_ids)
+{
+    return std::unexpected(mw::runtimeError(
+        "Dynamic game card persistence is unavailable"));
+}
+
+mw::E<void> DataSourceTransactionInterface::updateCard(
+    [[maybe_unused]] const Card& card,
+    [[maybe_unused]] const std::vector<GameFieldValue>& field_values,
+    [[maybe_unused]] const std::vector<std::int64_t>& series_ids)
+{
+    return std::unexpected(mw::runtimeError(
+        "Dynamic game card persistence is unavailable"));
+}
+
+mw::E<std::optional<GameDefinitionSnapshot>>
+DataSourceTransactionInterface::getGameDefinitionForUpdate(
+    [[maybe_unused]] const std::string& short_name)
+{
+    return std::unexpected(mw::runtimeError(
+        "Game definition transactions are unavailable"));
+}
+
+mw::E<GameUsage> DataSourceTransactionInterface::getGameUsage(
+    [[maybe_unused]] const std::string& short_name)
+{
+    return std::unexpected(mw::runtimeError(
+        "Game definition transactions are unavailable"));
+}
+
+mw::E<void> DataSourceTransactionInterface::insertGame(
+    [[maybe_unused]] const Game& game)
+{
+    return std::unexpected(mw::runtimeError(
+        "Game definition transactions are unavailable"));
+}
+
+mw::E<bool> DataSourceTransactionInterface::updateGame(
+    [[maybe_unused]] const std::string& short_name,
+    [[maybe_unused]] const std::string& display_name,
+    [[maybe_unused]] const std::string& description,
+    [[maybe_unused]] std::int64_t expected_revision)
+{
+    return std::unexpected(mw::runtimeError(
+        "Game definition transactions are unavailable"));
+}
+
+mw::E<void> DataSourceTransactionInterface::deleteGame(
+    [[maybe_unused]] const std::string& short_name)
+{
+    return std::unexpected(mw::runtimeError(
+        "Game definition transactions are unavailable"));
+}
+
+mw::E<std::int64_t> DataSourceTransactionInterface::insertGameField(
+    [[maybe_unused]] const GameField& field,
+    [[maybe_unused]] const std::vector<GameChoice>& choices)
+{
+    return std::unexpected(mw::runtimeError(
+        "Game definition transactions are unavailable"));
+}
+
+mw::E<void> DataSourceTransactionInterface::updateGameField(
+    [[maybe_unused]] std::int64_t field_id,
+    [[maybe_unused]] const std::string& label,
+    [[maybe_unused]] const std::vector<GameChoice>& choices)
+{
+    return std::unexpected(mw::runtimeError(
+        "Game definition transactions are unavailable"));
+}
+
+mw::E<void> DataSourceTransactionInterface::deleteGameField(
+    [[maybe_unused]] std::int64_t field_id)
+{
+    return std::unexpected(mw::runtimeError(
+        "Game definition transactions are unavailable"));
+}
+
+mw::E<std::int64_t> DataSourceTransactionInterface::countFieldUsage(
+    [[maybe_unused]] std::int64_t field_id)
+{
+    return std::unexpected(mw::runtimeError(
+        "Game definition transactions are unavailable"));
+}
+
+mw::E<std::int64_t> DataSourceTransactionInterface::countChoiceUsage(
+    [[maybe_unused]] std::int64_t field_id,
+    [[maybe_unused]] const std::string& value)
+{
+    return std::unexpected(mw::runtimeError(
+        "Game definition transactions are unavailable"));
+}
+
+mw::E<void> DataSourceTransactionInterface::updateGameFieldOrder(
+    [[maybe_unused]] const std::string& short_name,
+    [[maybe_unused]] const std::vector<std::int64_t>& field_ids)
+{
+    return std::unexpected(mw::runtimeError(
+        "Game definition transactions are unavailable"));
+}
+
+mw::E<bool> DataSourceTransactionInterface::incrementGameRevision(
+    [[maybe_unused]] const std::string& short_name,
+    [[maybe_unused]] std::int64_t expected_revision)
+{
+    return std::unexpected(mw::runtimeError(
+        "Game definition transactions are unavailable"));
+}
+
+mw::E<bool> DataSourceTransactionInterface::seriesBelongToGame(
+    [[maybe_unused]] const std::string& short_name,
+    [[maybe_unused]] const std::vector<std::int64_t>& series_ids)
+{
+    return std::unexpected(mw::runtimeError(
+        "Transactional series validation is unavailable"));
+}
+
 mw::E<std::optional<User>>
 DataSourceTransactionInterface::getUserForUpdate(
     [[maybe_unused]] std::int64_t user_id)
@@ -252,7 +372,26 @@ mw::E<void> DataSourceInterface::cleanupAuthentication(
         "Authentication data is unavailable"));
 }
 
-mw::E<void> DataSourceInterface::migrateToLatest(const GameRegistry& games)
+mw::E<std::vector<Game>> DataSourceInterface::getGames() const
+{
+    return std::unexpected(mw::runtimeError("Game data is unavailable"));
+}
+
+mw::E<std::optional<GameDefinitionSnapshot>>
+DataSourceInterface::getGameDefinition(
+    [[maybe_unused]] const std::string& short_name) const
+{
+    return std::unexpected(mw::runtimeError("Game data is unavailable"));
+}
+
+mw::E<std::optional<CardGameFields>>
+DataSourceInterface::getCardFieldValues(
+    [[maybe_unused]] std::int64_t card_id) const
+{
+    return std::unexpected(mw::runtimeError("Game data is unavailable"));
+}
+
+mw::E<void> DataSourceInterface::migrateToLatest()
 {
     auto version_result = getSchemaVersion();
     if(!version_result)
@@ -269,7 +408,7 @@ mw::E<void> DataSourceInterface::migrateToLatest(const GameRegistry& games)
     while(version < DB_SCHEMA_VERSION)
     {
         const std::int64_t expected_version = version + 1;
-        mw::E<void> migration_result = migrateSchema0To1(games);
+        mw::E<void> migration_result = migrateSchema0To1();
         if(!migration_result)
         {
             return std::unexpected(std::move(migration_result.error()));
diff --git a/src/data.h b/src/data.h
index 110c8f6..23c35ff 100644
--- a/src/data.h
+++ b/src/data.h
@@ -10,7 +10,6 @@
 
 #include "card.h"
 #include "game.h"
-#include "game_definition.h"
 #include "user.h"
 
 /// Raw SHA-256 credential digest stored as a 32-byte SQLite BLOB.
@@ -76,9 +75,6 @@ struct AuthenticationReservation
     std::int64_t retry_after;
 };
 
-/// Immutable collection of compiled game definitions.
-class GameRegistry;
-
 /// Latest schema version implemented by this binary.
 inline constexpr std::int64_t DB_SCHEMA_VERSION = 1;
 
@@ -93,10 +89,66 @@ public:
     virtual mw::E<std::uint64_t>
     allocateGameNumber(const std::string& game_short_name) = 0;
 
-    /// Ensure a registered game has a persistent sequence row.
+    /// Ensure a database-defined game has a persistent sequence row.
     virtual mw::E<void>
     ensureGameSequence(const std::string& game_short_name) = 0;
 
+    /// Read a complete game definition while holding the writer lock.
+    virtual mw::E<std::optional<GameDefinitionSnapshot>>
+    getGameDefinitionForUpdate(const std::string& short_name);
+
+    /// Return deletion-relevant usage for one game.
+    virtual mw::E<GameUsage> getGameUsage(const std::string& short_name);
+
+    /// Insert a game and its zero-valued number sequence.
+    virtual mw::E<void> insertGame(const Game& game);
+
+    /// Replace mutable game metadata and advance a matching revision.
+    virtual mw::E<bool> updateGame(
+        const std::string& short_name,
+        const std::string& display_name,
+        const std::string& description,
+        std::int64_t expected_revision);
+
+    /// Delete an unused game, its fields, choices, and sequence.
+    virtual mw::E<void> deleteGame(const std::string& short_name);
+
+    /// Insert a field and its initial ordered choices.
+    virtual mw::E<std::int64_t> insertGameField(
+        const GameField& field,
+        const std::vector<GameChoice>& choices);
+
+    /// Replace a field label and its complete ordered choice list.
+    virtual mw::E<void> updateGameField(
+        std::int64_t field_id,
+        const std::string& label,
+        const std::vector<GameChoice>& choices);
+
+    /// Delete a field that has no stored card values.
+    virtual mw::E<void> deleteGameField(std::int64_t field_id);
+
+    /// Return the number of cards with a value for a field.
+    virtual mw::E<std::int64_t> countFieldUsage(std::int64_t field_id);
+
+    /// Return the number of cards using one exact choice.
+    virtual mw::E<std::int64_t> countChoiceUsage(
+        std::int64_t field_id, const std::string& value);
+
+    /// Replace every field position for a game.
+    virtual mw::E<void> updateGameFieldOrder(
+        const std::string& short_name,
+        const std::vector<std::int64_t>& field_ids);
+
+    /// Return whether every requested series belongs to one game.
+    virtual mw::E<bool> seriesBelongToGame(
+        const std::string& short_name,
+        const std::vector<std::int64_t>& series_ids);
+
+    /// Advance an aggregate definition revision if it is still current.
+    virtual mw::E<bool> incrementGameRevision(
+        const std::string& short_name,
+        std::int64_t expected_revision);
+
     /// Return whether a loose-card number already exists.
     virtual mw::E<bool> looseNumberExists(std::uint32_t number) = 0;
 
@@ -187,19 +239,17 @@ public:
     virtual mw::E<std::int64_t> incrementHolding(
         std::int64_t user_id, std::int64_t card_id);
 
-    /// Insert common, game-specific, and series-membership card rows.
+    /// Insert a card with generic custom values and series memberships.
     virtual mw::E<std::int64_t> insertCard(
         const Card& card,
-        const GameDefinition* game,
-        const GameCardMetadata* metadata,
-        const std::vector<std::int64_t>& series_ids) = 0;
+        const std::vector<GameFieldValue>& field_values,
+        const std::vector<std::int64_t>& series_ids);
 
-    /// Replace a card's common, game-specific, and membership rows.
+    /// Replace a card and all of its generic custom values and memberships.
     virtual mw::E<void> updateCard(
         const Card& card,
-        const GameDefinition* game,
-        const GameCardMetadata* metadata,
-        const std::vector<std::int64_t>& series_ids) = 0;
+        const std::vector<GameFieldValue>& field_values,
+        const std::vector<std::int64_t>& series_ids);
 
     /// Delete a card and its dependent database rows.
     virtual mw::E<void> deleteCard(std::int64_t card_id) = 0;
@@ -227,12 +277,11 @@ public:
     /// Return the stored schema version.
     virtual mw::E<std::int64_t> getSchemaVersion() const = 0;
 
-    /// Apply every required migration in order through the current version.
-    mw::E<void> migrateToLatest(const GameRegistry& games);
+    /// Apply every required migration through the current schema version.
+    mw::E<void> migrateToLatest();
 
-    /// Create schema version 1 from an empty version-0 database.
-    virtual mw::E<void>
-    migrateSchema0To1(const GameRegistry& games) = 0;
+    /// Migrate an empty version-0 database to schema version 1.
+    virtual mw::E<void> migrateSchema0To1() = 0;
 
     /// Start an immediate transaction with exclusive mutation ownership.
     virtual mw::E<std::unique_ptr<DataSourceTransactionInterface>>
@@ -252,6 +301,17 @@ public:
     virtual mw::E<std::optional<Card>>
     getCard(const CardIdentity& identity) const = 0;
 
+    /// Return games ordered by display name and then short name.
+    virtual mw::E<std::vector<Game>> getGames() const;
+
+    /// Return a complete, consistently read game definition.
+    virtual mw::E<std::optional<GameDefinitionSnapshot>>
+    getGameDefinition(const std::string& short_name) const;
+
+    /// Return a card's definition and ordered custom values together.
+    virtual mw::E<std::optional<CardGameFields>>
+    getCardFieldValues(std::int64_t card_id) const;
+
     /// Return a user by internal identity.
     virtual mw::E<std::optional<User>> getUser(
         std::int64_t user_id) const;
@@ -290,16 +350,6 @@ public:
     /// Best-effort removal of obsolete authentication persistence rows.
     virtual mw::E<void> cleanupAuthentication(std::int64_t now);
 
-    /// Return a card's game-owned display fields.
-    virtual mw::E<std::vector<DisplayField>> getGameDisplayFields(
-        const GameDefinition& game,
-        std::int64_t card_id) const = 0;
-
-    /// Return current game-owned values for the edit form.
-    virtual mw::E<FormFields> getGameFormValues(
-        const GameDefinition& game,
-        std::int64_t card_id) const = 0;
-
     /// Return all series, ordered by game and name.
     virtual mw::E<std::vector<Series>> getSeries() const = 0;
 
@@ -311,10 +361,6 @@ public:
     virtual mw::E<std::vector<std::int64_t>>
     getCardSeries(std::int64_t card_id) const = 0;
 
-    /// Return every persisted game name used for startup reconciliation.
-    virtual mw::E<std::vector<std::string>>
-    getPersistedGameNames() const = 0;
-
 protected:
     /// Set the schema version inside a concrete migration transaction.
     virtual mw::E<void> setSchemaVersion(std::int64_t version) = 0;
diff --git a/src/data_fake.cpp b/src/data_fake.cpp
index 674c68c..caa43ac 100644
--- a/src/data_fake.cpp
+++ b/src/data_fake.cpp
@@ -1,7 +1,6 @@
 #include "data_fake.h"
 
 #include <algorithm>
-#include <set>
 #include <string_view>
 #include <utility>
 
@@ -27,11 +26,13 @@ DataSourceFake::DataSourceFake(
     std::vector<Card> cards,
     std::vector<Series> series,
     std::unordered_map<std::int64_t, std::vector<std::int64_t>> card_series,
-    std::unordered_map<std::int64_t, std::vector<DisplayField>> display_fields)
+    std::vector<GameDefinitionSnapshot> game_definitions,
+    std::unordered_map<std::int64_t, std::vector<GameFieldValue>> field_values)
         : cards_(std::move(cards)),
           series_(std::move(series)),
           card_series_(std::move(card_series)),
-          display_fields_(std::move(display_fields))
+          game_definitions_(std::move(game_definitions)),
+          field_values_(std::move(field_values))
 {}
 
 mw::E<std::int64_t> DataSourceFake::getSchemaVersion() const
@@ -39,8 +40,7 @@ mw::E<std::int64_t> DataSourceFake::getSchemaVersion() const
     return DB_SCHEMA_VERSION;
 }
 
-mw::E<void> DataSourceFake::migrateSchema0To1(
-    [[maybe_unused]] const GameRegistry& games)
+mw::E<void> DataSourceFake::migrateSchema0To1()
 {
     return std::unexpected(readOnlyError("migrate the schema"));
 }
@@ -72,23 +72,65 @@ mw::E<std::optional<Card>> DataSourceFake::getCard(
     return *card;
 }
 
-mw::E<std::vector<DisplayField>> DataSourceFake::getGameDisplayFields(
-    [[maybe_unused]] const GameDefinition& game,
-    std::int64_t card_id) const
+mw::E<std::vector<Game>> DataSourceFake::getGames() const
 {
-    const auto fields = display_fields_.find(card_id);
-    if(fields == display_fields_.end())
+    std::vector<Game> games;
+    games.reserve(game_definitions_.size());
+    for(const GameDefinitionSnapshot& definition : game_definitions_)
     {
-        return std::vector<DisplayField>{};
+        games.push_back(definition.game);
     }
-    return fields->second;
+    std::ranges::sort(
+        games,
+        [](const Game& left, const Game& right)
+        {
+            if(left.display_name != right.display_name)
+            {
+                return left.display_name < right.display_name;
+            }
+            return left.short_name < right.short_name;
+        });
+    return games;
 }
 
-mw::E<FormFields> DataSourceFake::getGameFormValues(
-    [[maybe_unused]] const GameDefinition& game,
-    [[maybe_unused]] std::int64_t card_id) const
+mw::E<std::optional<GameDefinitionSnapshot>>
+DataSourceFake::getGameDefinition(const std::string& short_name) const
 {
-    return FormFields{};
+    const auto definition = std::ranges::find_if(
+        game_definitions_,
+        [&short_name](const GameDefinitionSnapshot& candidate)
+        {
+            return candidate.game.short_name == short_name;
+        });
+    if(definition == game_definitions_.end())
+    {
+        return std::nullopt;
+    }
+    return *definition;
+}
+
+mw::E<std::optional<CardGameFields>> DataSourceFake::getCardFieldValues(
+    std::int64_t card_id) const
+{
+    const auto card = std::ranges::find_if(
+        cards_,
+        [card_id](const Card& candidate) { return candidate.id == card_id; });
+    if(card == cards_.end() || !card->identity.game_short_name)
+    {
+        return std::nullopt;
+    }
+    auto definition = getGameDefinition(*card->identity.game_short_name);
+    if(!definition || !*definition)
+    {
+        return std::unexpected(mw::runtimeError(
+            "Fake card references a missing game"));
+    }
+    const auto values = field_values_.find(card_id);
+    return CardGameFields{
+        std::move(**definition),
+        values == field_values_.end()
+            ? std::vector<GameFieldValue>{}
+            : values->second};
 }
 
 mw::E<std::vector<Series>> DataSourceFake::getSeries() const
@@ -123,24 +165,6 @@ mw::E<std::vector<std::int64_t>> DataSourceFake::getCardSeries(
     return memberships->second;
 }
 
-mw::E<std::vector<std::string>>
-DataSourceFake::getPersistedGameNames() const
-{
-    std::set<std::string> names;
-    for(const Card& card : cards_)
-    {
-        if(card.identity.game_short_name)
-        {
-            names.insert(*card.identity.game_short_name);
-        }
-    }
-    for(const Series& series : series_)
-    {
-        names.insert(series.game_short_name);
-    }
-    return std::vector<std::string>(names.begin(), names.end());
-}
-
 mw::E<void> DataSourceFake::setSchemaVersion(
     [[maybe_unused]] std::int64_t version)
 {
diff --git a/src/data_fake.h b/src/data_fake.h
index 13db8e0..70893e7 100644
--- a/src/data_fake.h
+++ b/src/data_fake.h
@@ -19,15 +19,15 @@ public:
         std::vector<Series> series = {},
         std::unordered_map<std::int64_t, std::vector<std::int64_t>>
             card_series = {},
-        std::unordered_map<std::int64_t, std::vector<DisplayField>>
-            display_fields = {});
+        std::vector<GameDefinitionSnapshot> game_definitions = {},
+        std::unordered_map<std::int64_t, std::vector<GameFieldValue>>
+            field_values = {});
 
     /// Return the latest schema version represented by the fake.
     mw::E<std::int64_t> getSchemaVersion() const override;
 
-    /// Reject migration because the fake always represents the latest schema.
-    mw::E<void>
-    migrateSchema0To1(const GameRegistry& games) override;
+    /// Reject migration because the fake already has the current schema.
+    mw::E<void> migrateSchema0To1() override;
 
     /// Reject mutation transactions because the fake is read-only.
     mw::E<std::unique_ptr<DataSourceTransactionInterface>>
@@ -40,14 +40,15 @@ public:
     mw::E<std::optional<Card>>
     getCard(const CardIdentity& identity) const override;
 
-    /// Return configured display fields for a card.
-    mw::E<std::vector<DisplayField>> getGameDisplayFields(
-        const GameDefinition& game,
-        std::int64_t card_id) const override;
+    /// Return configured games ordered by display name and short name.
+    mw::E<std::vector<Game>> getGames() const override;
+
+    /// Return one configured complete game definition.
+    mw::E<std::optional<GameDefinitionSnapshot>> getGameDefinition(
+        const std::string& short_name) const override;
 
-    /// Return no editable game values from the read-only fake.
-    mw::E<FormFields> getGameFormValues(
-        const GameDefinition& game,
+    /// Return a configured card's game definition and values.
+    mw::E<std::optional<CardGameFields>> getCardFieldValues(
         std::int64_t card_id) const override;
 
     /// Return all configured series in insertion order.
@@ -61,10 +62,6 @@ public:
     mw::E<std::vector<std::int64_t>>
     getCardSeries(std::int64_t card_id) const override;
 
-    /// Return the sorted unique compiled game names used by configured rows.
-    mw::E<std::vector<std::string>>
-    getPersistedGameNames() const override;
-
 protected:
     /// Reject schema mutation because the fake is read-only.
     mw::E<void> setSchemaVersion(std::int64_t version) override;
@@ -73,6 +70,7 @@ private:
     std::vector<Card> cards_;
     std::vector<Series> series_;
     std::unordered_map<std::int64_t, std::vector<std::int64_t>> card_series_;
-    std::unordered_map<std::int64_t, std::vector<DisplayField>>
-        display_fields_;
+    std::vector<GameDefinitionSnapshot> game_definitions_;
+    std::unordered_map<std::int64_t, std::vector<GameFieldValue>>
+        field_values_;
 };
diff --git a/src/data_sqlite.cpp b/src/data_sqlite.cpp
index 030165f..2989a5a 100644
--- a/src/data_sqlite.cpp
+++ b/src/data_sqlite.cpp
@@ -14,8 +14,7 @@
 
 #include <spdlog/spdlog.h>
 
-#include "game_definition.h"
-#include "game_registry.h"
+#include "game_field.h"
 
 namespace
 {
@@ -34,7 +33,7 @@ mw::E<void> rollbackWithError(
     return std::unexpected(std::move(error));
 }
 
-const std::array<std::string_view, 20> SCHEMA_VERSION_1_STATEMENTS = {
+const std::vector<std::string_view> SCHEMA_VERSION_1_STATEMENTS = {
     R"sql(
         CREATE TABLE application_metadata(
             key TEXT PRIMARY KEY,
@@ -111,12 +110,92 @@ const std::array<std::string_view, 20> SCHEMA_VERSION_1_STATEMENTS = {
                 CHECK(attempted_sends >= 0)
         ) STRICT;
     )sql",
+    R"sql(
+        CREATE TABLE games(
+            short_name TEXT PRIMARY KEY,
+            display_name TEXT NOT NULL,
+            description TEXT NOT NULL DEFAULT '',
+            revision INTEGER NOT NULL DEFAULT 1 CHECK(revision >= 1),
+            CHECK(length(short_name) > 0),
+            CHECK(short_name NOT GLOB '*[^a-z0-9]*')
+        ) STRICT;
+    )sql",
+    R"sql(
+        CREATE TABLE game_fields(
+            id INTEGER PRIMARY KEY AUTOINCREMENT,
+            game_short_name TEXT NOT NULL
+                REFERENCES games(short_name) ON DELETE RESTRICT,
+            key TEXT NOT NULL,
+            label TEXT NOT NULL,
+            type TEXT NOT NULL
+                CHECK(type IN ('INTEGER', 'STRING', 'CHOICE')),
+            position INTEGER NOT NULL CHECK(position >= 0),
+            UNIQUE(game_short_name, key),
+            UNIQUE(id, type),
+            CHECK(length(key) > 0)
+        ) STRICT;
+    )sql",
+    R"sql(
+        CREATE INDEX game_fields_by_game
+        ON game_fields(game_short_name, position, id);
+    )sql",
+    R"sql(
+        CREATE TABLE game_field_choices(
+            field_id INTEGER NOT NULL
+                REFERENCES game_fields(id) ON DELETE CASCADE,
+            value TEXT NOT NULL CHECK(length(value) > 0),
+            position INTEGER NOT NULL CHECK(position >= 0),
+            PRIMARY KEY(field_id, value)
+        ) WITHOUT ROWID, STRICT;
+    )sql",
+    R"sql(
+        CREATE TRIGGER game_choice_type_insert
+        BEFORE INSERT ON game_field_choices
+        WHEN NOT EXISTS(
+            SELECT 1 FROM game_fields
+            WHERE id = NEW.field_id AND type = 'CHOICE')
+        BEGIN
+            SELECT RAISE(ABORT, 'choices require a choice field');
+        END;
+    )sql",
+    R"sql(
+        CREATE TRIGGER game_choice_type_update
+        BEFORE UPDATE ON game_field_choices
+        WHEN NOT EXISTS(
+            SELECT 1 FROM game_fields
+            WHERE id = NEW.field_id AND type = 'CHOICE')
+        BEGIN
+            SELECT RAISE(ABORT, 'choices require a choice field');
+        END;
+    )sql",
+    R"sql(
+        CREATE TRIGGER games_identity_immutable
+        BEFORE UPDATE OF short_name ON games
+        BEGIN
+            SELECT RAISE(ABORT, 'game short name is immutable');
+        END;
+    )sql",
+    R"sql(
+        CREATE TRIGGER game_fields_identity_immutable
+        BEFORE UPDATE OF game_short_name, key, type ON game_fields
+        BEGIN
+            SELECT RAISE(ABORT, 'game field identity is immutable');
+        END;
+    )sql",
+    R"sql(
+        CREATE TRIGGER game_choices_identity_immutable
+        BEFORE UPDATE OF field_id, value ON game_field_choices
+        BEGIN
+            SELECT RAISE(ABORT, 'game choice identity is immutable');
+        END;
+    )sql",
     R"sql(
         CREATE TABLE cards (
             id INTEGER PRIMARY KEY,
             creator_user_id INTEGER NOT NULL
                 REFERENCES users(id) ON DELETE RESTRICT,
-            game_short_name TEXT,
+            game_short_name TEXT
+                REFERENCES games(short_name) ON DELETE RESTRICT,
             card_number INTEGER NOT NULL,
             name TEXT NOT NULL,
             short_description TEXT,
@@ -138,7 +217,7 @@ const std::array<std::string_view, 20> SCHEMA_VERSION_1_STATEMENTS = {
                 OR
                 (game_short_name IS NOT NULL AND card_number >= 1)
             )
-        );
+        ) STRICT;
     )sql",
     R"sql(
         CREATE UNIQUE INDEX cards_game_number_unique
@@ -158,18 +237,20 @@ const std::array<std::string_view, 20> SCHEMA_VERSION_1_STATEMENTS = {
     )sql",
     R"sql(
         CREATE TABLE game_sequences (
-            game_short_name TEXT PRIMARY KEY,
+            game_short_name TEXT PRIMARY KEY
+                REFERENCES games(short_name) ON DELETE RESTRICT,
             last_number INTEGER NOT NULL CHECK(last_number >= 0)
-        );
+        ) STRICT;
     )sql",
     R"sql(
         CREATE TABLE series (
             id INTEGER PRIMARY KEY,
-            game_short_name TEXT NOT NULL,
+            game_short_name TEXT NOT NULL
+                REFERENCES games(short_name) ON DELETE RESTRICT,
             name TEXT NOT NULL,
             description TEXT NOT NULL DEFAULT '',
             UNIQUE(game_short_name, name)
-        );
+        ) STRICT;
     )sql",
     R"sql(
         CREATE TABLE card_series (
@@ -178,7 +259,7 @@ const std::array<std::string_view, 20> SCHEMA_VERSION_1_STATEMENTS = {
             series_id INTEGER NOT NULL
                 REFERENCES series(id) ON DELETE CASCADE,
             PRIMARY KEY(card_id, series_id)
-        );
+        ) WITHOUT ROWID, STRICT;
     )sql",
     R"sql(
         CREATE TRIGGER card_series_same_game_insert
@@ -200,6 +281,82 @@ const std::array<std::string_view, 20> SCHEMA_VERSION_1_STATEMENTS = {
             END;
         END;
     )sql",
+    R"sql(
+        CREATE TRIGGER cards_identity_immutable
+        BEFORE UPDATE OF game_short_name, card_number ON cards
+        BEGIN
+            SELECT RAISE(ABORT, 'card identity is immutable');
+        END;
+    )sql",
+    R"sql(
+        CREATE TRIGGER series_game_immutable
+        BEFORE UPDATE OF game_short_name ON series
+        BEGIN
+            SELECT RAISE(ABORT, 'series game is immutable');
+        END;
+    )sql",
+    R"sql(
+        CREATE TABLE card_field_values(
+            card_id INTEGER NOT NULL
+                REFERENCES cards(id) ON DELETE CASCADE,
+            field_id INTEGER NOT NULL,
+            field_type TEXT NOT NULL,
+            integer_value INTEGER,
+            string_value TEXT,
+            choice_value TEXT,
+            PRIMARY KEY(card_id, field_id),
+            FOREIGN KEY(field_id, field_type)
+                REFERENCES game_fields(id, type) ON DELETE RESTRICT,
+            FOREIGN KEY(field_id, choice_value)
+                REFERENCES game_field_choices(field_id, value)
+                ON DELETE RESTRICT,
+            CHECK(
+                (field_type = 'INTEGER' AND integer_value IS NOT NULL
+                 AND string_value IS NULL AND choice_value IS NULL)
+                OR
+                (field_type = 'STRING' AND integer_value IS NULL
+                 AND string_value IS NOT NULL AND length(string_value) > 0
+                 AND choice_value IS NULL)
+                OR
+                (field_type = 'CHOICE' AND integer_value IS NULL
+                 AND string_value IS NULL AND choice_value IS NOT NULL)
+            )
+        ) WITHOUT ROWID, STRICT;
+    )sql",
+    R"sql(
+        CREATE INDEX card_field_values_by_field
+        ON card_field_values(field_id);
+    )sql",
+    R"sql(
+        CREATE INDEX card_field_values_by_choice
+        ON card_field_values(field_id, choice_value);
+    )sql",
+    R"sql(
+        CREATE TRIGGER card_field_value_game_insert
+        BEFORE INSERT ON card_field_values
+        WHEN NOT EXISTS(
+            SELECT 1
+            FROM cards JOIN game_fields
+              ON cards.game_short_name = game_fields.game_short_name
+            WHERE cards.id = NEW.card_id
+              AND game_fields.id = NEW.field_id)
+        BEGIN
+            SELECT RAISE(ABORT, 'card and field games differ');
+        END;
+    )sql",
+    R"sql(
+        CREATE TRIGGER card_field_value_game_update
+        BEFORE UPDATE ON card_field_values
+        WHEN NOT EXISTS(
+            SELECT 1
+            FROM cards JOIN game_fields
+              ON cards.game_short_name = game_fields.game_short_name
+            WHERE cards.id = NEW.card_id
+              AND game_fields.id = NEW.field_id)
+        BEGIN
+            SELECT RAISE(ABORT, 'card and field games differ');
+        END;
+    )sql",
     R"sql(
         CREATE TABLE card_holdings(
             user_id INTEGER NOT NULL
@@ -329,6 +486,114 @@ mw::E<std::vector<Card>> cardsFromRows(std::vector<CardRow> rows)
     return result;
 }
 
+mw::E<std::optional<GameDefinitionSnapshot>> loadGameDefinition(
+    mw::SQLite& connection,
+    const std::string& short_name)
+{
+    auto game_statement = connection.statementFromStr(
+        "SELECT short_name, display_name, description, revision "
+        "FROM games WHERE short_name = ?;");
+    if(!game_statement)
+    {
+        return std::unexpected(std::move(game_statement.error()));
+    }
+    auto game_bind = game_statement->bind(short_name);
+    if(!game_bind)
+    {
+        return std::unexpected(std::move(game_bind.error()));
+    }
+    auto game_rows = connection.eval<
+        std::string, std::string, std::string, std::int64_t>(
+            std::move(*game_statement));
+    if(!game_rows)
+    {
+        return std::unexpected(std::move(game_rows.error()));
+    }
+    if(game_rows->empty())
+    {
+        return std::optional<GameDefinitionSnapshot>{};
+    }
+
+    auto& [stored_short_name, display_name, description, revision] =
+        game_rows->front();
+    if(revision < 1)
+    {
+        return std::unexpected(mw::runtimeError(
+            "Database contains an invalid game revision"));
+    }
+    GameDefinitionSnapshot snapshot{
+        {std::move(stored_short_name), std::move(display_name),
+         std::move(description), revision},
+        {}};
+
+    auto field_statement = connection.statementFromStr(
+        "SELECT id, game_short_name, key, label, type, position "
+        "FROM game_fields WHERE game_short_name = ? "
+        "ORDER BY position, id;");
+    if(!field_statement)
+    {
+        return std::unexpected(std::move(field_statement.error()));
+    }
+    auto field_bind = field_statement->bind(short_name);
+    if(!field_bind)
+    {
+        return std::unexpected(std::move(field_bind.error()));
+    }
+    auto field_rows = connection.eval<
+        std::int64_t, std::string, std::string, std::string,
+        std::string, std::int64_t>(std::move(*field_statement));
+    if(!field_rows)
+    {
+        return std::unexpected(std::move(field_rows.error()));
+    }
+    snapshot.fields.reserve(field_rows->size());
+    for(auto& [id, game_short_name, key, label, type_name, position] :
+        *field_rows)
+    {
+        auto type = parseGameFieldType(type_name);
+        if(!type || position < 0)
+        {
+            return std::unexpected(mw::runtimeError(
+                "Database contains an invalid game field"));
+        }
+        GameField field{
+            id, std::move(game_short_name), std::move(key),
+            std::move(label), *type, position, {}};
+
+        auto choice_statement = connection.statementFromStr(
+            "SELECT value, position FROM game_field_choices "
+            "WHERE field_id = ? ORDER BY position, value;");
+        if(!choice_statement)
+        {
+            return std::unexpected(std::move(choice_statement.error()));
+        }
+        auto choice_bind = choice_statement->bind(id);
+        if(!choice_bind)
+        {
+            return std::unexpected(std::move(choice_bind.error()));
+        }
+        auto choice_rows = connection.eval<std::string, std::int64_t>(
+            std::move(*choice_statement));
+        if(!choice_rows)
+        {
+            return std::unexpected(std::move(choice_rows.error()));
+        }
+        field.choices.reserve(choice_rows->size());
+        for(auto& [value, choice_position] : *choice_rows)
+        {
+            if(choice_position < 0)
+            {
+                return std::unexpected(mw::runtimeError(
+                    "Database contains an invalid choice position"));
+            }
+            field.choices.push_back(
+                {std::move(value), choice_position});
+        }
+        snapshot.fields.push_back(std::move(field));
+    }
+    return std::optional<GameDefinitionSnapshot>(std::move(snapshot));
+}
+
 class DataSourceSQLiteTransaction final
     : public DataSourceTransactionInterface
 {
@@ -397,7 +662,7 @@ public:
             std::get<0>(rows->front()));
     }
 
-    /// Ensure a registered game has a persistent sequence row.
+    /// Ensure a database-defined game has a persistent sequence row.
     mw::E<void> ensureGameSequence(
         const std::string& game_short_name) override
     {
@@ -416,6 +681,371 @@ public:
         return connection_.execute(std::move(*statement));
     }
 
+    /// Read a complete game definition under this transaction's lock.
+    mw::E<std::optional<GameDefinitionSnapshot>>
+    getGameDefinitionForUpdate(const std::string& short_name) override
+    {
+        return loadGameDefinition(connection_, short_name);
+    }
+
+    /// Return deletion-relevant usage for one game.
+    mw::E<GameUsage> getGameUsage(
+        const std::string& short_name) override
+    {
+        auto statement = connection_.statementFromStr(
+            "SELECT (SELECT COUNT(*) FROM cards "
+            "WHERE game_short_name = ?), "
+            "(SELECT COUNT(*) FROM series WHERE game_short_name = ?), "
+            "COALESCE((SELECT last_number FROM game_sequences "
+            "WHERE game_short_name = ?), -1);");
+        if(!statement)
+        {
+            return std::unexpected(std::move(statement.error()));
+        }
+        auto bind = statement->bind(short_name, short_name, short_name);
+        if(!bind)
+        {
+            return std::unexpected(std::move(bind.error()));
+        }
+        auto rows = connection_.eval<
+            std::int64_t, std::int64_t, std::int64_t>(
+                std::move(*statement));
+        if(!rows || rows->size() != 1)
+        {
+            return std::unexpected(
+                rows ? mw::runtimeError("Could not read game usage")
+                     : std::move(rows.error()));
+        }
+        auto [cards, series, last_number] = rows->front();
+        return GameUsage{cards, series, last_number};
+    }
+
+    /// Insert a game and its number sequence.
+    mw::E<void> insertGame(const Game& game) override
+    {
+        auto statement = connection_.statementFromStr(
+            "INSERT INTO games(short_name, display_name, description, "
+            "revision) VALUES (?, ?, ?, ?);");
+        if(!statement)
+        {
+            return std::unexpected(std::move(statement.error()));
+        }
+        auto bind = statement->bind(
+            game.short_name, game.display_name, game.description,
+            game.revision);
+        if(!bind)
+        {
+            return std::unexpected(std::move(bind.error()));
+        }
+        auto inserted = connection_.execute(std::move(*statement));
+        if(!inserted)
+        {
+            return std::unexpected(std::move(inserted.error()));
+        }
+        return ensureGameSequence(game.short_name);
+    }
+
+    /// Replace mutable game metadata and advance its revision.
+    mw::E<bool> updateGame(
+        const std::string& short_name,
+        const std::string& display_name,
+        const std::string& description,
+        std::int64_t expected_revision) override
+    {
+        auto statement = connection_.statementFromStr(
+            "UPDATE games SET display_name = ?, description = ?, "
+            "revision = revision + 1 "
+            "WHERE short_name = ? AND revision = ?;");
+        if(!statement)
+        {
+            return std::unexpected(std::move(statement.error()));
+        }
+        auto bind = statement->bind(
+            display_name, description, short_name, expected_revision);
+        if(!bind)
+        {
+            return std::unexpected(std::move(bind.error()));
+        }
+        auto updated = connection_.execute(std::move(*statement));
+        if(!updated)
+        {
+            return std::unexpected(std::move(updated.error()));
+        }
+        return connection_.changedRowsCount() == 1;
+    }
+
+    /// Delete an unused game and its definition rows.
+    mw::E<void> deleteGame(const std::string& short_name) override
+    {
+        auto fields = connection_.statementFromStr(
+            "DELETE FROM game_fields WHERE game_short_name = ?;");
+        if(!fields)
+        {
+            return std::unexpected(std::move(fields.error()));
+        }
+        auto fields_bind = fields->bind(short_name);
+        if(!fields_bind)
+        {
+            return std::unexpected(std::move(fields_bind.error()));
+        }
+        auto fields_deleted = connection_.execute(std::move(*fields));
+        if(!fields_deleted)
+        {
+            return std::unexpected(std::move(fields_deleted.error()));
+        }
+        for(const std::string table : {"game_sequences", "games"})
+        {
+            auto statement = connection_.statementFromStr(
+                "DELETE FROM " + table + " WHERE " +
+                (table == "games" ? "short_name" : "game_short_name") +
+                " = ?;");
+            if(!statement)
+            {
+                return std::unexpected(std::move(statement.error()));
+            }
+            auto bind = statement->bind(short_name);
+            if(!bind)
+            {
+                return std::unexpected(std::move(bind.error()));
+            }
+            auto deleted = connection_.execute(std::move(*statement));
+            if(!deleted)
+            {
+                return std::unexpected(std::move(deleted.error()));
+            }
+        }
+        return {};
+    }
+
+    /// Insert a field and its initial choices.
+    mw::E<std::int64_t> insertGameField(
+        const GameField& field,
+        const std::vector<GameChoice>& choices) override
+    {
+        auto statement = connection_.statementFromStr(
+            "INSERT INTO game_fields(game_short_name, key, label, type, "
+            "position) VALUES (?, ?, ?, ?, ?);");
+        if(!statement)
+        {
+            return std::unexpected(std::move(statement.error()));
+        }
+        auto bind = statement->bind(
+            field.game_short_name, field.key, field.label,
+            std::string(gameFieldTypeName(field.type)), field.position);
+        if(!bind)
+        {
+            return std::unexpected(std::move(bind.error()));
+        }
+        auto inserted = connection_.execute(std::move(*statement));
+        if(!inserted)
+        {
+            return std::unexpected(std::move(inserted.error()));
+        }
+        const std::int64_t field_id = connection_.lastInsertRowID();
+        for(const GameChoice& choice : choices)
+        {
+            auto choice_result = upsertChoice(field_id, choice);
+            if(!choice_result)
+            {
+                return std::unexpected(std::move(choice_result.error()));
+            }
+        }
+        return field_id;
+    }
+
+    /// Replace a field label and complete choice list.
+    mw::E<void> updateGameField(
+        std::int64_t field_id,
+        const std::string& label,
+        const std::vector<GameChoice>& choices) override
+    {
+        auto label_statement = connection_.statementFromStr(
+            "UPDATE game_fields SET label = ? WHERE id = ?;");
+        if(!label_statement)
+        {
+            return std::unexpected(std::move(label_statement.error()));
+        }
+        auto label_bind = label_statement->bind(label, field_id);
+        if(!label_bind)
+        {
+            return std::unexpected(std::move(label_bind.error()));
+        }
+        auto label_updated = connection_.execute(
+            std::move(*label_statement));
+        if(!label_updated)
+        {
+            return std::unexpected(std::move(label_updated.error()));
+        }
+        auto rows_statement = connection_.statementFromStr(
+            "SELECT value FROM game_field_choices WHERE field_id = ?;");
+        if(!rows_statement)
+        {
+            return std::unexpected(std::move(rows_statement.error()));
+        }
+        auto rows_bind = rows_statement->bind(field_id);
+        if(!rows_bind)
+        {
+            return std::unexpected(std::move(rows_bind.error()));
+        }
+        auto rows = connection_.eval<std::string>(
+            std::move(*rows_statement));
+        if(!rows)
+        {
+            return std::unexpected(std::move(rows.error()));
+        }
+        for(auto& [existing] : *rows)
+        {
+            const bool retained = std::ranges::any_of(
+                choices,
+                [&existing](const GameChoice& choice)
+                {
+                    return choice.value == existing;
+                });
+            if(!retained)
+            {
+                auto deleted = deleteChoice(field_id, existing);
+                if(!deleted)
+                {
+                    return std::unexpected(std::move(deleted.error()));
+                }
+            }
+        }
+        for(const GameChoice& choice : choices)
+        {
+            auto updated = upsertChoice(field_id, choice);
+            if(!updated)
+            {
+                return std::unexpected(std::move(updated.error()));
+            }
+        }
+        return {};
+    }
+
+    /// Delete a field with no stored card values.
+    mw::E<void> deleteGameField(std::int64_t field_id) override
+    {
+        auto statement = connection_.statementFromStr(
+            "DELETE FROM game_fields WHERE id = ?;");
+        if(!statement)
+        {
+            return std::unexpected(std::move(statement.error()));
+        }
+        auto bind = statement->bind(field_id);
+        if(!bind)
+        {
+            return std::unexpected(std::move(bind.error()));
+        }
+        return connection_.execute(std::move(*statement));
+    }
+
+    /// Return the number of cards using a field.
+    mw::E<std::int64_t> countFieldUsage(std::int64_t field_id) override
+    {
+        return countUsage(
+            "SELECT COUNT(*) FROM card_field_values WHERE field_id = ?;",
+            field_id, std::nullopt);
+    }
+
+    /// Return the number of cards using an exact choice.
+    mw::E<std::int64_t> countChoiceUsage(
+        std::int64_t field_id, const std::string& value) override
+    {
+        return countUsage(
+            "SELECT COUNT(*) FROM card_field_values "
+            "WHERE field_id = ? AND choice_value = ?;",
+            field_id, value);
+    }
+
+    /// Replace every field position for a game.
+    mw::E<void> updateGameFieldOrder(
+        const std::string& short_name,
+        const std::vector<std::int64_t>& field_ids) override
+    {
+        for(std::size_t position = 0; position < field_ids.size(); ++position)
+        {
+            auto statement = connection_.statementFromStr(
+                "UPDATE game_fields SET position = ? "
+                "WHERE id = ? AND game_short_name = ?;");
+            if(!statement)
+            {
+                return std::unexpected(std::move(statement.error()));
+            }
+            auto bind = statement->bind(
+                static_cast<std::int64_t>(position), field_ids[position],
+                short_name);
+            if(!bind)
+            {
+                return std::unexpected(std::move(bind.error()));
+            }
+            auto updated = connection_.execute(std::move(*statement));
+            if(!updated || connection_.changedRowsCount() != 1)
+            {
+                return std::unexpected(
+                    updated ? mw::runtimeError("Invalid field order")
+                            : std::move(updated.error()));
+            }
+        }
+        return {};
+    }
+
+    /// Advance a matching aggregate game revision.
+    mw::E<bool> incrementGameRevision(
+        const std::string& short_name,
+        std::int64_t expected_revision) override
+    {
+        auto statement = connection_.statementFromStr(
+            "UPDATE games SET revision = revision + 1 "
+            "WHERE short_name = ? AND revision = ?;");
+        if(!statement)
+        {
+            return std::unexpected(std::move(statement.error()));
+        }
+        auto bind = statement->bind(short_name, expected_revision);
+        if(!bind)
+        {
+            return std::unexpected(std::move(bind.error()));
+        }
+        auto updated = connection_.execute(std::move(*statement));
+        if(!updated)
+        {
+            return std::unexpected(std::move(updated.error()));
+        }
+        return connection_.changedRowsCount() == 1;
+    }
+
+    /// Return whether every requested series belongs to one game.
+    mw::E<bool> seriesBelongToGame(
+        const std::string& short_name,
+        const std::vector<std::int64_t>& series_ids) override
+    {
+        for(std::int64_t series_id : series_ids)
+        {
+            auto statement = connection_.statementFromStr(
+                "SELECT EXISTS(SELECT 1 FROM series "
+                "WHERE id = ? AND game_short_name = ?);");
+            if(!statement)
+            {
+                return std::unexpected(std::move(statement.error()));
+            }
+            auto bind = statement->bind(series_id, short_name);
+            if(!bind)
+            {
+                return std::unexpected(std::move(bind.error()));
+            }
+            auto belongs = connection_.evalToValue<int>(
+                std::move(*statement));
+            if(!belongs)
+            {
+                return std::unexpected(std::move(belongs.error()));
+            }
+            if(*belongs == 0)
+            {
+                return false;
+            }
+        }
+        return true;
+    }
+
     /// Return whether a loose-card number already exists.
     mw::E<bool> looseNumberExists(std::uint32_t number) override
     {
@@ -1025,154 +1655,79 @@ public:
         return std::optional<Card>(std::move(card));
     }
 
-    /// Insert common and series-membership rows for a loose card.
+    /// Insert a card with generic custom values and memberships.
     mw::E<std::int64_t> insertCard(
-        const Card& card,
-        const GameDefinition* game,
-        const GameCardMetadata* metadata,
-        const std::vector<std::int64_t>& series_ids) override
-    {
-        if(card.id != 0)
-        {
-            return std::unexpected(mw::runtimeError(
-                "A new card cannot already have an internal ID"));
-        }
-        if((game == nullptr) != (metadata == nullptr))
-        {
-            return std::unexpected(mw::runtimeError(
-                "Game definition and metadata must be provided together"));
-        }
-        if(card.identity.game_short_name)
-        {
-            if(game == nullptr)
-            {
-                return std::unexpected(mw::runtimeError(
-                    "A game card requires compiled game metadata"));
-            }
-            if(game->shortName() != *card.identity.game_short_name)
-            {
-                return std::unexpected(mw::runtimeError(
-                    "Card and compiled game identities differ"));
-            }
-        }
-        else if(game != nullptr || !series_ids.empty())
+        const Card& card,
+        const std::vector<GameFieldValue>& field_values,
+        const std::vector<std::int64_t>& series_ids) override
+    {
+        if(card.id != 0)
         {
             return std::unexpected(mw::runtimeError(
-                "A loose card cannot have game metadata or series"));
+                "A new card cannot already have an internal ID"));
         }
         if(!card.identity.game_short_name &&
-           card.identity.card_number >
-           std::numeric_limits<std::uint32_t>::max())
+           (!field_values.empty() || !series_ids.empty()))
         {
             return std::unexpected(mw::runtimeError(
-                "Loose card number exceeds the 32-bit namespace"));
+                "A loose card cannot have game fields or series"));
         }
-
-        auto insert = connection_.statementFromStr(
-            "INSERT INTO cards ("
-            "creator_user_id, game_short_name, card_number, name, "
-            "short_description, "
-            "long_description, rarity, front_extension, foil_extension, "
+        auto statement = connection_.statementFromStr(
+            "INSERT INTO cards (creator_user_id, game_short_name, "
+            "card_number, name, short_description, long_description, "
+            "rarity, front_extension, foil_extension, "
             "thumbnail_extension, revision) "
             "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?);");
-        if(!insert)
+        if(!statement)
         {
-            return std::unexpected(std::move(insert.error()));
+            return std::unexpected(std::move(statement.error()));
         }
-        auto bind = insert->bind(
-            card.creator_user_id,
-            card.identity.game_short_name,
+        auto bind = statement->bind(
+            card.creator_user_id, card.identity.game_short_name,
             static_cast<std::int64_t>(card.identity.card_number),
-            card.name,
-            card.short_description,
-            card.long_description,
-            card.rarity,
-            card.front_extension,
-            card.foil_extension,
-            card.thumbnail_extension,
-            card.revision);
+            card.name, card.short_description, card.long_description,
+            card.rarity, card.front_extension, card.foil_extension,
+            card.thumbnail_extension, card.revision);
         if(!bind)
         {
             return std::unexpected(std::move(bind.error()));
         }
-        auto execute = connection_.execute(std::move(*insert));
-        if(!execute)
+        auto inserted = connection_.execute(std::move(*statement));
+        if(!inserted)
         {
-            return std::unexpected(std::move(execute.error()));
+            return std::unexpected(std::move(inserted.error()));
         }
         const std::int64_t card_id = connection_.lastInsertRowID();
-
-        if(game != nullptr)
+        auto values = insertFieldValues(card_id, field_values);
+        if(!values)
         {
-            auto game_insert = game->insertMetadata(
-                connection_, card_id, *metadata);
-            if(!game_insert)
-            {
-                return std::unexpected(std::move(game_insert.error()));
-            }
+            return std::unexpected(std::move(values.error()));
         }
-
-        for(std::int64_t series_id : series_ids)
+        auto memberships = insertMemberships(card_id, series_ids);
+        if(!memberships)
         {
-            auto membership = connection_.statementFromStr(
-                "INSERT INTO card_series (card_id, series_id) "
-                "VALUES (?, ?);");
-            if(!membership)
-            {
-                return std::unexpected(std::move(membership.error()));
-            }
-            auto membership_bind = membership->bind<std::int64_t,
-                                                    std::int64_t>(
-                card_id, series_id);
-            if(!membership_bind)
-            {
-                return std::unexpected(
-                    std::move(membership_bind.error()));
-            }
-            auto membership_execute = connection_.execute(
-                std::move(*membership));
-            if(!membership_execute)
-            {
-                return std::unexpected(
-                    std::move(membership_execute.error()));
-            }
+            return std::unexpected(std::move(memberships.error()));
         }
         return card_id;
     }
 
-    /// Replace card rows after validation by the service layer.
+    /// Replace a card and all generic custom values and memberships.
     mw::E<void> updateCard(
         const Card& card,
-        const GameDefinition* game,
-        const GameCardMetadata* metadata,
-        const std::vector<std::int64_t>& series_ids)
-        override
+        const std::vector<GameFieldValue>& field_values,
+        const std::vector<std::int64_t>& series_ids) override
     {
         if(card.id <= 0)
         {
             return std::unexpected(mw::runtimeError(
                 "An updated card requires an internal ID"));
         }
-        if((game == nullptr) != (metadata == nullptr))
-        {
-            return std::unexpected(mw::runtimeError(
-                "Game definition and metadata must be provided together"));
-        }
-        if(card.identity.game_short_name)
-        {
-            if(game == nullptr ||
-               game->shortName() != *card.identity.game_short_name)
-            {
-                return std::unexpected(mw::runtimeError(
-                    "A game card requires its compiled game metadata"));
-            }
-        }
-        else if(game != nullptr || !series_ids.empty())
+        if(!card.identity.game_short_name &&
+           (!field_values.empty() || !series_ids.empty()))
         {
             return std::unexpected(mw::runtimeError(
-                "A loose card cannot have game metadata or series"));
+                "A loose card cannot have game fields or series"));
         }
-
         auto statement = connection_.statementFromStr(
             "UPDATE cards SET name = ?, short_description = ?, "
             "long_description = ?, rarity = ?, front_extension = ?, "
@@ -1185,89 +1740,43 @@ public:
             return std::unexpected(std::move(statement.error()));
         }
         auto bind = statement->bind(
-            card.name,
-            card.short_description,
-            card.long_description,
-            card.rarity,
-            card.front_extension,
-            card.foil_extension,
-            card.thumbnail_extension,
-            card.revision,
-            card.id,
-            card.identity.game_short_name,
-            card.identity.game_short_name,
+            card.name, card.short_description, card.long_description,
+            card.rarity, card.front_extension, card.foil_extension,
+            card.thumbnail_extension, card.revision, card.id,
+            card.identity.game_short_name, card.identity.game_short_name,
             static_cast<std::int64_t>(card.identity.card_number));
         if(!bind)
         {
             return std::unexpected(std::move(bind.error()));
         }
-        auto update = connection_.execute(std::move(*statement));
-        if(!update)
-        {
-            return std::unexpected(std::move(update.error()));
-        }
-        auto changes = connection_.evalToValue<std::int64_t>(
-            "SELECT changes();");
-        if(!changes)
+        auto updated = connection_.execute(std::move(*statement));
+        if(!updated)
         {
-            return std::unexpected(std::move(changes.error()));
+            return std::unexpected(std::move(updated.error()));
         }
-        if(*changes != 1)
+        if(connection_.changedRowsCount() != 1)
         {
             return std::unexpected(mw::runtimeError(
                 "The card disappeared while it was being updated"));
         }
-        if(game != nullptr)
-        {
-            auto game_update = game->updateMetadata(
-                connection_, card.id, *metadata);
-            if(!game_update)
-            {
-                return std::unexpected(std::move(game_update.error()));
-            }
-        }
-        auto delete_memberships = connection_.statementFromStr(
-            "DELETE FROM card_series WHERE card_id = ?;");
-        if(!delete_memberships)
-        {
-            return std::unexpected(std::move(delete_memberships.error()));
-        }
-        auto delete_bind = delete_memberships->bind<std::int64_t>(card.id);
-        if(!delete_bind)
+        auto delete_values = deleteCardRelations(
+            "card_field_values", card.id);
+        if(!delete_values)
         {
-            return std::unexpected(std::move(delete_bind.error()));
+            return std::unexpected(std::move(delete_values.error()));
         }
-        auto deleted = connection_.execute(std::move(*delete_memberships));
-        if(!deleted)
+        auto values = insertFieldValues(card.id, field_values);
+        if(!values)
         {
-            return std::unexpected(std::move(deleted.error()));
+            return std::unexpected(std::move(values.error()));
         }
-        for(std::int64_t series_id : series_ids)
+        auto delete_memberships = deleteCardRelations(
+            "card_series", card.id);
+        if(!delete_memberships)
         {
-            auto membership = connection_.statementFromStr(
-                "INSERT INTO card_series (card_id, series_id) "
-                "VALUES (?, ?);");
-            if(!membership)
-            {
-                return std::unexpected(std::move(membership.error()));
-            }
-            auto membership_bind = membership->bind<std::int64_t,
-                                                    std::int64_t>(
-                card.id, series_id);
-            if(!membership_bind)
-            {
-                return std::unexpected(
-                    std::move(membership_bind.error()));
-            }
-            auto membership_insert = connection_.execute(
-                std::move(*membership));
-            if(!membership_insert)
-            {
-                return std::unexpected(
-                    std::move(membership_insert.error()));
-            }
+            return std::unexpected(std::move(delete_memberships.error()));
         }
-        return {};
+        return insertMemberships(card.id, series_ids);
     }
 
     /// Delete a card and its dependent database rows.
@@ -1378,6 +1887,171 @@ public:
     }
 
 private:
+    mw::E<void> upsertChoice(
+        std::int64_t field_id, const GameChoice& choice)
+    {
+        auto statement = connection_.statementFromStr(
+            "INSERT INTO game_field_choices(field_id, value, position) "
+            "VALUES (?, ?, ?) ON CONFLICT(field_id, value) DO UPDATE "
+            "SET position = excluded.position;");
+        if(!statement)
+        {
+            return std::unexpected(std::move(statement.error()));
+        }
+        auto bind = statement->bind(
+            field_id, choice.value, choice.position);
+        if(!bind)
+        {
+            return std::unexpected(std::move(bind.error()));
+        }
+        return connection_.execute(std::move(*statement));
+    }
+
+    mw::E<void> deleteChoice(
+        std::int64_t field_id, const std::string& value)
+    {
+        auto statement = connection_.statementFromStr(
+            "DELETE FROM game_field_choices "
+            "WHERE field_id = ? AND value = ?;");
+        if(!statement)
+        {
+            return std::unexpected(std::move(statement.error()));
+        }
+        auto bind = statement->bind(field_id, value);
+        if(!bind)
+        {
+            return std::unexpected(std::move(bind.error()));
+        }
+        return connection_.execute(std::move(*statement));
+    }
+
+    mw::E<std::int64_t> countUsage(
+        const std::string& sql,
+        std::int64_t field_id,
+        const std::optional<std::string>& value)
+    {
+        auto statement = connection_.statementFromStr(sql);
+        if(!statement)
+        {
+            return std::unexpected(std::move(statement.error()));
+        }
+        auto bind = value ? statement->bind(field_id, *value)
+                          : statement->bind(field_id);
+        if(!bind)
+        {
+            return std::unexpected(std::move(bind.error()));
+        }
+        return connection_.evalToValue<std::int64_t>(
+            std::move(*statement));
+    }
+
+    mw::E<void> deleteCardRelations(
+        std::string_view table, std::int64_t card_id)
+    {
+        auto statement = connection_.statementFromStr(
+            "DELETE FROM " + std::string(table) + " WHERE card_id = ?;");
+        if(!statement)
+        {
+            return std::unexpected(std::move(statement.error()));
+        }
+        auto bind = statement->bind(card_id);
+        if(!bind)
+        {
+            return std::unexpected(std::move(bind.error()));
+        }
+        return connection_.execute(std::move(*statement));
+    }
+
+    mw::E<void> insertFieldValues(
+        std::int64_t card_id,
+        const std::vector<GameFieldValue>& field_values)
+    {
+        for(const GameFieldValue& field_value : field_values)
+        {
+            std::optional<std::int64_t> integer_value;
+            std::optional<std::string> string_value;
+            std::optional<std::string> choice_value;
+            if(field_value.type == GameFieldType::INTEGER)
+            {
+                const auto value = std::get_if<std::int64_t>(
+                    &field_value.value);
+                if(value == nullptr)
+                {
+                    return std::unexpected(mw::runtimeError(
+                        "Integer field has a non-integer value"));
+                }
+                integer_value = *value;
+            }
+            else
+            {
+                const auto value = std::get_if<std::string>(
+                    &field_value.value);
+                if(value == nullptr)
+                {
+                    return std::unexpected(mw::runtimeError(
+                        "Text field has a non-text value"));
+                }
+                if(field_value.type == GameFieldType::STRING)
+                {
+                    string_value = *value;
+                }
+                else
+                {
+                    choice_value = *value;
+                }
+            }
+            auto statement = connection_.statementFromStr(
+                "INSERT INTO card_field_values(card_id, field_id, "
+                "field_type, integer_value, string_value, choice_value) "
+                "VALUES (?, ?, ?, ?, ?, ?);");
+            if(!statement)
+            {
+                return std::unexpected(std::move(statement.error()));
+            }
+            auto bind = statement->bind(
+                card_id, field_value.field_id,
+                std::string(gameFieldTypeName(field_value.type)),
+                integer_value, string_value, choice_value);
+            if(!bind)
+            {
+                return std::unexpected(std::move(bind.error()));
+            }
+            auto inserted = connection_.execute(std::move(*statement));
+            if(!inserted)
+            {
+                return std::unexpected(std::move(inserted.error()));
+            }
+        }
+        return {};
+    }
+
+    mw::E<void> insertMemberships(
+        std::int64_t card_id,
+        const std::vector<std::int64_t>& series_ids)
+    {
+        for(std::int64_t series_id : series_ids)
+        {
+            auto statement = connection_.statementFromStr(
+                "INSERT INTO card_series(card_id, series_id) "
+                "VALUES (?, ?);");
+            if(!statement)
+            {
+                return std::unexpected(std::move(statement.error()));
+            }
+            auto bind = statement->bind(card_id, series_id);
+            if(!bind)
+            {
+                return std::unexpected(std::move(bind.error()));
+            }
+            auto inserted = connection_.execute(std::move(*statement));
+            if(!inserted)
+            {
+                return std::unexpected(std::move(inserted.error()));
+            }
+        }
+        return {};
+    }
+
     mw::E<std::optional<User>> readUser(mw::SQLiteStatement statement)
     {
         auto rows = connection_.eval<
@@ -1576,8 +2250,7 @@ mw::E<std::int64_t> DataSourceSQLite::getSchemaVersion() const
     return *version;
 }
 
-mw::E<void> DataSourceSQLite::migrateSchema0To1(
-    const GameRegistry& games)
+mw::E<void> DataSourceSQLite::migrateSchema0To1()
 {
     std::lock_guard lock(mutex_);
     auto version = connection_->evalToValue<std::int64_t>(
@@ -1606,16 +2279,6 @@ mw::E<void> DataSourceSQLite::migrateSchema0To1(
                 *connection_, std::move(result.error()));
         }
     }
-    for(const GameDefinition* game : games.games())
-    {
-        auto game_schema = game->createSchema(*connection_);
-        if(!game_schema)
-        {
-            return rollbackWithError(
-                *connection_, std::move(game_schema.error()));
-        }
-    }
-
     auto set_version = connection_->execute("PRAGMA user_version = 1;");
     if(!set_version)
     {
@@ -1630,6 +2293,49 @@ mw::E<void> DataSourceSQLite::migrateSchema0To1(
     return {};
 }
 
+mw::E<void> DataSourceSQLite::checkIntegrity() const
+{
+    std::lock_guard lock(mutex_);
+    auto quick_check = connection_->eval<std::string>(
+        "PRAGMA quick_check;");
+    if(!quick_check)
+    {
+        return std::unexpected(std::move(quick_check.error()));
+    }
+    if(quick_check->size() != 1 ||
+       std::get<0>(quick_check->front()) != "ok")
+    {
+        return std::unexpected(mw::runtimeError(
+            "SQLite integrity check failed"));
+    }
+    auto foreign_key_errors = connection_->evalToValue<std::int64_t>(
+        "SELECT COUNT(*) FROM pragma_foreign_key_check;");
+    if(!foreign_key_errors)
+    {
+        return std::unexpected(std::move(foreign_key_errors.error()));
+    }
+    if(*foreign_key_errors != 0)
+    {
+        return std::unexpected(mw::runtimeError(
+            "Database contains foreign-key violations"));
+    }
+    auto missing_sequences = connection_->evalToValue<std::int64_t>(
+        "SELECT COUNT(*) FROM games AS game "
+        "LEFT JOIN game_sequences AS sequence "
+        "ON sequence.game_short_name = game.short_name "
+        "WHERE sequence.game_short_name IS NULL;");
+    if(!missing_sequences)
+    {
+        return std::unexpected(std::move(missing_sequences.error()));
+    }
+    if(*missing_sequences != 0)
+    {
+        return std::unexpected(mw::runtimeError(
+            "A game is missing its card-number sequence"));
+    }
+    return {};
+}
+
 mw::E<std::unique_ptr<DataSourceTransactionInterface>>
 DataSourceSQLite::beginTransaction()
 {
@@ -1841,6 +2547,136 @@ mw::E<std::optional<Card>> DataSourceSQLite::getCard(
     return std::optional<Card>(std::move(card));
 }
 
+mw::E<std::vector<Game>> DataSourceSQLite::getGames() const
+{
+    std::lock_guard lock(mutex_);
+    auto rows = connection_->eval<
+        std::string, std::string, std::string, std::int64_t>(
+            "SELECT short_name, display_name, description, revision "
+            "FROM games ORDER BY display_name, short_name;");
+    if(!rows)
+    {
+        return std::unexpected(std::move(rows.error()));
+    }
+    std::vector<Game> games;
+    games.reserve(rows->size());
+    for(auto& [short_name, display_name, description, revision] : *rows)
+    {
+        if(revision < 1)
+        {
+            return std::unexpected(mw::runtimeError(
+                "Database contains an invalid game revision"));
+        }
+        games.push_back({
+            std::move(short_name), std::move(display_name),
+            std::move(description), revision});
+    }
+    return games;
+}
+
+mw::E<std::optional<GameDefinitionSnapshot>>
+DataSourceSQLite::getGameDefinition(
+    const std::string& short_name) const
+{
+    std::lock_guard lock(mutex_);
+    return loadGameDefinition(*connection_, short_name);
+}
+
+mw::E<std::optional<CardGameFields>>
+DataSourceSQLite::getCardFieldValues(std::int64_t card_id) const
+{
+    std::lock_guard lock(mutex_);
+    auto card_statement = connection_->statementFromStr(
+        "SELECT game_short_name FROM cards WHERE id = ?;");
+    if(!card_statement)
+    {
+        return std::unexpected(std::move(card_statement.error()));
+    }
+    auto card_bind = card_statement->bind(card_id);
+    if(!card_bind)
+    {
+        return std::unexpected(std::move(card_bind.error()));
+    }
+    auto card_rows = connection_->eval<std::optional<std::string>>(
+        std::move(*card_statement));
+    if(!card_rows)
+    {
+        return std::unexpected(std::move(card_rows.error()));
+    }
+    if(card_rows->empty() || !std::get<0>(card_rows->front()))
+    {
+        return std::optional<CardGameFields>{};
+    }
+    const std::string& short_name = *std::get<0>(card_rows->front());
+    auto definition = loadGameDefinition(*connection_, short_name);
+    if(!definition)
+    {
+        return std::unexpected(std::move(definition.error()));
+    }
+    if(!*definition)
+    {
+        return std::unexpected(mw::runtimeError(
+            "Card references a missing game"));
+    }
+
+    auto value_statement = connection_->statementFromStr(
+        "SELECT value.field_id, value.field_type, "
+        "value.integer_value, value.string_value, value.choice_value "
+        "FROM card_field_values AS value "
+        "JOIN game_fields AS field ON field.id = value.field_id "
+        "WHERE value.card_id = ? ORDER BY field.position, field.id;");
+    if(!value_statement)
+    {
+        return std::unexpected(std::move(value_statement.error()));
+    }
+    auto value_bind = value_statement->bind(card_id);
+    if(!value_bind)
+    {
+        return std::unexpected(std::move(value_bind.error()));
+    }
+    auto value_rows = connection_->eval<
+        std::int64_t, std::string, std::optional<std::int64_t>,
+        std::optional<std::string>, std::optional<std::string>>(
+            std::move(*value_statement));
+    if(!value_rows)
+    {
+        return std::unexpected(std::move(value_rows.error()));
+    }
+
+    CardGameFields result{std::move(**definition), {}};
+    result.values.reserve(value_rows->size());
+    for(auto& [field_id, type_name, integer_value, string_value,
+               choice_value] : *value_rows)
+    {
+        auto type = parseGameFieldType(type_name);
+        if(!type)
+        {
+            return std::unexpected(mw::runtimeError(
+                "Database contains an invalid card field type"));
+        }
+        if(*type == GameFieldType::INTEGER && integer_value)
+        {
+            result.values.push_back({field_id, *type, *integer_value});
+        }
+        else if(*type == GameFieldType::STRING && string_value)
+        {
+            result.values.push_back(
+                {field_id, *type, std::move(*string_value)});
+        }
+        else if(*type == GameFieldType::CHOICE && choice_value)
+        {
+            result.values.push_back(
+                {field_id, *type, std::move(*choice_value)});
+        }
+        else
+        {
+            return std::unexpected(mw::runtimeError(
+                "Database contains an invalid card field value"));
+        }
+    }
+    return std::optional<CardGameFields>(std::move(result));
+}
+
 mw::E<std::optional<User>> DataSourceSQLite::getUser(
     std::int64_t user_id) const
 {
@@ -2320,23 +3156,6 @@ mw::E<void> DataSourceSQLite::cleanupAuthentication(std::int64_t now)
     return connection_->execute(std::move(*quota));
 }
 
-mw::E<std::vector<DisplayField>>
-DataSourceSQLite::getGameDisplayFields(
-    const GameDefinition& game,
-    std::int64_t card_id) const
-{
-    std::lock_guard lock(mutex_);
-    return game.displayFields(*connection_, card_id);
-}
-
-mw::E<FormFields> DataSourceSQLite::getGameFormValues(
-    const GameDefinition& game,
-    std::int64_t card_id) const
-{
-    std::lock_guard lock(mutex_);
-    return game.formValues(*connection_, card_id);
-}
-
 mw::E<std::vector<Series>> DataSourceSQLite::getSeries() const
 {
     std::lock_guard lock(mutex_);
@@ -2433,28 +3252,6 @@ mw::E<std::vector<std::int64_t>> DataSourceSQLite::getCardSeries(
     return result;
 }
 
-mw::E<std::vector<std::string>>
-DataSourceSQLite::getPersistedGameNames() const
-{
-    std::lock_guard lock(mutex_);
-    auto rows = connection_->eval<std::string>(
-        "SELECT game_short_name FROM cards "
-        "WHERE game_short_name IS NOT NULL "
-        "UNION SELECT game_short_name FROM series "
-        "ORDER BY game_short_name;");
-    if(!rows)
-    {
-        return std::unexpected(std::move(rows.error()));
-    }
-    std::vector<std::string> result;
-    result.reserve(rows->size());
-    for(auto& [name] : *rows)
-    {
-        result.push_back(std::move(name));
-    }
-    return result;
-}
-
 mw::E<void> DataSourceSQLite::setSchemaVersion(
     std::int64_t version)
 {
diff --git a/src/data_sqlite.h b/src/data_sqlite.h
index 0b51464..ba56641 100644
--- a/src/data_sqlite.h
+++ b/src/data_sqlite.h
@@ -28,9 +28,11 @@ public:
     /// Return the stored schema version.
     mw::E<std::int64_t> getSchemaVersion() const override;
 
-    /// Create schema version 1 from an empty version-0 database.
-    mw::E<void>
-    migrateSchema0To1(const GameRegistry& games) override;
+    /// Migrate an empty version-0 database to schema version 1.
+    mw::E<void> migrateSchema0To1() override;
+
+    /// Verify SQLite and application referential integrity after migration.
+    mw::E<void> checkIntegrity() const;
 
     /// Start an immediate transaction with exclusive mutation ownership.
     mw::E<std::unique_ptr<DataSourceTransactionInterface>>
@@ -50,6 +52,17 @@ public:
     mw::E<std::optional<Card>>
     getCard(const CardIdentity& identity) const override;
 
+    /// Return all database-defined games ordered by display name.
+    mw::E<std::vector<Game>> getGames() const override;
+
+    /// Return a complete, consistently read game definition.
+    mw::E<std::optional<GameDefinitionSnapshot>>
+    getGameDefinition(const std::string& short_name) const override;
+
+    /// Return a card's definition and ordered custom values together.
+    mw::E<std::optional<CardGameFields>>
+    getCardFieldValues(std::int64_t card_id) const override;
+
     /// Return a user by internal identity.
     mw::E<std::optional<User>> getUser(
         std::int64_t user_id) const override;
@@ -88,16 +101,6 @@ public:
     /// Best-effort removal of obsolete authentication rows.
     mw::E<void> cleanupAuthentication(std::int64_t now) override;
 
-    /// Return a card's game-owned display fields.
-    mw::E<std::vector<DisplayField>> getGameDisplayFields(
-        const GameDefinition& game,
-        std::int64_t card_id) const override;
-
-    /// Return current game-owned values for the edit form.
-    mw::E<FormFields> getGameFormValues(
-        const GameDefinition& game,
-        std::int64_t card_id) const override;
-
     /// Return all series, ordered by game and name.
     mw::E<std::vector<Series>> getSeries() const override;
 
@@ -109,10 +112,6 @@ public:
     mw::E<std::vector<std::int64_t>>
     getCardSeries(std::int64_t card_id) const override;
 
-    /// Return every persisted game name used for startup reconciliation.
-    mw::E<std::vector<std::string>>
-    getPersistedGameNames() const override;
-
 protected:
     /// Set the schema version inside a concrete migration transaction.
     mw::E<void> setSchemaVersion(std::int64_t version) override;
diff --git a/src/form_limits.h b/src/form_limits.h
new file mode 100644
index 0000000..5f1166c
--- /dev/null
+++ b/src/form_limits.h
@@ -0,0 +1,9 @@
+#pragma once
+
+#include <cstddef>
+
+/// Maximum decoded byte length of one submitted text control.
+inline constexpr std::size_t MAX_FORM_TEXT_FIELD_SIZE = 1024 * 1024;
+
+/// Maximum aggregate decoded text bytes in one application form.
+inline constexpr std::size_t MAX_FORM_TOTAL_TEXT_SIZE = 8 * 1024 * 1024;
diff --git a/src/game.h b/src/game.h
index 73362ed..4ef45d6 100644
--- a/src/game.h
+++ b/src/game.h
@@ -2,24 +2,126 @@
 
 #include <cstdint>
 #include <string>
+#include <unordered_map>
+#include <variant>
+#include <vector>
 
-/// One game-specific label and value shown on a card page.
-struct DisplayField
+/// Supported storage and form behavior for a custom game field.
+enum class GameFieldType
 {
+    INTEGER,
+    STRING,
+    CHOICE
+};
+
+/// One database-defined game.
+struct Game
+{
+    /// Permanent lowercase public card-ID prefix.
+    std::string short_name;
+
+    /// Human-readable game name.
+    std::string display_name;
+
+    /// Markdown game description.
+    std::string description;
+
+    /// Aggregate optimistic-concurrency revision.
+    std::int64_t revision;
+};
+
+/// One allowed value for a choice field.
+struct GameChoice
+{
+    /// Exact stored and displayed choice text.
+    std::string value;
+
+    /// Zero-based display position.
+    std::int64_t position;
+};
+
+/// One ordered custom field belonging to a game.
+struct GameField
+{
+    /// Internal database identity.
+    std::int64_t id;
+
+    /// Permanent owning game short name.
+    std::string game_short_name;
+
+    /// Permanent key used in submitted form names.
+    std::string key;
+
     /// Human-readable field label.
     std::string label;
 
-    /// Human-readable field value.
-    std::string value;
+    /// Field value type.
+    GameFieldType type;
+
+    /// Zero-based display position.
+    std::int64_t position;
+
+    /// Ordered allowed values, populated only for choice fields.
+    std::vector<GameChoice> choices;
+};
+
+/// Consistent owning snapshot of a game and all its field definitions.
+struct GameDefinitionSnapshot
+{
+    /// Database-defined game metadata.
+    Game game;
+
+    /// Fields ordered by position and stable identity.
+    std::vector<GameField> fields;
+};
+
+/// Decoded storage value for one custom field.
+struct GameFieldValue
+{
+    /// Referenced field identity.
+    std::int64_t field_id;
+
+    /// Field discriminator retained for database constraint checks.
+    GameFieldType type;
+
+    /// Parsed integer or exact string/choice text.
+    std::variant<std::int64_t, std::string> value;
 };
 
-/// Dynamic series metadata belonging to one compiled game.
+/// One card's values paired with a consistent owning game definition.
+struct CardGameFields
+{
+    /// Definition used to interpret and present the stored values.
+    GameDefinitionSnapshot definition;
+
+    /// Set values ordered by their fields' display positions.
+    std::vector<GameFieldValue> values;
+};
+
+/// Counts that determine whether a database-defined game can be deleted.
+struct GameUsage
+{
+    /// Cards that use the game.
+    std::int64_t card_count;
+
+    /// Series that use the game.
+    std::int64_t series_count;
+
+    /// Highest public card number ever allocated for the game.
+    std::int64_t last_number;
+};
+
+/// Unique custom-field form values keyed by permanent field key.
+using SubmittedGameFields =
+    std::unordered_map<std::string, std::string>;
+
+/// Dynamic series metadata belonging to one database-defined game.
 struct Series
 {
     /// Internal SQLite primary key, or zero before insertion.
     std::int64_t id;
 
-    /// Lowercase short name of the owning compiled game.
+    /// Lowercase short name of the owning database-defined game.
     std::string game_short_name;
 
     /// Series name, unique within its game.
diff --git a/src/game_definition.h b/src/game_definition.h
deleted file mode 100644
index 44b9e25..0000000
--- a/src/game_definition.h
+++ /dev/null
@@ -1,92 +0,0 @@
-#pragma once
-
-#include <cstdint>
-#include <memory>
-#include <string>
-#include <string_view>
-#include <unordered_map>
-#include <vector>
-
-#include <mw/database.hpp>
-#include <mw/error.hpp>
-
-#include "game.h"
-
-/// Posted fields owned by one compiled game definition.
-using FormFields = std::unordered_map<std::string, std::string>;
-
-/// One server-owned field rendered for compiled-game card metadata.
-struct GameFormField
-{
-    /// Posted field name below the game namespace.
-    std::string name;
-
-    /// Human-readable form label.
-    std::string label;
-
-    /// Trusted HTML input type.
-    std::string input_type;
-
-    /// Whether browsers should require a value.
-    bool required;
-
-    /// Optional minimum value for numeric inputs.
-    std::string minimum;
-};
-
-/// Polymorphic validated metadata owned by a compiled game.
-class GameCardMetadata
-{
-public:
-    /// Destroy validated game metadata through its base interface.
-    virtual ~GameCardMetadata() = default;
-};
-
-/// Compiled behavior and persistence hooks for one game.
-class GameDefinition
-{
-public:
-    /// Destroy a compiled game through its base interface.
-    virtual ~GameDefinition() = default;
-
-    /// Return the immutable lowercase public short name.
-    virtual std::string_view shortName() const = 0;
-
-    /// Return the human-readable game name.
-    virtual std::string_view displayName() const = 0;
-
-    /// Return the Markdown game description.
-    virtual std::string_view description() const = 0;
-
-    /// Add this game's extension schema to a new database.
-    virtual mw::E<void> createSchema(mw::SQLite& database) const = 0;
-
-    /// Validate posted game fields into strongly typed metadata.
-    virtual mw::E<std::unique_ptr<GameCardMetadata>> validateMetadata(
-        const FormFields& fields) const = 0;
-
-    /// Return trusted field definitions for the create-card form.
-    virtual std::vector<GameFormField> formFields() const = 0;
-
-    /// Return current field values for the edit-card form.
-    virtual mw::E<FormFields> formValues(
-        mw::SQLite& database,
-        std::int64_t card_id) const = 0;
-
-    /// Insert game-owned metadata for a common card ID.
-    virtual mw::E<void> insertMetadata(
-        mw::SQLite& database,
-        std::int64_t card_id,
-        const GameCardMetadata& metadata) const = 0;
-
-    /// Replace game-owned metadata for a common card ID.
-    virtual mw::E<void> updateMetadata(
-        mw::SQLite& database,
-        std::int64_t card_id,
-        const GameCardMetadata& metadata) const = 0;
-
-    /// Return read-only label/value fields for a common card ID.
-    virtual mw::E<std::vector<DisplayField>> displayFields(
-        mw::SQLite& database,
-        std::int64_t card_id) const = 0;
-};
diff --git a/src/game_field.cpp b/src/game_field.cpp
new file mode 100644
index 0000000..e20e58a
--- /dev/null
+++ b/src/game_field.cpp
@@ -0,0 +1,198 @@
+#include "game_field.h"
+
+#include <algorithm>
+#include <charconv>
+#include <cstdint>
+#include <string>
+#include <string_view>
+#include <unordered_set>
+#include <utility>
+#include <vector>
+
+bool validGameText(std::string_view value)
+{
+    const auto* current = reinterpret_cast<const unsigned char*>(
+        value.data());
+    const auto* end = current + value.size();
+    while(current != end)
+    {
+        const unsigned char first = *current++;
+        if(first <= 0x7f)
+        {
+            if(first == 0)
+            {
+                return false;
+            }
+            continue;
+        }
+        std::size_t remaining = 0;
+        std::uint32_t code_point = 0;
+        std::uint32_t minimum = 0;
+        if(first >= 0xc2 && first <= 0xdf)
+        {
+            remaining = 1;
+            code_point = first & 0x1f;
+            minimum = 0x80;
+        }
+        else if(first >= 0xe0 && first <= 0xef)
+        {
+            remaining = 2;
+            code_point = first & 0x0f;
+            minimum = 0x800;
+        }
+        else if(first >= 0xf0 && first <= 0xf4)
+        {
+            remaining = 3;
+            code_point = first & 0x07;
+            minimum = 0x10000;
+        }
+        else
+        {
+            return false;
+        }
+        if(static_cast<std::size_t>(end - current) < remaining)
+        {
+            return false;
+        }
+        for(std::size_t index = 0; index < remaining; ++index)
+        {
+            const unsigned char continuation = *current++;
+            if((continuation & 0xc0) != 0x80)
+            {
+                return false;
+            }
+            code_point = (code_point << 6) | (continuation & 0x3f);
+        }
+        if(code_point < minimum || code_point > 0x10ffff ||
+           (code_point >= 0xd800 && code_point <= 0xdfff))
+        {
+            return false;
+        }
+    }
+    return true;
+}
+
+namespace
+{
+
+mw::Error fieldError(const GameField& field, std::string message)
+{
+    return GameFieldValidationError{
+        field.key,
+        "Invalid field '" + field.key + "': " + std::move(message)};
+}
+
+mw::E<std::int64_t> parseInteger(
+    const GameField& field, const std::string& value)
+{
+    if(value.empty() || value.front() == '+' || value.front() == ' ' ||
+       value.back() == ' ')
+    {
+        return std::unexpected(fieldError(field, "expected an integer"));
+    }
+    std::int64_t result = 0;
+    const auto parsed = std::from_chars(
+        value.data(), value.data() + value.size(), result, 10);
+    if(parsed.ec != std::errc() ||
+       parsed.ptr != value.data() + value.size())
+    {
+        return std::unexpected(fieldError(field, "expected an integer"));
+    }
+    return result;
+}
+
+} // namespace
+
+mw::E<std::vector<GameFieldValue>> decodeGameFields(
+    const GameDefinitionSnapshot& definition,
+    const SubmittedGameFields& submitted)
+{
+    std::unordered_set<std::string_view> known_keys;
+    known_keys.reserve(definition.fields.size());
+    for(const GameField& field : definition.fields)
+    {
+        known_keys.insert(field.key);
+    }
+    for(const auto& [key, value] : submitted)
+    {
+        [[maybe_unused]] const std::string& ignored_value = value;
+        if(!known_keys.contains(key))
+        {
+            return std::unexpected(mw::Error(GameFieldValidationError{
+                key, "Unknown custom field '" + key + "'"}));
+        }
+    }
+
+    std::vector<GameFieldValue> result;
+    result.reserve(submitted.size());
+    for(const GameField& field : definition.fields)
+    {
+        const auto position = submitted.find(field.key);
+        if(position == submitted.end() || position->second.empty())
+        {
+            continue;
+        }
+        const std::string& value = position->second;
+        if(!validGameText(value))
+        {
+            return std::unexpected(fieldError(field, "expected UTF-8 text"));
+        }
+        if(field.type == GameFieldType::INTEGER)
+        {
+            auto parsed = parseInteger(field, value);
+            if(!parsed)
+            {
+                return std::unexpected(std::move(parsed.error()));
+            }
+            result.push_back({field.id, field.type, *parsed});
+            continue;
+        }
+        if(field.type == GameFieldType::CHOICE)
+        {
+            const bool allowed = std::ranges::any_of(
+                field.choices,
+                [&value](const GameChoice& choice)
+                {
+                    return choice.value == value;
+                });
+            if(!allowed)
+            {
+                return std::unexpected(
+                    fieldError(field, "unknown choice"));
+            }
+        }
+        result.push_back({field.id, field.type, value});
+    }
+    return result;
+}
+
+std::string_view gameFieldTypeName(GameFieldType type)
+{
+    switch(type)
+    {
+    case GameFieldType::INTEGER:
+        return "INTEGER";
+    case GameFieldType::STRING:
+        return "STRING";
+    case GameFieldType::CHOICE:
+        return "CHOICE";
+    }
+    return "UNKNOWN";
+}
+
+mw::E<GameFieldType> parseGameFieldType(std::string_view value)
+{
+    if(value == "INTEGER")
+    {
+        return GameFieldType::INTEGER;
+    }
+    if(value == "STRING")
+    {
+        return GameFieldType::STRING;
+    }
+    if(value == "CHOICE")
+    {
+        return GameFieldType::CHOICE;
+    }
+    return std::unexpected(mw::runtimeError("Unknown game field type"));
+}
diff --git a/src/game_field.h b/src/game_field.h
new file mode 100644
index 0000000..aa137cc
--- /dev/null
+++ b/src/game_field.h
@@ -0,0 +1,33 @@
+#pragma once
+
+#include <string>
+#include <string_view>
+#include <vector>
+
+#include <mw/error.hpp>
+
+#include "game.h"
+
+/// A custom-field validation failure addressable by a form control.
+struct GameFieldValidationError
+{
+    /// Permanent field key, or the unknown submitted key.
+    std::string field_key;
+
+    /// Safe user-facing validation message without the submitted value.
+    std::string msg;
+};
+
+/// Return whether text is valid UTF-8 and contains no NUL byte.
+bool validGameText(std::string_view value);
+
+/// Decode a complete custom-field submission against one game snapshot.
+mw::E<std::vector<GameFieldValue>> decodeGameFields(
+    const GameDefinitionSnapshot& definition,
+    const SubmittedGameFields& submitted);
+
+/// Return the stable database spelling for a field type.
+std::string_view gameFieldTypeName(GameFieldType type);
+
+/// Parse one stable database field-type spelling.
+mw::E<GameFieldType> parseGameFieldType(std::string_view value);
diff --git a/src/game_registry.cpp b/src/game_registry.cpp
deleted file mode 100644
index 38edc25..0000000
--- a/src/game_registry.cpp
+++ /dev/null
@@ -1,65 +0,0 @@
-#include "game_registry.h"
-
-#include <algorithm>
-#include <memory>
-#include <string_view>
-
-#include "game_definition.h"
-
-namespace
-{
-
-bool validShortName(std::string_view value)
-{
-    return !value.empty() && std::ranges::all_of(
-        value,
-        [](char character)
-        {
-            return (character >= 'a' && character <= 'z') ||
-                (character >= '0' && character <= '9');
-        });
-}
-
-} // namespace
-
-GameRegistry::GameRegistry() = default;
-
-GameRegistry::~GameRegistry() = default;
-
-mw::E<void> GameRegistry::add(std::unique_ptr<GameDefinition> game)
-{
-    if(!game || !validShortName(game->shortName()))
-    {
-        return std::unexpected(mw::runtimeError(
-            "A compiled game requires a lowercase alphanumeric short name"));
-    }
-    if(find(game->shortName()) != nullptr)
-    {
-        return std::unexpected(mw::runtimeError(
-            "A compiled game short name was registered more than once"));
-    }
-    games_.push_back(std::move(game));
-    return {};
-}
-
-const GameDefinition* GameRegistry::find(std::string_view short_name) const
-{
-    const auto position = std::ranges::find_if(
-        games_,
-        [short_name](const std::unique_ptr<GameDefinition>& game)
-        {
-            return game->shortName() == short_name;
-        });
-    return position == games_.end() ? nullptr : position->get();
-}
-
-std::vector<const GameDefinition*> GameRegistry::games() const
-{
-    std::vector<const GameDefinition*> result;
-    result.reserve(games_.size());
-    for(const std::unique_ptr<GameDefinition>& game : games_)
-    {
-        result.push_back(game.get());
-    }
-    return result;
-}
diff --git a/src/game_registry.h b/src/game_registry.h
deleted file mode 100644
index b9c7569..0000000
--- a/src/game_registry.h
+++ /dev/null
@@ -1,32 +0,0 @@
-#pragma once
-
-#include <memory>
-#include <string_view>
-#include <vector>
-
-#include <mw/error.hpp>
-
-class GameDefinition;
-
-/// Immutable-after-startup collection of compiled game definitions.
-class GameRegistry
-{
-public:
-    /// Construct an empty registry.
-    GameRegistry();
-
-    /// Destroy all owned compiled game definitions.
-    ~GameRegistry();
-
-    /// Register one uniquely named compiled game definition.
-    mw::E<void> add(std::unique_ptr<GameDefinition> game);
-
-    /// Return a registered game by short name, or null when absent.
-    const GameDefinition* find(std::string_view short_name) const;
-
-    /// Return all registered games in registration order.
-    std::vector<const GameDefinition*> games() const;
-
-private:
-    std::vector<std::unique_ptr<GameDefinition>> games_;
-};
diff --git a/src/game_service.cpp b/src/game_service.cpp
new file mode 100644
index 0000000..ef9fa44
--- /dev/null
+++ b/src/game_service.cpp
@@ -0,0 +1,580 @@
+#include "game_service.h"
+
+#include <algorithm>
+#include <cstdint>
+#include <string>
+#include <unordered_set>
+#include <utility>
+#include <vector>
+
+#include <mw/utils.hpp>
+
+#include "game_field.h"
+
+namespace
+{
+
+mw::E<void> authorizeAdministrator(
+    DataSourceTransactionInterface& transaction,
+    const AuthorizationService& authorization,
+    std::int64_t actor_user_id)
+{
+    auto actor = transaction.getUserForUpdate(actor_user_id);
+    if(!actor)
+    {
+        return std::unexpected(std::move(actor.error()));
+    }
+    if(!*actor || !(*actor)->username ||
+       !authorization.canAdminister(**actor))
+    {
+        return std::unexpected(mw::httpError(403, "Administrator required"));
+    }
+    return {};
+}
+
+bool validShortName(const std::string& value)
+{
+    return !value.empty() && std::ranges::all_of(
+        value,
+        [](unsigned char character)
+        {
+            return (character >= 'a' && character <= 'z') ||
+                   (character >= '0' && character <= '9');
+        });
+}
+
+bool validFieldKey(const std::string& value)
+{
+    if(value.empty() || value.front() < 'a' || value.front() > 'z')
+    {
+        return false;
+    }
+    return std::ranges::all_of(
+        value,
+        [](unsigned char character)
+        {
+            return (character >= 'a' && character <= 'z') ||
+                   (character >= '0' && character <= '9') ||
+                   character == '_';
+        });
+}
+
+mw::E<void> validateDisplayText(
+    std::string& display_name,
+    const std::string& description,
+    MarkdownRenderer& renderer)
+{
+    display_name = std::string(mw::strip(display_name));
+    if(display_name.empty())
+    {
+        return std::unexpected(mw::Error(GameDefinitionValidationError{
+            "display_name", "Game name is required"}));
+    }
+    if(!validGameText(display_name))
+    {
+        return std::unexpected(mw::Error(GameDefinitionValidationError{
+            "display_name", "Game name must be UTF-8 text"}));
+    }
+    if(!validGameText(description))
+    {
+        return std::unexpected(mw::Error(GameDefinitionValidationError{
+            "description", "Game description must be UTF-8 text"}));
+    }
+    auto rendered = renderer.render(description);
+    if(!rendered)
+    {
+        return std::unexpected(mw::Error(GameDefinitionValidationError{
+            "description", rendered.error().msg()}));
+    }
+    return {};
+}
+
+mw::E<std::vector<GameChoice>> validateChoices(
+    GameFieldType type, const std::vector<std::string>& values)
+{
+    if(type != GameFieldType::CHOICE && !values.empty())
+    {
+        return std::unexpected(mw::Error(GameDefinitionValidationError{
+            "choice", "Only choice fields can define choices"}));
+    }
+    if(type == GameFieldType::CHOICE && values.empty())
+    {
+        return std::unexpected(mw::Error(GameDefinitionValidationError{
+            "choice", "A choice field requires at least one choice"}));
+    }
+    std::unordered_set<std::string> unique;
+    std::vector<GameChoice> choices;
+    choices.reserve(values.size());
+    for(std::size_t position = 0; position < values.size(); ++position)
+    {
+        const std::string& value = values[position];
+        if(value.empty() || !validGameText(value))
+        {
+            return std::unexpected(mw::Error(GameDefinitionValidationError{
+                "choice", "Choices must be nonempty UTF-8 text"}));
+        }
+        if(!unique.insert(value).second)
+        {
+            return std::unexpected(mw::httpError(
+                409, "Choice values must be unique"));
+        }
+        choices.push_back({value, static_cast<std::int64_t>(position)});
+    }
+    return choices;
+}
+
+const GameField* findField(
+    const GameDefinitionSnapshot& definition, std::int64_t field_id)
+{
+    const auto field = std::ranges::find_if(
+        definition.fields,
+        [field_id](const GameField& candidate)
+        {
+            return candidate.id == field_id;
+        });
+    return field == definition.fields.end() ? nullptr : &*field;
+}
+
+mw::E<GameDefinitionSnapshot> readDefinition(
+    DataSourceTransactionInterface& transaction,
+    const std::string& short_name,
+    std::int64_t expected_revision)
+{
+    auto definition = transaction.getGameDefinitionForUpdate(short_name);
+    if(!definition)
+    {
+        return std::unexpected(std::move(definition.error()));
+    }
+    if(!*definition)
+    {
+        return std::unexpected(mw::httpError(404, "Game not found"));
+    }
+    if((**definition).game.revision != expected_revision)
+    {
+        return std::unexpected(mw::httpError(
+            409, "The game definition changed; reload and try again"));
+    }
+    return std::move(**definition);
+}
+
+mw::E<void> advanceRevision(
+    DataSourceTransactionInterface& transaction,
+    const std::string& short_name,
+    std::int64_t expected_revision)
+{
+    auto advanced = transaction.incrementGameRevision(
+        short_name, expected_revision);
+    if(!advanced)
+    {
+        return std::unexpected(std::move(advanced.error()));
+    }
+    if(!*advanced)
+    {
+        return std::unexpected(mw::httpError(
+            409, "The game definition changed; reload and try again"));
+    }
+    return {};
+}
+
+} // namespace
+
+GameService::GameService(DataSourceInterface& data_source)
+        : data_source_(data_source)
+{}
+
+mw::E<std::string> GameService::createGame(
+    std::int64_t actor_user_id,
+    std::string short_name,
+    std::string display_name,
+    std::string description)
+{
+    if(!validShortName(short_name))
+    {
+        return std::unexpected(mw::Error(GameDefinitionValidationError{
+            "short_name",
+            "Short name must contain lowercase letters and digits"}));
+    }
+    auto valid = validateDisplayText(
+        display_name, description, markdown_renderer_);
+    if(!valid)
+    {
+        return std::unexpected(std::move(valid.error()));
+    }
+    auto transaction = data_source_.beginTransaction();
+    if(!transaction)
+    {
+        return std::unexpected(std::move(transaction.error()));
+    }
+    auto authorized = authorizeAdministrator(
+        **transaction, authorization_, actor_user_id);
+    if(!authorized)
+    {
+        return std::unexpected(std::move(authorized.error()));
+    }
+    auto existing = (*transaction)->getGameDefinitionForUpdate(short_name);
+    if(!existing)
+    {
+        return std::unexpected(std::move(existing.error()));
+    }
+    if(*existing)
+    {
+        return std::unexpected(mw::httpError(
+            409, "A game with this short name already exists"));
+    }
+    auto inserted = (*transaction)->insertGame(
+        {short_name, std::move(display_name), std::move(description), 1});
+    if(!inserted)
+    {
+        return std::unexpected(std::move(inserted.error()));
+    }
+    auto committed = (*transaction)->commit();
+    if(!committed)
+    {
+        return std::unexpected(std::move(committed.error()));
+    }
+    return short_name;
+}
+
+mw::E<void> GameService::updateGame(
+    std::int64_t actor_user_id,
+    const std::string& short_name,
+    std::int64_t expected_revision,
+    std::string display_name,
+    std::string description)
+{
+    auto valid = validateDisplayText(
+        display_name, description, markdown_renderer_);
+    if(!valid)
+    {
+        return std::unexpected(std::move(valid.error()));
+    }
+    auto transaction = data_source_.beginTransaction();
+    if(!transaction)
+    {
+        return std::unexpected(std::move(transaction.error()));
+    }
+    auto authorized = authorizeAdministrator(
+        **transaction, authorization_, actor_user_id);
+    if(!authorized)
+    {
+        return std::unexpected(std::move(authorized.error()));
+    }
+    auto definition = readDefinition(
+        **transaction, short_name, expected_revision);
+    if(!definition)
+    {
+        return std::unexpected(std::move(definition.error()));
+    }
+    auto updated = (*transaction)->updateGame(
+        short_name, display_name, description, expected_revision);
+    if(!updated)
+    {
+        return std::unexpected(std::move(updated.error()));
+    }
+    if(!*updated)
+    {
+        return std::unexpected(mw::httpError(409, "Game update conflict"));
+    }
+    return (*transaction)->commit();
+}
+
+mw::E<void> GameService::removeGame(
+    std::int64_t actor_user_id,
+    const std::string& short_name,
+    std::int64_t expected_revision)
+{
+    auto transaction = data_source_.beginTransaction();
+    if(!transaction)
+    {
+        return std::unexpected(std::move(transaction.error()));
+    }
+    auto authorized = authorizeAdministrator(
+        **transaction, authorization_, actor_user_id);
+    if(!authorized)
+    {
+        return std::unexpected(std::move(authorized.error()));
+    }
+    auto definition = readDefinition(
+        **transaction, short_name, expected_revision);
+    if(!definition)
+    {
+        return std::unexpected(std::move(definition.error()));
+    }
+    auto usage = (*transaction)->getGameUsage(short_name);
+    if(!usage)
+    {
+        return std::unexpected(std::move(usage.error()));
+    }
+    if(usage->card_count != 0 || usage->series_count != 0 ||
+       usage->last_number != 0)
+    {
+        return std::unexpected(mw::httpError(
+            409,
+            "This game cannot be deleted because it has " +
+                std::to_string(usage->card_count) + " card(s), " +
+                std::to_string(usage->series_count) + " series, and has " +
+                "issued card number " + std::to_string(usage->last_number)));
+    }
+    auto deleted = (*transaction)->deleteGame(short_name);
+    if(!deleted)
+    {
+        return std::unexpected(std::move(deleted.error()));
+    }
+    return (*transaction)->commit();
+}
+
+mw::E<std::int64_t> GameService::createField(
+    std::int64_t actor_user_id,
+    const std::string& short_name,
+    std::int64_t expected_revision,
+    std::string key,
+    std::string label,
+    GameFieldType type,
+    std::vector<std::string> choice_values)
+{
+    label = std::string(mw::strip(label));
+    if(!validFieldKey(key))
+    {
+        return std::unexpected(mw::Error(GameDefinitionValidationError{
+            "key", "Field key is invalid"}));
+    }
+    if(label.empty() || !validGameText(label))
+    {
+        return std::unexpected(mw::Error(GameDefinitionValidationError{
+            "label", "Field label is invalid"}));
+    }
+    auto choices = validateChoices(type, choice_values);
+    if(!choices)
+    {
+        return std::unexpected(std::move(choices.error()));
+    }
+    auto transaction = data_source_.beginTransaction();
+    if(!transaction)
+    {
+        return std::unexpected(std::move(transaction.error()));
+    }
+    auto authorized = authorizeAdministrator(
+        **transaction, authorization_, actor_user_id);
+    if(!authorized)
+    {
+        return std::unexpected(std::move(authorized.error()));
+    }
+    auto definition = readDefinition(
+        **transaction, short_name, expected_revision);
+    if(!definition)
+    {
+        return std::unexpected(std::move(definition.error()));
+    }
+    if(std::ranges::any_of(
+           definition->fields,
+           [&key](const GameField& field) { return field.key == key; }))
+    {
+        return std::unexpected(mw::httpError(
+            409, "A field with this key already exists"));
+    }
+    GameField field{
+        0, short_name, std::move(key), std::move(label), type,
+        static_cast<std::int64_t>(definition->fields.size()), {}};
+    auto inserted = (*transaction)->insertGameField(field, *choices);
+    if(!inserted)
+    {
+        return std::unexpected(std::move(inserted.error()));
+    }
+    auto advanced = advanceRevision(
+        **transaction, short_name, expected_revision);
+    if(!advanced)
+    {
+        return std::unexpected(std::move(advanced.error()));
+    }
+    auto committed = (*transaction)->commit();
+    if(!committed)
+    {
+        return std::unexpected(std::move(committed.error()));
+    }
+    return *inserted;
+}
+
+mw::E<void> GameService::updateField(
+    std::int64_t actor_user_id,
+    const std::string& short_name,
+    std::int64_t field_id,
+    std::int64_t expected_revision,
+    std::string label,
+    std::vector<std::string> choice_values)
+{
+    label = std::string(mw::strip(label));
+    if(label.empty() || !validGameText(label))
+    {
+        return std::unexpected(mw::Error(GameDefinitionValidationError{
+            "label", "Field label is invalid"}));
+    }
+    auto transaction = data_source_.beginTransaction();
+    if(!transaction)
+    {
+        return std::unexpected(std::move(transaction.error()));
+    }
+    auto authorized = authorizeAdministrator(
+        **transaction, authorization_, actor_user_id);
+    if(!authorized)
+    {
+        return std::unexpected(std::move(authorized.error()));
+    }
+    auto definition = readDefinition(
+        **transaction, short_name, expected_revision);
+    if(!definition)
+    {
+        return std::unexpected(std::move(definition.error()));
+    }
+    const GameField* field = findField(*definition, field_id);
+    if(field == nullptr)
+    {
+        return std::unexpected(mw::httpError(404, "Game field not found"));
+    }
+    auto choices = validateChoices(field->type, choice_values);
+    if(!choices)
+    {
+        return std::unexpected(std::move(choices.error()));
+    }
+    for(const GameChoice& existing : field->choices)
+    {
+        const bool retained = std::ranges::any_of(
+            *choices,
+            [&existing](const GameChoice& choice)
+            {
+                return choice.value == existing.value;
+            });
+        if(!retained)
+        {
+            auto count = (*transaction)->countChoiceUsage(
+                field_id, existing.value);
+            if(!count)
+            {
+                return std::unexpected(std::move(count.error()));
+            }
+            if(*count != 0)
+            {
+                return std::unexpected(mw::httpError(
+                    409,
+                    "This choice cannot be deleted because " +
+                        std::to_string(*count) + " card(s) use it"));
+            }
+        }
+    }
+    auto updated = (*transaction)->updateGameField(
+        field_id, label, *choices);
+    if(!updated)
+    {
+        return std::unexpected(std::move(updated.error()));
+    }
+    auto advanced = advanceRevision(
+        **transaction, short_name, expected_revision);
+    if(!advanced)
+    {
+        return std::unexpected(std::move(advanced.error()));
+    }
+    return (*transaction)->commit();
+}
+
+mw::E<void> GameService::removeField(
+    std::int64_t actor_user_id,
+    const std::string& short_name,
+    std::int64_t field_id,
+    std::int64_t expected_revision)
+{
+    auto transaction = data_source_.beginTransaction();
+    if(!transaction)
+    {
+        return std::unexpected(std::move(transaction.error()));
+    }
+    auto authorized = authorizeAdministrator(
+        **transaction, authorization_, actor_user_id);
+    if(!authorized)
+    {
+        return std::unexpected(std::move(authorized.error()));
+    }
+    auto definition = readDefinition(
+        **transaction, short_name, expected_revision);
+    if(!definition)
+    {
+        return std::unexpected(std::move(definition.error()));
+    }
+    if(findField(*definition, field_id) == nullptr)
+    {
+        return std::unexpected(mw::httpError(404, "Game field not found"));
+    }
+    auto count = (*transaction)->countFieldUsage(field_id);
+    if(!count)
+    {
+        return std::unexpected(std::move(count.error()));
+    }
+    if(*count != 0)
+    {
+        return std::unexpected(mw::httpError(
+            409,
+            "This field cannot be deleted because " +
+                std::to_string(*count) + " card(s) use it"));
+    }
+    auto deleted = (*transaction)->deleteGameField(field_id);
+    if(!deleted)
+    {
+        return std::unexpected(std::move(deleted.error()));
+    }
+    auto advanced = advanceRevision(
+        **transaction, short_name, expected_revision);
+    if(!advanced)
+    {
+        return std::unexpected(std::move(advanced.error()));
+    }
+    return (*transaction)->commit();
+}
+
+mw::E<void> GameService::reorderFields(
+    std::int64_t actor_user_id,
+    const std::string& short_name,
+    std::int64_t expected_revision,
+    const std::vector<std::int64_t>& field_ids)
+{
+    auto transaction = data_source_.beginTransaction();
+    if(!transaction)
+    {
+        return std::unexpected(std::move(transaction.error()));
+    }
+    auto authorized = authorizeAdministrator(
+        **transaction, authorization_, actor_user_id);
+    if(!authorized)
+    {
+        return std::unexpected(std::move(authorized.error()));
+    }
+    auto definition = readDefinition(
+        **transaction, short_name, expected_revision);
+    if(!definition)
+    {
+        return std::unexpected(std::move(definition.error()));
+    }
+    std::unordered_set<std::int64_t> requested(
+        field_ids.begin(), field_ids.end());
+    if(requested.size() != field_ids.size() ||
+       field_ids.size() != definition->fields.size() ||
+       std::ranges::any_of(
+           definition->fields,
+           [&requested](const GameField& field)
+           {
+               return !requested.contains(field.id);
+           }))
+    {
+        return std::unexpected(mw::httpError(422, "Invalid field order"));
+    }
+    auto reordered = (*transaction)->updateGameFieldOrder(
+        short_name, field_ids);
+    if(!reordered)
+    {
+        return std::unexpected(std::move(reordered.error()));
+    }
+    auto advanced = advanceRevision(
+        **transaction, short_name, expected_revision);
+    if(!advanced)
+    {
+        return std::unexpected(std::move(advanced.error()));
+    }
+    return (*transaction)->commit();
+}
diff --git a/src/game_service.h b/src/game_service.h
new file mode 100644
index 0000000..955afe3
--- /dev/null
+++ b/src/game_service.h
@@ -0,0 +1,88 @@
+#pragma once
+
+#include <cstdint>
+#include <string>
+#include <vector>
+
+#include <mw/error.hpp>
+
+#include "authorization.h"
+#include "data.h"
+#include "markdown_renderer.h"
+
+/// A game-definition validation failure addressable by an editor control.
+struct GameDefinitionValidationError
+{
+    /// Logical form field containing the invalid definition value.
+    std::string field_name;
+
+    /// Safe user-facing validation message.
+    std::string msg;
+};
+
+/// Validate and coordinate database-defined game mutations.
+class GameService
+{
+public:
+    /// Construct a game service over the application persistence boundary.
+    explicit GameService(DataSourceInterface& data_source);
+
+    /// Create a game and return its permanent short name.
+    mw::E<std::string> createGame(
+        std::int64_t actor_user_id,
+        std::string short_name,
+        std::string display_name,
+        std::string description);
+
+    /// Update a game's mutable presentation metadata.
+    mw::E<void> updateGame(
+        std::int64_t actor_user_id,
+        const std::string& short_name,
+        std::int64_t expected_revision,
+        std::string display_name,
+        std::string description);
+
+    /// Delete a game only when it has never issued a card number.
+    mw::E<void> removeGame(
+        std::int64_t actor_user_id,
+        const std::string& short_name,
+        std::int64_t expected_revision);
+
+    /// Add a custom field and return its internal identity.
+    mw::E<std::int64_t> createField(
+        std::int64_t actor_user_id,
+        const std::string& short_name,
+        std::int64_t expected_revision,
+        std::string key,
+        std::string label,
+        GameFieldType type,
+        std::vector<std::string> choices);
+
+    /// Update a field label and its complete ordered choice list.
+    mw::E<void> updateField(
+        std::int64_t actor_user_id,
+        const std::string& short_name,
+        std::int64_t field_id,
+        std::int64_t expected_revision,
+        std::string label,
+        std::vector<std::string> choices);
+
+    /// Delete an unused custom field.
+    mw::E<void> removeField(
+        std::int64_t actor_user_id,
+        const std::string& short_name,
+        std::int64_t field_id,
+        std::int64_t expected_revision);
+
+    /// Save a complete ordering of a game's custom fields.
+    mw::E<void> reorderFields(
+        std::int64_t actor_user_id,
+        const std::string& short_name,
+        std::int64_t expected_revision,
+        const std::vector<std::int64_t>& field_ids);
+
+private:
+    DataSourceInterface& data_source_;
+    AuthorizationService authorization_;
+    MarkdownRenderer markdown_renderer_;
+};
diff --git a/src/going_home.h b/src/going_home.h
deleted file mode 100644
index 0c5b66a..0000000
--- a/src/going_home.h
+++ /dev/null
@@ -1,98 +0,0 @@
-#pragma once
-
-#include "game_definition.h"
-
-/// Going Home uses the standard card fields without additional metadata.
-class GoingHome final : public GameDefinition
-{
-public:
-    /// Return the permanent public card-ID prefix.
-    std::string_view shortName() const override
-    {
-        return "gh";
-    }
-
-    /// Return the name displayed in game and series selectors.
-    std::string_view displayName() const override
-    {
-        return "Going Home";
-    }
-
-    /// No game description has been supplied yet.
-    std::string_view description() const override
-    {
-        return "";
-    }
-
-    /// Standard card storage is sufficient for this game.
-    mw::E<void> createSchema(
-        [[maybe_unused]] mw::SQLite& database) const override
-    {
-        return {};
-    }
-
-    /// Accept an empty set of game-specific fields.
-    mw::E<std::unique_ptr<GameCardMetadata>> validateMetadata(
-        const FormFields& fields) const override
-    {
-        if(!fields.empty())
-        {
-            return std::unexpected(mw::httpError(
-                422, "Going Home has no additional card fields"));
-        }
-        return std::make_unique<Metadata>();
-    }
-
-    /// No extra controls are needed on the card form.
-    std::vector<GameFormField> formFields() const override
-    {
-        return {};
-    }
-
-    /// There are no extra values to load when editing a card.
-    mw::E<FormFields> formValues(
-        [[maybe_unused]] mw::SQLite& database,
-        [[maybe_unused]] std::int64_t card_id) const override
-    {
-        return FormFields{};
-    }
-
-    /// Validate metadata ownership without creating extension rows.
-    mw::E<void> insertMetadata(
-        [[maybe_unused]] mw::SQLite& database,
-        [[maybe_unused]] std::int64_t card_id,
-        const GameCardMetadata& metadata) const override
-    {
-        return checkMetadata(metadata);
-    }
-
-    /// Validate metadata ownership without updating extension rows.
-    mw::E<void> updateMetadata(
-        [[maybe_unused]] mw::SQLite& database,
-        [[maybe_unused]] std::int64_t card_id,
-        const GameCardMetadata& metadata) const override
-    {
-        return checkMetadata(metadata);
-    }
-
-    /// Standard card details contain all the information for this game.
-    mw::E<std::vector<DisplayField>> displayFields(
-        [[maybe_unused]] mw::SQLite& database,
-        [[maybe_unused]] std::int64_t card_id) const override
-    {
-        return std::vector<DisplayField>{};
-    }
-
-private:
-    struct Metadata final : public GameCardMetadata {};
-
-    static mw::E<void> checkMetadata(const GameCardMetadata& metadata)
-    {
-        if(dynamic_cast<const Metadata*>(&metadata) == nullptr)
-        {
-            return std::unexpected(mw::runtimeError(
-                "Going Home received metadata of the wrong type"));
-        }
-        return {};
-    }
-};
diff --git a/src/main.cpp b/src/main.cpp
index 82cdbb9..001282a 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -13,8 +13,6 @@
 
 #include "app.h"
 #include "clock.h"
-#include "game_registry.h"
-#include "going_home.h"
 #include "startup.h"
 
 namespace
@@ -110,21 +108,12 @@ int main(int argc, char** argv)
         una::version::unicode.minor(),
         una::version::unicode.update());
 
-    auto games = std::make_unique<GameRegistry>();
-    auto registered = games->add(std::make_unique<GoingHome>());
-    if(!registered)
-    {
-        spdlog::error(
-            "Failed to register Going Home: {}", registered.error().msg());
-        return 1;
-    }
     const auto startup_time = std::chrono::system_clock::now();
     const std::int64_t startup_seconds =
         std::chrono::duration_cast<std::chrono::seconds>(
             startup_time.time_since_epoch()).count();
     auto data_source = prepareDataSource(
         config->database_path,
-        *games,
         config->administrator_email,
         config->administrator_email_key,
         startup_seconds,
@@ -152,7 +141,6 @@ int main(int argc, char** argv)
         App app(
             *config,
             std::move(*data_source),
-            std::move(games),
             std::make_unique<NonSecretRandom>());
 
         std::signal(SIGINT, handleSignal);
diff --git a/src/multipart_reader.cpp b/src/multipart_reader.cpp
index 4744426..145494f 100644
--- a/src/multipart_reader.cpp
+++ b/src/multipart_reader.cpp
@@ -13,10 +13,11 @@
 #include <unordered_set>
 #include <utility>
 
+#include "form_limits.h"
+
 namespace
 {
 
-inline constexpr std::size_t MAX_TEXT_FIELD_SIZE = 1024 * 1024;
 inline constexpr std::size_t MAX_IMAGE_SIZE = 32 * 1024 * 1024;
 inline constexpr std::size_t MAX_TOTAL_IMAGE_SIZE = 72 * 1024 * 1024;
 
@@ -40,6 +41,7 @@ bool isTextField(std::string_view name)
         "front_action",
         "foil_action",
         "revision",
+        "game_revision",
         "series_id",
         "csrf_token",
     };
@@ -135,6 +137,7 @@ mw::E<CardUpload> MultipartReader::read(
     bool ignore_part = false;
     std::size_t part_size = 0;
     std::size_t total_image_size = 0;
+    std::size_t total_text_size = 0;
     std::string error_message;
     int error_status = 400;
 
@@ -226,12 +229,19 @@ mw::E<CardUpload> MultipartReader::read(
             }
             if(text_value != nullptr)
             {
-                if(part_size > MAX_TEXT_FIELD_SIZE)
+                total_text_size += size;
+                if(part_size > MAX_FORM_TEXT_FIELD_SIZE)
                 {
                     error_message = "A form field is too large";
                     error_status = 413;
                     return false;
                 }
+                if(total_text_size > MAX_FORM_TOTAL_TEXT_SIZE)
+                {
+                    error_message = "Card metadata is too large";
+                    error_status = 413;
+                    return false;
+                }
                 text_value->append(data, size);
                 return true;
             }
diff --git a/src/series_service.cpp b/src/series_service.cpp
index 50fd71c..a85b201 100644
--- a/src/series_service.cpp
+++ b/src/series_service.cpp
@@ -55,11 +55,8 @@ mw::E<void> authorizeAdministrator(
 
 } // namespace
 
-SeriesService::SeriesService(
-    DataSourceInterface& data_source,
-    const GameRegistry& games)
-        : data_source_(data_source),
-          games_(games)
+SeriesService::SeriesService(DataSourceInterface& data_source)
+        : data_source_(data_source)
 {}
 
 mw::E<std::int64_t> SeriesService::create(
@@ -69,10 +66,6 @@ mw::E<std::int64_t> SeriesService::create(
     std::string description)
 {
     name = std::string(mw::strip(name));
-    if(games_.find(game_short_name) == nullptr)
-    {
-        return std::unexpected(mw::httpError(422, "Unknown compiled game"));
-    }
     if(name.empty())
     {
         return std::unexpected(mw::httpError(422, "Series name is required"));
@@ -108,6 +101,16 @@ mw::E<std::int64_t> SeriesService::create(
     {
         return std::unexpected(std::move(authorized.error()));
     }
+    auto game = (*transaction)->getGameDefinitionForUpdate(
+        series.game_short_name);
+    if(!game)
+    {
+        return std::unexpected(std::move(game.error()));
+    }
+    if(!*game)
+    {
+        return std::unexpected(mw::httpError(422, "Unknown game"));
+    }
     auto id = (*transaction)->insertSeries(series);
     if(!id)
     {
diff --git a/src/series_service.h b/src/series_service.h
index c05da3c..1733197 100644
--- a/src/series_service.h
+++ b/src/series_service.h
@@ -7,7 +7,6 @@
 
 #include "authorization.h"
 #include "data.h"
-#include "game_registry.h"
 #include "markdown_renderer.h"
 
 /// Validate and coordinate dynamic series mutations.
@@ -15,9 +14,7 @@ class SeriesService
 {
 public:
     /// Construct a series service from its persistence and game boundaries.
-    SeriesService(
-        DataSourceInterface& data_source,
-        const GameRegistry& games);
+    explicit SeriesService(DataSourceInterface& data_source);
 
     /// Create a series and return its internal ID.
     mw::E<std::int64_t> create(
@@ -40,7 +37,6 @@ public:
 
 private:
     DataSourceInterface& data_source_;
-    const GameRegistry& games_;
     AuthorizationService authorization_;
     MarkdownRenderer markdown_renderer_;
 };
diff --git a/src/startup.cpp b/src/startup.cpp
index e66d64c..8bc73ce 100644
--- a/src/startup.cpp
+++ b/src/startup.cpp
@@ -5,12 +5,8 @@
 #include <spdlog/spdlog.h>
 
 #include "data_sqlite.h"
-#include "game_definition.h"
-#include "game_registry.h"
-
 mw::E<std::unique_ptr<DataSourceInterface>> prepareDataSource(
     const std::filesystem::path& database_path,
-    const GameRegistry& games,
     const std::string& administrator_email,
     const std::string& administrator_email_key,
     std::int64_t now,
@@ -21,11 +17,16 @@ mw::E<std::unique_ptr<DataSourceInterface>> prepareDataSource(
     {
         return std::unexpected(std::move(data_source.error()));
     }
-    auto migration_result = (*data_source)->migrateToLatest(games);
+    auto migration_result = (*data_source)->migrateToLatest();
     if(!migration_result)
     {
         return std::unexpected(std::move(migration_result.error()));
     }
+    auto integrity_result = (*data_source)->checkIntegrity();
+    if(!integrity_result)
+    {
+        return std::unexpected(std::move(integrity_result.error()));
+    }
 
     auto administrator = (*data_source)->reconcileAdministrator(
         administrator_email,
@@ -44,39 +45,5 @@ mw::E<std::unique_ptr<DataSourceInterface>> prepareDataSource(
             cleaned.error().msg());
     }
 
-    auto transaction = (*data_source)->beginTransaction();
-    if(!transaction)
-    {
-        return std::unexpected(std::move(transaction.error()));
-    }
-    for(const GameDefinition* game : games.games())
-    {
-        auto ensured = (*transaction)->ensureGameSequence(
-            std::string(game->shortName()));
-        if(!ensured)
-        {
-            return std::unexpected(std::move(ensured.error()));
-        }
-    }
-    auto committed = (*transaction)->commit();
-    if(!committed)
-    {
-        return std::unexpected(std::move(committed.error()));
-    }
-
-    auto persisted_games = (*data_source)->getPersistedGameNames();
-    if(!persisted_games)
-    {
-        return std::unexpected(std::move(persisted_games.error()));
-    }
-    for(const std::string& name : *persisted_games)
-    {
-        if(games.find(name) == nullptr)
-        {
-            return std::unexpected(mw::runtimeError(
-                "Database uses unregistered compiled game '" + name +
-                "'; a schema migration is required"));
-        }
-    }
     return std::move(*data_source);
 }
diff --git a/src/startup.h b/src/startup.h
index 2f20502..6114313 100644
--- a/src/startup.h
+++ b/src/startup.h
@@ -9,10 +9,9 @@
 
 #include "data.h"
 
-/// Open and migrate the application data source before server startup.
+/// Open, migrate, and reconcile the data source before server startup.
 mw::E<std::unique_ptr<DataSourceInterface>> prepareDataSource(
     const std::filesystem::path& database_path,
-    const GameRegistry& games,
     const std::string& administrator_email = "admin@example.com",
     const std::string& administrator_email_key = "admin@example.com",
     std::int64_t now = 0,
diff --git a/static/card_form.js b/static/card_form.js
index 4f56ae7..7733c59 100644
--- a/static/card_form.js
+++ b/static/card_form.js
@@ -61,22 +61,24 @@ function updateSourceMode()
     foil_url.required = state.foil_url_enabled && state.foil_required;
 }
 
-/** Show only series belonging to the selected compiled game. */
+/** Show controls and series belonging to the selected database game. */
 function updateSeriesChoices()
 {
     const game = document.getElementById("CardGame").value;
     for(const group of document.querySelectorAll(".game-fields"))
     {
-        const visible = group.dataset.game == game;
+        const visible = window.cardFormLogic.gameControlEnabled(
+            game, group.dataset.game);
         group.hidden = !visible;
-        for(const input of group.querySelectorAll("input"))
+        for(const input of group.querySelectorAll("input, textarea, select"))
         {
             input.disabled = !visible;
         }
     }
     for(const choice of document.querySelectorAll(".series-choice"))
     {
-        const visible = game != "" && choice.dataset.game == game;
+        const visible = window.cardFormLogic.seriesChoiceEnabled(
+            game, choice.dataset.game);
         choice.hidden = !visible;
         choice.querySelector("input").disabled = !visible;
     }
diff --git a/static/card_form_logic.js b/static/card_form_logic.js
index aaadb16..52605e0 100644
--- a/static/card_form_logic.js
+++ b/static/card_form_logic.js
@@ -50,10 +50,24 @@
         };
     }
 
+    /** Return whether controls for one game belong to the selection. */
+    function gameControlEnabled(selected_game, control_game)
+    {
+        return selected_game == control_game;
+    }
+
+    /** Return whether a series choice belongs to the selected game. */
+    function seriesChoiceEnabled(selected_game, series_game)
+    {
+        return selected_game != "" && selected_game == series_game;
+    }
+
     const api = {
         validRemoteImageUrl,
         foilThumbnailRequired,
         sourceInputState,
+        gameControlEnabled,
+        seriesChoiceEnabled,
     };
     if(typeof module != "undefined" && module.exports != null)
     {
diff --git a/static/css/styles.css b/static/css/styles.css
index 59273f9..aabdc6d 100644
--- a/static/css/styles.css
+++ b/static/css/styles.css
@@ -591,6 +591,10 @@ h1 {
     font-weight: 800;
 }
 
+.game-field-value {
+    white-space: pre-wrap;
+}
+
 .card-info-section {
     margin-top: 2rem;
     padding-top: 1.75rem;
@@ -652,6 +656,10 @@ h1 {
     border-top: 1px solid rgb(99 95 105 / 12%);
 }
 
+.form-section[hidden] {
+    display: none;
+}
+
 .form-section h2,
 .form-section legend {
     margin: 0;
@@ -1093,6 +1101,18 @@ h1 {
     color: var(--violet);
 }
 
+.clay-action-danger {
+    background: linear-gradient(145deg, #fb7185, #be123c);
+    color: #ffffff;
+}
+
+.clay-action:disabled {
+    box-shadow: var(--clay-pressed-shadow);
+    cursor: not-allowed;
+    opacity: 0.48;
+    transform: none;
+}
+
 .welcome-preview {
     position: relative;
     display: grid;
@@ -1799,35 +1819,6 @@ h1 {
     width: 100%;
 }
 
-.compiled-game-note {
-    display: flex;
-    align-items: center;
-    gap: 1.25rem;
-    margin-bottom: 2.5rem;
-    padding: 1.25rem 1.5rem;
-    border: 1px solid rgb(255 255 255 / 82%);
-    border-radius: 2rem;
-    background: rgb(255 255 255 / 70%);
-    box-shadow: var(--clay-card-shadow);
-    backdrop-filter: blur(1.25rem);
-}
-
-.compiled-game-note .clay-icon {
-    margin: 0;
-}
-
-.compiled-game-note strong {
-    font-family: Nunito, ui-rounded, sans-serif;
-    font-size: 1.1rem;
-    font-weight: 900;
-}
-
-.compiled-game-note p {
-    max-width: 56rem;
-    margin: 0.1rem 0 0;
-    color: var(--muted);
-}
-
 .game-admin-grid {
     display: grid;
     grid-template-columns: repeat(auto-fit, minmax(22rem, 1fr));
@@ -1940,6 +1931,148 @@ h1 {
     text-transform: uppercase;
 }
 
+.game-editor {
+    width: 100%;
+}
+
+.clay-form-panel,
+.definition-panel {
+    padding: clamp(1.5rem, 4vw, 2.5rem);
+    border: 1px solid rgb(255 255 255 / 82%);
+    border-radius: 2.5rem;
+    background: rgb(255 255 255 / 70%);
+    box-shadow: var(--clay-card-shadow);
+    backdrop-filter: blur(1.25rem);
+}
+
+.clay-form-panel {
+    width: min(52rem, 100%);
+}
+
+.definition-panel {
+    margin-top: 2.5rem;
+}
+
+.section-heading,
+.definition-list > li,
+.definition-actions,
+.choice-row {
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    gap: 1rem;
+}
+
+.section-heading h2,
+.definition-copy h3 {
+    margin: 0;
+    font-family: Nunito, ui-rounded, sans-serif;
+    font-weight: 900;
+}
+
+.definition-list {
+    display: grid;
+    gap: 1rem;
+    margin: 1.75rem 0 0;
+    padding: 0;
+    list-style: none;
+}
+
+.definition-list > li {
+    padding: 1.25rem;
+    border-radius: 1.75rem;
+    background: #efebf5;
+    box-shadow: var(--clay-pressed-shadow);
+}
+
+.definition-copy p {
+    margin: 0;
+    color: var(--muted);
+}
+
+.definition-actions {
+    flex-wrap: wrap;
+    justify-content: flex-end;
+}
+
+.definition-actions form {
+    margin: 0;
+}
+
+.definition-actions .clay-action,
+.choice-row .clay-action {
+    min-height: 2.75rem;
+    padding: 0.55rem 0.9rem;
+}
+
+.choice-rows {
+    display: grid;
+    gap: 0.85rem;
+    margin-bottom: 1rem;
+}
+
+.choice-row input {
+    min-width: 10rem;
+    min-height: 3.25rem;
+    flex: 1;
+    padding: 0.75rem 1rem;
+    border: 0;
+    border-radius: 1.15rem;
+    background: #efebf5;
+    box-shadow:
+        inset 7px 7px 15px rgb(185 177 198 / 55%),
+        inset -7px -7px 15px rgb(255 255 255 / 90%);
+    color: var(--foreground);
+    font: inherit;
+    font-weight: 600;
+}
+
+.choice-row input:focus {
+    background: rgb(255 255 255 / 84%);
+    outline: 0.25rem solid rgb(124 58 237 / 20%);
+}
+
+.form-tail-actions {
+    margin-top: 2rem;
+    margin-bottom: 0;
+}
+
+.mutation-error-page {
+    width: min(56rem, 100%);
+}
+
+.mutation-error-page h1 {
+    margin-bottom: 2rem;
+}
+
+.error-summary {
+    margin-bottom: 2rem;
+    padding: 1.5rem;
+    border: 2px solid rgb(190 24 93 / 28%);
+    border-radius: 1.75rem;
+    background: rgb(255 255 255 / 72%);
+    box-shadow: var(--clay-card-shadow);
+}
+
+.error-summary h2,
+.error-summary p {
+    margin-top: 0;
+}
+
+.error-summary p:last-child {
+    margin-bottom: 0;
+}
+
+.field-error {
+    color: #9d174d;
+    font-weight: 800;
+}
+
+[aria-invalid="true"] {
+    border-color: #be185d;
+    box-shadow: 0 0 0 0.2rem rgb(190 24 93 / 14%);
+}
+
 @media(max-width: 42rem) {
     .management-actions,
     .page-heading .management-actions {
@@ -1956,12 +2089,21 @@ h1 {
         grid-template-columns: 1fr;
     }
 
-    .compiled-game-note {
-        align-items: flex-start;
+    .game-admin-card {
+        border-radius: 2rem;
+    }
+
+    .section-heading,
+    .definition-list > li,
+    .choice-row {
+        align-items: stretch;
         flex-direction: column;
     }
 
-    .game-admin-card {
-        border-radius: 2rem;
+    .definition-actions,
+    .definition-actions form,
+    .definition-actions .clay-action,
+    .choice-row .clay-action {
+        width: 100%;
     }
 }
diff --git a/static/game_field_form.js b/static/game_field_form.js
new file mode 100644
index 0000000..81392a5
--- /dev/null
+++ b/static/game_field_form.js
@@ -0,0 +1,108 @@
+/** Create one editable choice row. */
+function createChoiceRow()
+{
+    const row = document.createElement("div");
+    row.className = "choice-row";
+    row.innerHTML = `
+        <input name="choice" type="text" required>
+        <button class="clay-action clay-action-secondary move-up"
+                type="button">Move up</button>
+        <button class="clay-action clay-action-secondary move-down"
+                type="button">Move down</button>
+        <button class="clay-action clay-action-danger remove-choice"
+                type="button">Remove</button>`;
+    return row;
+}
+
+/** Return the bounded destination for one choice movement. */
+function choiceMoveIndex(index, direction, count)
+{
+    if(count <= 0 || index < 0 || index >= count)
+    {
+        return index;
+    }
+    return Math.max(0, Math.min(count - 1, index + direction));
+}
+
+/** Update choice-editor visibility and button states. */
+function updateChoiceEditor()
+{
+    const type = document.getElementById("FieldType");
+    const is_choice = type.value == "CHOICE" ||
+        type.dataset.fieldType == "CHOICE";
+    const editor = document.getElementById("ChoiceEditor");
+    editor.hidden = !is_choice;
+    for(const input of editor.querySelectorAll("input"))
+    {
+        input.disabled = !is_choice;
+    }
+    const rows = [...document.querySelectorAll(".choice-row")];
+    for(let index = 0; index < rows.length; ++index)
+    {
+        rows[index].querySelector(".move-up").disabled = index == 0;
+        rows[index].querySelector(".move-down").disabled =
+            index + 1 == rows.length;
+    }
+}
+
+/** Handle add, remove, and reorder controls for choice rows. */
+function handleChoiceAction(event)
+{
+    const button = event.target.closest("button");
+    if(button == null)
+    {
+        return;
+    }
+    const rows = document.getElementById("ChoiceRows");
+    if(button.id == "AddChoice")
+    {
+        const row = createChoiceRow();
+        rows.append(row);
+        row.querySelector("input").focus();
+    }
+    else
+    {
+        const row = button.closest(".choice-row");
+        if(button.classList.contains("remove-choice"))
+        {
+            const focus_target = row.nextElementSibling ??
+                row.previousElementSibling;
+            row.remove();
+            focus_target?.querySelector("input").focus();
+        }
+        else
+        {
+            const current_rows = [...rows.querySelectorAll(".choice-row")];
+            const index = current_rows.indexOf(row);
+            const direction = button.classList.contains("move-up")
+                ? -1
+                : button.classList.contains("move-down") ? 1 : 0;
+            const target = choiceMoveIndex(
+                index, direction, current_rows.length);
+            if(target < index)
+            {
+                rows.insertBefore(row, current_rows[target]);
+                button.focus();
+            }
+            else if(target > index)
+            {
+                rows.insertBefore(row, current_rows[target].nextSibling);
+                button.focus();
+            }
+        }
+    }
+    updateChoiceEditor();
+}
+
+if(typeof document != "undefined")
+{
+    document.getElementById("GameFieldForm").addEventListener(
+        "click", handleChoiceAction);
+    document.getElementById("FieldType").addEventListener(
+        "change", updateChoiceEditor);
+    updateChoiceEditor();
+}
+if(typeof module != "undefined" && module.exports != null)
+{
+    module.exports = {choiceMoveIndex};
+}
diff --git a/templates/card_form.html b/templates/card_form.html
index b9d9d0d..da7eda4 100644
--- a/templates/card_form.html
+++ b/templates/card_form.html
@@ -18,6 +18,22 @@
         <p class="card-view-id">{{ display_id }}</p>
         <h1 id="CardFormHeading">{{ heading }}</h1>
 
+        {% if has_errors %}
+        <div class="error-summary" role="alert" tabindex="-1">
+            <h2>Check your card details</h2>
+            <p>
+                {% if length(error_control_id) > 0 %}
+                <a href="#{{ error_control_id }}">{{ error_message }}</a>
+                {% else %}
+                {{ error_message }}
+                {% endif %}
+            </p>
+            {% if files_need_reselection %}
+            <p>Uploaded files were discarded and must be selected again.</p>
+            {% endif %}
+        </div>
+        {% endif %}
+
         <form id="CardForm" class="card-form" method="post"
               action="{{ action_url }}" enctype="multipart/form-data">
             <input name="csrf_token" type="hidden" value="{{ csrf_token }}">
@@ -64,19 +80,59 @@
                       data-game="{{ game.short_name }}"
                       {% if game.short_name != selected_game %}hidden{% endif %}>
                 <legend>{{ game.display_name }} details</legend>
+                <input name="game_revision" type="hidden"
+                       value="{{ game.revision }}"
+                       {% if game.short_name != selected_game %}
+                       disabled
+                       {% endif %}>
                 {% for field in game.fields %}
-                <label class="form-field">
+                <label class="form-field" for="{{ field.control_id }}">
                     <span>{{ field.label }}</span>
-                    <input name="game.{{ field.name }}"
-                           type="{{ field.input_type }}"
-                           {% if length(field.minimum) > 0 %}
-                           min="{{ field.minimum }}"
+                    {% if field.is_string %}
+                    <textarea id="{{ field.control_id }}"
+                           name="game.{{ field.key }}" rows="4"
+                           {% if field.has_error %}
+                           aria-invalid="true"
+                           aria-describedby="{{ field.error_id }}"
+                           {% endif %}
+                           {% if game.short_name != selected_game %}
+                           disabled
+                           {% endif %}>{{ field.value }}</textarea>
+                    {% else if field.is_choice %}
+                    <select id="{{ field.control_id }}"
+                            name="game.{{ field.key }}"
+                            {% if field.has_error %}
+                            aria-invalid="true"
+                            aria-describedby="{{ field.error_id }}"
+                            {% endif %}
+                            {% if game.short_name != selected_game %}
+                            disabled
+                            {% endif %}>
+                        <option value="">Not set</option>
+                        {% for choice in field.choices %}
+                        <option value="{{ choice }}"
+                                {% if choice == field.value %}selected{% endif %}>
+                            {{ choice }}
+                        </option>
+                        {% endfor %}
+                    </select>
+                    {% else %}
+                    <input id="{{ field.control_id }}"
+                           name="game.{{ field.key }}" type="text"
+                           inputmode="numeric" value="{{ field.value }}"
+                           {% if field.has_error %}
+                           aria-invalid="true"
+                           aria-describedby="{{ field.error_id }}"
                            {% endif %}
-                           {% if field.required %}required{% endif %}
-                           value="{{ field.value }}"
                            {% if game.short_name != selected_game %}
                            disabled
                            {% endif %}>
+                    {% endif %}
+                    {% if field.has_error %}
+                    <small id="{{ field.error_id }}" class="field-error">
+                        {{ field.error_message }}
+                    </small>
+                    {% endif %}
                 </label>
                 {% endfor %}
             </fieldset>
diff --git a/templates/card_view.html b/templates/card_view.html
index 786cc28..20e5453 100644
--- a/templates/card_view.html
+++ b/templates/card_view.html
@@ -54,7 +54,7 @@
             {% for field in game_fields %}
             <div>
                 <dt>{{ field.label }}</dt>
-                <dd>{{ field.value }}</dd>
+                <dd class="game-field-value">{{ field.value }}</dd>
             </div>
             {% endfor %}
         </dl>
diff --git a/templates/game_admin.html b/templates/game_admin.html
index ded4358..2dee589 100644
--- a/templates/game_admin.html
+++ b/templates/game_admin.html
@@ -8,30 +8,23 @@
             <h1 id="GamesHeading">Games</h1>
         </div>
         <div class="management-actions" aria-label="Game actions">
+            <a class="clay-action" href="{{ create_url }}">
+                Create game
+            </a>
             <a class="clay-action clay-action-secondary"
                href="{{ series_url }}">
                 Manage series
             </a>
-            <a class="clay-action" href="{{ create_card_url }}">
+            <a class="clay-action clay-action-secondary"
+               href="{{ create_card_url }}">
                 Create card
             </a>
         </div>
     </header>
 
-    <div class="compiled-game-note" role="note">
-        <div class="clay-icon clay-icon-blue" aria-hidden="true">⌘</div>
-        <div>
-            <strong>Games are installed with the application</strong>
-            <p>
-                Their fields and database schema are compiled definitions.
-                Deploy a code change to add or alter a game.
-            </p>
-        </div>
-    </div>
-
     {% if length(games) == 0 %}
     <div class="empty-panel">
-        <h2>No games installed</h2>
+        <h2>No games defined</h2>
         <p>Loose cards can still be created without a game definition.</p>
     </div>
     {% else %}
@@ -47,20 +40,28 @@
                     <h2>{{ game.display_name }}</h2>
                 </div>
             </header>
-            <p class="game-description">{{ game.description }}</p>
+            {% if game.has_description %}
+            <div class="game-description description-copy">
+                {{ game.description }}
+            </div>
+            {% endif %}
+            <div class="management-actions">
+                <a class="clay-action clay-action-secondary"
+                   href="{{ game.edit_url }}">Edit game</a>
+            </div>
             <div class="game-field-heading">
                 <strong>Card fields</strong>
                 <span>{{ game.field_count }}</span>
             </div>
             {% if length(game.fields) == 0 %}
-            <p class="muted-copy">This game has no additional fields.</p>
+            <p class="muted-copy">Uses standard card fields.</p>
             {% else %}
             <ul class="game-field-list">
                 {% for field in game.fields %}
                 <li>
                     <span>
                         <strong>{{ field.label }}</strong>
-                        <small>{{ field.name }}</small>
+                        <small>{{ field.key }}</small>
                     </span>
                     <span class="field-type">{{ field.input_type }}</span>
                 </li>
diff --git a/templates/game_delete.html b/templates/game_delete.html
new file mode 100644
index 0000000..37e2987
--- /dev/null
+++ b/templates/game_delete.html
@@ -0,0 +1,18 @@
+{% extends "layout.html" %}
+
+{% block content %}
+<section class="page-shell form-page" aria-labelledby="DeleteGameHeading">
+    <a class="back-link" href="{{ back_url }}">← Back to game</a>
+    <p class="eyebrow">Game administration</p>
+    <h1 id="DeleteGameHeading">Delete {{ display_name }}?</h1>
+    <p>This is available only before the game has issued any card numbers.</p>
+    <form class="standalone-form" method="post" action="{{ action_url }}">
+        <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
+        <input type="hidden" name="game_revision"
+               value="{{ game_revision }}">
+        <button class="form-submit destructive-submit" type="submit">
+            Delete game
+        </button>
+    </form>
+</section>
+{% endblock %}
diff --git a/templates/game_field_delete.html b/templates/game_field_delete.html
new file mode 100644
index 0000000..f104c6b
--- /dev/null
+++ b/templates/game_field_delete.html
@@ -0,0 +1,18 @@
+{% extends "layout.html" %}
+
+{% block content %}
+<section class="page-shell form-page" aria-labelledby="DeleteFieldHeading">
+    <a class="back-link" href="{{ back_url }}">← Back to field</a>
+    <p class="eyebrow">Game administration</p>
+    <h1 id="DeleteFieldHeading">Delete {{ field_label }}?</h1>
+    <p>A field with values on any card cannot be deleted.</p>
+    <form class="standalone-form" method="post" action="{{ action_url }}">
+        <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
+        <input type="hidden" name="game_revision"
+               value="{{ game_revision }}">
+        <button class="form-submit destructive-submit" type="submit">
+            Delete field
+        </button>
+    </form>
+</section>
+{% endblock %}
diff --git a/templates/game_field_form.html b/templates/game_field_form.html
new file mode 100644
index 0000000..6bf878d
--- /dev/null
+++ b/templates/game_field_form.html
@@ -0,0 +1,128 @@
+{% extends "layout.html" %}
+
+{% block content %}
+<section class="page-shell form-page" aria-labelledby="FieldFormHeading">
+    <a class="back-link" href="{{ back_url }}">← {{ game_name }}</a>
+    <p class="eyebrow">Game administration</p>
+    <h1 id="FieldFormHeading">{{ heading }}</h1>
+
+    {% if has_errors %}
+    <div class="error-summary" role="alert" tabindex="-1">
+        <h2>Check the field definition</h2>
+        <p><a href="#{{ error_control_id }}">{{ error_message }}</a></p>
+    </div>
+    {% endif %}
+
+    <form id="GameFieldForm" class="standalone-form" method="post"
+          action="{{ action_url }}">
+        <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
+        <input type="hidden" name="game_revision"
+               value="{{ game_revision }}">
+        <label class="form-field" for="FieldKey">
+            <span>Key</span>
+            <input id="FieldKey" {% if mode == "create" %}name="key"{% endif %}
+                   type="text" value="{{ key }}"
+                   pattern="[a-z][a-z0-9_]*" required
+                   {% if error_field == "key" %}
+                   aria-invalid="true" aria-describedby="FieldKeyError"
+                   {% endif %}
+                   {% if mode == "edit" %}readonly{% endif %}>
+            <small>Permanent form and storage identity.</small>
+            {% if error_field == "key" %}
+            <small id="FieldKeyError" class="field-error">
+                {{ error_message }}
+            </small>
+            {% endif %}
+        </label>
+        <label class="form-field" for="FieldLabel">
+            <span>Label</span>
+            <input id="FieldLabel" name="label" type="text"
+                   value="{{ label }}" required
+                   {% if error_field == "label" %}
+                   aria-invalid="true" aria-describedby="FieldLabelError"
+                   {% endif %}>
+            {% if error_field == "label" %}
+            <small id="FieldLabelError" class="field-error">
+                {{ error_message }}
+            </small>
+            {% endif %}
+        </label>
+        <label class="form-field" for="FieldType">
+            <span>Type</span>
+            {% if mode == "create" %}
+            <select id="FieldType" name="type"
+                    {% if error_field == "type" %}
+                    aria-invalid="true" aria-describedby="FieldTypeError"
+                    {% endif %}>
+                <option value="INTEGER"
+                        {% if field_type == "INTEGER" %}selected{% endif %}>
+                    Integer
+                </option>
+                <option value="STRING"
+                        {% if field_type == "STRING" %}selected{% endif %}>
+                    String
+                </option>
+                <option value="CHOICE"
+                        {% if field_type == "CHOICE" %}selected{% endif %}>
+                    Choice
+                </option>
+            </select>
+            {% else %}
+            <input id="FieldType" type="text" value="{{ field_type }}"
+                   readonly data-field-type="{{ field_type }}">
+            {% endif %}
+            {% if error_field == "type" %}
+            <small id="FieldTypeError" class="field-error">
+                {{ error_message }}
+            </small>
+            {% endif %}
+        </label>
+
+        <fieldset id="ChoiceEditor" class="form-section"
+                  {% if error_field == "choice" %}
+                  aria-invalid="true" aria-describedby="ChoiceEditorError"
+                  {% endif %}
+                  {% if field_type != "CHOICE" %}hidden{% endif %}>
+            <legend>Choices</legend>
+            <p class="form-hint">
+                Values match exactly. Existing values are permanent while
+                cards use them.
+            </p>
+            <div id="ChoiceRows" class="choice-rows">
+                {% for choice in choices %}
+                <div class="choice-row">
+                    <input name="choice" type="text"
+                           value="{{ choice.value }}"
+                           {% if choice.existing %}readonly{% endif %} required>
+                    <button class="clay-action clay-action-secondary move-up"
+                            type="button">Move up</button>
+                    <button class="clay-action clay-action-secondary move-down"
+                            type="button">Move down</button>
+                    <button class="clay-action clay-action-danger remove-choice"
+                            type="button">Remove</button>
+                </div>
+                {% endfor %}
+            </div>
+            <button id="AddChoice" class="clay-action clay-action-secondary"
+                    type="button">Add choice</button>
+            {% if error_field == "choice" %}
+            <p id="ChoiceEditorError" class="field-error">
+                {{ error_message }}
+            </p>
+            {% endif %}
+        </fieldset>
+
+        <button class="form-submit" type="submit">{{ submit_label }}</button>
+    </form>
+    {% if mode == "edit" %}
+    <div class="management-actions form-tail-actions">
+        <a class="clay-action clay-action-danger"
+           href="{{ delete_url }}">Delete field</a>
+    </div>
+    {% endif %}
+</section>
+{% endblock %}
+
+{% block scripts %}
+<script src="{{ url_for("static", "game_field_form.js") }}"></script>
+{% endblock %}
diff --git a/templates/game_form.html b/templates/game_form.html
new file mode 100644
index 0000000..666a101
--- /dev/null
+++ b/templates/game_form.html
@@ -0,0 +1,150 @@
+{% extends "layout.html" %}
+
+{% block content %}
+<section class="game-editor" aria-labelledby="GameFormHeading">
+    <a class="back-link" href="{{ back_url }}">← All games</a>
+    <header class="page-heading">
+        <div>
+            <p class="eyebrow">Game administration</p>
+            <h1 id="GameFormHeading">{{ heading }}</h1>
+        </div>
+        {% if mode == "edit" %}
+        <div class="management-actions">
+            <a class="clay-action clay-action-secondary"
+               href="{{ add_field_url }}">Add field</a>
+            <a class="clay-action clay-action-danger"
+               href="{{ delete_url }}">Delete game</a>
+        </div>
+        {% endif %}
+    </header>
+
+    {% if has_errors %}
+    <div class="error-summary" role="alert" tabindex="-1">
+        <h2>Check the game details</h2>
+        <p><a href="#{{ error_control_id }}">{{ error_message }}</a></p>
+    </div>
+    {% endif %}
+
+    <form class="standalone-form clay-form-panel" method="post"
+          action="{{ action_url }}">
+        <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
+        {% if mode == "edit" %}
+        <input type="hidden" name="game_revision"
+               value="{{ game_revision }}">
+        {% endif %}
+        <label class="form-field" for="GameShortName">
+            <span>Short name</span>
+            <input id="GameShortName"
+                   {% if mode == "create" %}name="short_name"{% endif %}
+                   type="text" value="{{ short_name }}"
+                   pattern="[a-z0-9]+" required
+                   {% if error_field == "short_name" %}
+                   aria-invalid="true"
+                   aria-describedby="GameShortNameError"
+                   {% endif %}
+                   {% if mode == "edit" %}readonly{% endif %}>
+            <small>Permanent lowercase prefix used in card IDs.</small>
+            {% if error_field == "short_name" %}
+            <small id="GameShortNameError" class="field-error">
+                {{ error_message }}
+            </small>
+            {% endif %}
+        </label>
+        <label class="form-field" for="GameDisplayName">
+            <span>Display name</span>
+            <input id="GameDisplayName" name="display_name" type="text"
+                   value="{{ display_name }}" required
+                   {% if error_field == "display_name" %}
+                   aria-invalid="true"
+                   aria-describedby="GameDisplayNameError"
+                   {% endif %}>
+            {% if error_field == "display_name" %}
+            <small id="GameDisplayNameError" class="field-error">
+                {{ error_message }}
+            </small>
+            {% endif %}
+        </label>
+        <label class="form-field" for="GameDescription">
+            <span>Description <em>Optional</em></span>
+            <textarea id="GameDescription" name="description"
+                      rows="7"
+                      {% if error_field == "description" %}
+                      aria-invalid="true"
+                      aria-describedby="GameDescriptionError"
+                      {% endif %}>{{ description }}</textarea>
+            {% if error_field == "description" %}
+            <small id="GameDescriptionError" class="field-error">
+                {{ error_message }}
+            </small>
+            {% endif %}
+        </label>
+        <p class="form-hint">Descriptions support Markdown.</p>
+        <button class="form-submit" type="submit">{{ submit_label }}</button>
+    </form>
+
+    {% if mode == "edit" %}
+    <section class="definition-panel" aria-labelledby="FieldsHeading">
+        <div class="section-heading">
+            <div>
+                <p class="eyebrow">Card details</p>
+                <h2 id="FieldsHeading">Custom fields</h2>
+            </div>
+            <a class="clay-action" href="{{ add_field_url }}">Add field</a>
+        </div>
+        {% if length(fields) == 0 %}
+        <div class="empty-panel">
+            <h2>Uses standard card fields</h2>
+            <p>Add a custom field when this game needs extra card details.</p>
+        </div>
+        {% else %}
+        <ul class="definition-list">
+            {% for field in fields %}
+            <li>
+                <div class="definition-copy">
+                    <p class="eyebrow">{{ field.type }}</p>
+                    <h3>{{ field.label }}</h3>
+                    <p><code>{{ field.key }}</code>
+                       {% if field.choice_count > 0 %}
+                       · {{ field.choice_count }} choices
+                       {% endif %}</p>
+                </div>
+                <div class="definition-actions">
+                    <form method="post" action="{{ field_order_url }}">
+                        <input type="hidden" name="csrf_token"
+                               value="{{ csrf_token }}">
+                        <input type="hidden" name="game_revision"
+                               value="{{ game_revision }}">
+                        {% for id in field.up_order %}
+                        <input type="hidden" name="field_id" value="{{ id }}">
+                        {% endfor %}
+                        <button class="clay-action clay-action-secondary"
+                                type="submit" {% if field.is_first %}disabled{% endif %}>
+                            Move up
+                        </button>
+                    </form>
+                    <form method="post" action="{{ field_order_url }}">
+                        <input type="hidden" name="csrf_token"
+                               value="{{ csrf_token }}">
+                        <input type="hidden" name="game_revision"
+                               value="{{ game_revision }}">
+                        {% for id in field.down_order %}
+                        <input type="hidden" name="field_id" value="{{ id }}">
+                        {% endfor %}
+                        <button class="clay-action clay-action-secondary"
+                                type="submit" {% if field.is_last %}disabled{% endif %}>
+                            Move down
+                        </button>
+                    </form>
+                    <a class="clay-action clay-action-secondary"
+                       href="{{ field.edit_url }}">Edit field</a>
+                    <a class="clay-action clay-action-danger"
+                       href="{{ field.delete_url }}">Delete</a>
+                </div>
+            </li>
+            {% endfor %}
+        </ul>
+        {% endif %}
+    </section>
+    {% endif %}
+</section>
+{% endblock %}
diff --git a/templates/mutation_error.html b/templates/mutation_error.html
new file mode 100644
index 0000000..fff9415
--- /dev/null
+++ b/templates/mutation_error.html
@@ -0,0 +1,23 @@
+{% extends "layout.html" %}
+
+{% block content %}
+<section class="page-shell mutation-error-page"
+         aria-labelledby="MutationErrorHeading">
+    <p class="eyebrow">
+        {% if conflict %}Edit conflict{% else %}Unable to save{% endif %}
+    </p>
+    <h1 id="MutationErrorHeading">
+        {% if conflict %}The definition changed{% else %}Check your changes{% endif %}
+    </h1>
+    <div class="error-summary" role="alert" tabindex="-1">
+        <h2>Nothing was saved</h2>
+        <p>{{ message }}</p>
+        {% if files_need_reselection %}
+        <p>Uploaded files were discarded and must be selected again.</p>
+        {% endif %}
+    </div>
+    <a class="clay-action" href="{{ reload_url }}">
+        Reload the editor
+    </a>
+</section>
+{% endblock %}
diff --git a/templates/series_index.html b/templates/series_index.html
index 6b94380..7c57c0e 100644
--- a/templates/series_index.html
+++ b/templates/series_index.html
@@ -20,7 +20,7 @@
         {% if can_create %}
         <p>Create a series to organize cards within a game.</p>
         {% else %}
-        <p>A compiled game must be registered before creating series.</p>
+        <p>Create a game before adding a series.</p>
         {% endif %}
     </div>
     {% else %}
diff --git a/tests/app_integration_test.cpp b/tests/app_integration_test.cpp
index 92c767e..f546e42 100644
--- a/tests/app_integration_test.cpp
+++ b/tests/app_integration_test.cpp
@@ -12,11 +12,8 @@
 #include <gtest/gtest.h>
 #include <httplib.h>
 
-#include "game_registry.h"
-#include "going_home.h"
 #include "non_secret_random.h"
 #include "startup.h"
-#include "test_game.h"
 
 namespace
 {
@@ -137,17 +134,19 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
         256,
     };
     config.email.link_file = temporary.path() / "latest-link.txt";
-    auto games = std::make_unique<GameRegistry>();
-    ASSERT_TRUE(games->add(std::make_unique<TestGame>()));
-    ASSERT_TRUE(games->add(std::make_unique<GoingHome>()));
-    auto data_source = prepareDataSource(config.database_path, *games);
+    auto data_source = prepareDataSource(config.database_path);
     ASSERT_TRUE(data_source);
-    const GameDefinition* game = games->find("test");
-    ASSERT_NE(game, nullptr);
-    auto metadata = game->validateMetadata({{"hp", "20"}, {"attack", "5"}});
-    ASSERT_TRUE(metadata);
     auto transaction = (*data_source)->beginTransaction();
     ASSERT_TRUE(transaction);
+    ASSERT_TRUE((*transaction)->insertGame(
+        {"test", "Test Game", "Dynamic extension test game.", 1}));
+    auto hp_id = (*transaction)->insertGameField(
+        {0, "test", "hp", "HP", GameFieldType::INTEGER, 0, {}}, {});
+    auto attack_id = (*transaction)->insertGameField(
+        {0, "test", "attack", "Attack", GameFieldType::INTEGER, 1, {}},
+        {});
+    ASSERT_TRUE(hp_id);
+    ASSERT_TRUE(attack_id);
     auto series_id = (*transaction)->insertSeries(
         {0, "test", "Core <Set>", "A **series**."});
     ASSERT_TRUE(series_id);
@@ -167,8 +166,8 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
             1,
             1,
         },
-        game,
-        metadata->get(),
+        {{*hp_id, GameFieldType::INTEGER, std::int64_t{20}},
+         {*attack_id, GameFieldType::INTEGER, std::int64_t{5}}},
         {*series_id});
     ASSERT_TRUE(card_id);
     ASSERT_TRUE((*transaction)->commit());
@@ -186,7 +185,6 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
     App app(
         config,
         std::move(*data_source),
-        std::move(games),
         std::make_unique<NonSecretRandom>(1));
     ASSERT_TRUE(app.start());
 
@@ -306,7 +304,8 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
     auto create = client.Get("/collection/cards/new", session_headers);
     ASSERT_NE(create, nullptr);
     EXPECT_EQ(create->status, 200) << create->body;
-    EXPECT_NE(create->body.find("Going Home"), std::string::npos);
+    EXPECT_NE(create->body.find("Test Game"), std::string::npos);
+    EXPECT_EQ(create->body.find("Going Home"), std::string::npos);
     auto edit = client.Get(
         "/collection/cards/test-1/edit", session_headers);
     ASSERT_NE(edit, nullptr);
@@ -322,10 +321,10 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
     ASSERT_NE(games_page, nullptr);
     EXPECT_EQ(games_page->status, 200) << games_page->body;
     EXPECT_NE(games_page->body.find("Test Game"), std::string::npos);
-    EXPECT_NE(games_page->body.find("Compiled extension test game."),
+    EXPECT_NE(games_page->body.find("Dynamic extension test game."),
               std::string::npos);
     EXPECT_NE(games_page->body.find("HP"), std::string::npos);
-    EXPECT_NE(games_page->body.find("Going Home"), std::string::npos);
+    EXPECT_EQ(games_page->body.find("Going Home"), std::string::npos);
     auto stylesheet = client.Get("/collection/static/css/styles.css");
     ASSERT_NE(stylesheet, nullptr);
     EXPECT_EQ(stylesheet->status, 200);
@@ -339,9 +338,436 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
     ASSERT_NE(traversal, nullptr);
     EXPECT_EQ(traversal->status, 404);
 
+    const httplib::Params csrf_fields = {{"csrf_token", csrf_token}};
+    const httplib::Params oversized_game_fields = {
+        {"csrf_token", csrf_token},
+        {"short_name", "oversized"},
+        {"display_name", "Oversized"},
+        {"description", std::string(1024 * 1024 + 1, 'x')},
+    };
+    auto oversized_game = client.Post(
+        "/collection/admin/games",
+        session_headers,
+        oversized_game_fields);
+    ASSERT_NE(oversized_game, nullptr);
+    EXPECT_EQ(oversized_game->status, 413);
+
+    const httplib::Params dynamic_game_fields = {
+        {"csrf_token", csrf_token},
+        {"short_name", "dynamic"},
+        {"display_name", "Dynamic Game"},
+        {"description", "A **dynamic** game."},
+    };
+    const httplib::Params missing_csrf_game_fields = {
+        {"short_name", "forbidden"},
+        {"display_name", "Forbidden"},
+        {"description", ""},
+    };
+    auto missing_csrf_game = client.Post(
+        "/collection/admin/games",
+        session_headers,
+        missing_csrf_game_fields);
+    ASSERT_NE(missing_csrf_game, nullptr);
+    EXPECT_EQ(missing_csrf_game->status, 403);
+
+    const httplib::Params invalid_game_fields = {
+        {"csrf_token", csrf_token},
+        {"short_name", "Invalid-Game"},
+        {"display_name", "Invalid Game"},
+        {"description", ""},
+    };
+    auto invalid_game = client.Post(
+        "/collection/admin/games",
+        session_headers,
+        invalid_game_fields);
+    ASSERT_NE(invalid_game, nullptr);
+    EXPECT_EQ(invalid_game->status, 422) << invalid_game->body;
+    EXPECT_NE(invalid_game->body.find("Check the game details"),
+              std::string::npos);
+    EXPECT_NE(invalid_game->body.find(
+        "aria-describedby=\"GameShortNameError\""),
+        std::string::npos);
+    EXPECT_NE(invalid_game->body.find("Invalid-Game"),
+              std::string::npos);
+
+    auto dynamic_game = client.Post(
+        "/collection/admin/games",
+        session_headers,
+        dynamic_game_fields);
+    ASSERT_NE(dynamic_game, nullptr);
+    ASSERT_EQ(dynamic_game->status, 303) << dynamic_game->body;
+
+    const httplib::Params invalid_field_fields = {
+        {"csrf_token", csrf_token},
+        {"game_revision", "1"},
+        {"key", "Invalid-Key"},
+        {"label", "Score"},
+        {"type", "INTEGER"},
+    };
+    auto invalid_field = client.Post(
+        "/collection/admin/games/dynamic/fields",
+        session_headers,
+        invalid_field_fields);
+    ASSERT_NE(invalid_field, nullptr);
+    EXPECT_EQ(invalid_field->status, 422) << invalid_field->body;
+    EXPECT_NE(invalid_field->body.find("Check the field definition"),
+              std::string::npos);
+    EXPECT_NE(invalid_field->body.find(
+        "aria-describedby=\"FieldKeyError\""), std::string::npos);
+    EXPECT_NE(invalid_field->body.find("Invalid-Key"),
+              std::string::npos);
+
+    const httplib::Params integer_field_fields = {
+        {"csrf_token", csrf_token},
+        {"game_revision", "1"},
+        {"key", "score"},
+        {"label", "Score"},
+        {"type", "INTEGER"},
+    };
+    auto integer_field = client.Post(
+        "/collection/admin/games/dynamic/fields",
+        session_headers,
+        integer_field_fields);
+    ASSERT_NE(integer_field, nullptr);
+    ASSERT_EQ(integer_field->status, 303) << integer_field->body;
+
+    const httplib::Params string_field_fields = {
+        {"csrf_token", csrf_token},
+        {"game_revision", "2"},
+        {"key", "note"},
+        {"label", "Note"},
+        {"type", "STRING"},
+    };
+    auto string_field = client.Post(
+        "/collection/admin/games/dynamic/fields",
+        session_headers,
+        string_field_fields);
+    ASSERT_NE(string_field, nullptr);
+    ASSERT_EQ(string_field->status, 303) << string_field->body;
+
+    const httplib::Params choice_field_fields = {
+        {"csrf_token", csrf_token},
+        {"game_revision", "3"},
+        {"key", "route"},
+        {"label", "Route"},
+        {"type", "CHOICE"},
+        {"choice", "Forest & River"},
+        {"choice", "Coast+Town"},
+    };
+    auto choice_field = client.Post(
+        "/collection/admin/games/dynamic/fields",
+        session_headers,
+        choice_field_fields);
+    ASSERT_NE(choice_field, nullptr);
+    ASSERT_EQ(choice_field->status, 303) << choice_field->body;
+
+    const httplib::Params field_order_fields = {
+        {"csrf_token", csrf_token},
+        {"game_revision", "4"},
+        {"field_id", "5"},
+        {"field_id", "4"},
+        {"field_id", "3"},
+    };
+    auto fields_reordered = client.Post(
+        "/collection/admin/games/dynamic/field-order",
+        session_headers,
+        field_order_fields);
+    ASSERT_NE(fields_reordered, nullptr);
+    ASSERT_EQ(fields_reordered->status, 303) << fields_reordered->body;
+
+    const httplib::Params game_update_fields = {
+        {"csrf_token", csrf_token},
+        {"game_revision", "5"},
+        {"display_name", "Dynamic Renamed"},
+        {"description", "A **rendered** description."},
+    };
+    auto game_updated = client.Post(
+        "/collection/admin/games/dynamic",
+        session_headers,
+        game_update_fields);
+    ASSERT_NE(game_updated, nullptr);
+    ASSERT_EQ(game_updated->status, 303) << game_updated->body;
+    auto dynamic_games_page = client.Get(
+        "/collection/admin/games", session_headers);
+    ASSERT_NE(dynamic_games_page, nullptr);
+    ASSERT_EQ(dynamic_games_page->status, 200);
+    EXPECT_NE(dynamic_games_page->body.find("Dynamic Renamed"),
+              std::string::npos);
+    EXPECT_NE(dynamic_games_page->body.find("<strong>rendered</strong>"),
+              std::string::npos);
+    auto foreign_field = client.Get(
+        "/collection/admin/games/dynamic/fields/1/edit",
+        session_headers);
+    ASSERT_NE(foreign_field, nullptr);
+    EXPECT_EQ(foreign_field->status, 404);
+
+    const httplib::Params stale_game_fields = {
+        {"csrf_token", csrf_token},
+        {"game_revision", "4"},
+        {"display_name", "Stale name"},
+        {"description", ""},
+    };
+    auto stale_game = client.Post(
+        "/collection/admin/games/dynamic",
+        session_headers,
+        stale_game_fields);
+    ASSERT_NE(stale_game, nullptr);
+    EXPECT_EQ(stale_game->status, 409) << stale_game->body;
+    EXPECT_NE(stale_game->body.find("Nothing was saved"),
+              std::string::npos);
+    EXPECT_NE(stale_game->body.find(
+        "/collection/admin/games/dynamic/edit"), std::string::npos);
+
+    const httplib::Params dynamic_series_fields = {
+        {"csrf_token", csrf_token},
+        {"game", "dynamic"},
+        {"name", "Dynamic series"},
+        {"description", "A dynamic series."},
+    };
+    auto dynamic_series = client.Post(
+        "/collection/admin/series",
+        session_headers,
+        dynamic_series_fields);
+    ASSERT_NE(dynamic_series, nullptr);
+    ASSERT_EQ(dynamic_series->status, 303) << dynamic_series->body;
+
+    const httplib::UploadFormDataItems dynamic_card_fields = {
+        {"csrf_token", csrf_token, "", ""},
+        {"game", "dynamic", "", ""},
+        {"game_revision", "6", "", ""},
+        {"name", "Dynamic card", "", ""},
+        {"rarity", "7", "", ""},
+        {"game.score", "9007199254740993", "", ""},
+        {"game.note", "Line <one>\nLine two", "", ""},
+        {"game.route", "Forest & River", "", ""},
+        {"series_id", "2", "", ""},
+        {"source_mode", "files", "", ""},
+        {"front", upload_bytes, "card.png", "image/png"},
+    };
+    httplib::UploadFormDataItems invalid_dynamic_card_fields =
+        dynamic_card_fields;
+    for(httplib::UploadFormData& field : invalid_dynamic_card_fields)
+    {
+        if(field.name == "game.route")
+        {
+            field.content = "Unknown route";
+        }
+    }
+    auto invalid_dynamic_card = client.Post(
+        "/collection/cards",
+        session_headers,
+        invalid_dynamic_card_fields);
+    ASSERT_NE(invalid_dynamic_card, nullptr);
+    EXPECT_EQ(invalid_dynamic_card->status, 422)
+        << invalid_dynamic_card->body;
+    EXPECT_NE(invalid_dynamic_card->body.find("Check your card details"),
+              std::string::npos);
+    EXPECT_NE(invalid_dynamic_card->body.find(
+        "aria-describedby=\"GameField-dynamic-route-error\""),
+        std::string::npos);
+    EXPECT_NE(invalid_dynamic_card->body.find("9007199254740993"),
+              std::string::npos);
+    EXPECT_NE(invalid_dynamic_card->body.find("must be selected again"),
+              std::string::npos);
+
+    auto dynamic_card = client.Post(
+        "/collection/cards", session_headers, dynamic_card_fields);
+    ASSERT_NE(dynamic_card, nullptr);
+    ASSERT_EQ(dynamic_card->status, 303) << dynamic_card->body;
+    const std::string dynamic_card_path = urlRequestPath(
+        dynamic_card->get_header_value("Location"));
+    EXPECT_EQ(dynamic_card_path, "/collection/cards/dynamic-1");
+    auto dynamic_card_page = client.Get(
+        dynamic_card_path, session_headers);
+    ASSERT_NE(dynamic_card_page, nullptr);
+    ASSERT_EQ(dynamic_card_page->status, 200) << dynamic_card_page->body;
+    EXPECT_NE(dynamic_card_page->body.find("9007199254740993"),
+              std::string::npos);
+    EXPECT_NE(dynamic_card_page->body.find("Line &lt;one&gt;\nLine two"),
+              std::string::npos);
+    EXPECT_NE(dynamic_card_page->body.find("Forest &amp; River"),
+              std::string::npos);
+
+    const httplib::UploadFormDataItems dynamic_edit_fields = {
+        {"csrf_token", csrf_token, "", ""},
+        {"name", "Dynamic card edited", "", ""},
+        {"rarity", "8", "", ""},
+        {"revision", "1", "", ""},
+        {"game_revision", "6", "", ""},
+        {"game.score", "-7", "", ""},
+        {"game.note", "Whitespace  kept", "", ""},
+        {"game.route", "Coast+Town", "", ""},
+        {"series_id", "2", "", ""},
+        {"source_mode", "files", "", ""},
+        {"front_action", "keep", "", ""},
+        {"foil_action", "keep", "", ""},
+    };
+    auto dynamic_edited = client.Post(
+        dynamic_card_path + "/edit",
+        session_headers,
+        dynamic_edit_fields);
+    ASSERT_NE(dynamic_edited, nullptr);
+    ASSERT_EQ(dynamic_edited->status, 303) << dynamic_edited->body;
+    dynamic_card_page = client.Get(dynamic_card_path, session_headers);
+    ASSERT_NE(dynamic_card_page, nullptr);
+    ASSERT_EQ(dynamic_card_page->status, 200);
+    EXPECT_NE(dynamic_card_page->body.find("Dynamic card edited"),
+              std::string::npos);
+    EXPECT_NE(dynamic_card_page->body.find("Coast+Town"),
+              std::string::npos);
+
+    const httplib::Params second_game_update_fields = {
+        {"csrf_token", csrf_token},
+        {"game_revision", "6"},
+        {"display_name", "Dynamic Final"},
+        {"description", "Final description."},
+    };
+    auto second_game_update = client.Post(
+        "/collection/admin/games/dynamic",
+        session_headers,
+        second_game_update_fields);
+    ASSERT_NE(second_game_update, nullptr);
+    ASSERT_EQ(second_game_update->status, 303)
+        << second_game_update->body;
+    httplib::UploadFormDataItems stale_dynamic_edit_fields =
+        dynamic_edit_fields;
+    for(httplib::UploadFormData& field : stale_dynamic_edit_fields)
+    {
+        if(field.name == "name")
+        {
+            field.content = "Stale card change";
+        }
+    }
+    auto stale_card = client.Post(
+        dynamic_card_path + "/edit",
+        session_headers,
+        stale_dynamic_edit_fields);
+    ASSERT_NE(stale_card, nullptr);
+    EXPECT_EQ(stale_card->status, 409) << stale_card->body;
+    EXPECT_NE(stale_card->body.find("must be selected again"),
+              std::string::npos);
+    dynamic_card_page = client.Get(dynamic_card_path, session_headers);
+    ASSERT_NE(dynamic_card_page, nullptr);
+    EXPECT_EQ(dynamic_card_page->status, 200);
+    EXPECT_EQ(dynamic_card_page->body.find("Stale card change"),
+              std::string::npos);
+    EXPECT_NE(dynamic_card_page->body.find("Dynamic card edited"),
+              std::string::npos);
+
+    const httplib::Params used_choice_fields = {
+        {"csrf_token", csrf_token},
+        {"game_revision", "7"},
+        {"label", "Route"},
+        {"choice", "Forest & River"},
+    };
+    auto used_choice = client.Post(
+        "/collection/admin/games/dynamic/fields/5",
+        session_headers,
+        used_choice_fields);
+    ASSERT_NE(used_choice, nullptr);
+    EXPECT_EQ(used_choice->status, 409) << used_choice->body;
+    EXPECT_NE(used_choice->body.find("1 card"), std::string::npos);
+
+    auto dynamic_deleted = client.Post(
+        dynamic_card_path + "/delete", session_headers, csrf_fields);
+    ASSERT_NE(dynamic_deleted, nullptr);
+    ASSERT_EQ(dynamic_deleted->status, 303) << dynamic_deleted->body;
+    auto dynamic_series_deleted = client.Post(
+        "/collection/admin/series/2/delete",
+        session_headers,
+        csrf_fields);
+    ASSERT_NE(dynamic_series_deleted, nullptr);
+    ASSERT_EQ(dynamic_series_deleted->status, 303)
+        << dynamic_series_deleted->body;
+
+    auto choice_after_card = client.Post(
+        "/collection/admin/games/dynamic/fields/5",
+        session_headers,
+        used_choice_fields);
+    ASSERT_NE(choice_after_card, nullptr);
+    ASSERT_EQ(choice_after_card->status, 303) << choice_after_card->body;
+    const httplib::Params delete_choice_field = {
+        {"csrf_token", csrf_token}, {"game_revision", "8"}};
+    const httplib::Params delete_string_field = {
+        {"csrf_token", csrf_token}, {"game_revision", "9"}};
+    const httplib::Params delete_integer_field = {
+        {"csrf_token", csrf_token}, {"game_revision", "10"}};
+    auto choice_deleted = client.Post(
+        "/collection/admin/games/dynamic/fields/5/delete",
+        session_headers,
+        delete_choice_field);
+    ASSERT_NE(choice_deleted, nullptr);
+    ASSERT_EQ(choice_deleted->status, 303) << choice_deleted->body;
+    auto string_deleted = client.Post(
+        "/collection/admin/games/dynamic/fields/4/delete",
+        session_headers,
+        delete_string_field);
+    ASSERT_NE(string_deleted, nullptr);
+    ASSERT_EQ(string_deleted->status, 303) << string_deleted->body;
+    auto integer_deleted = client.Post(
+        "/collection/admin/games/dynamic/fields/3/delete",
+        session_headers,
+        delete_integer_field);
+    ASSERT_NE(integer_deleted, nullptr);
+    ASSERT_EQ(integer_deleted->status, 303) << integer_deleted->body;
+
+    const httplib::Params delete_issued_game = {
+        {"csrf_token", csrf_token}, {"game_revision", "11"}};
+    auto issued_game = client.Post(
+        "/collection/admin/games/dynamic/delete",
+        session_headers,
+        delete_issued_game);
+    ASSERT_NE(issued_game, nullptr);
+    EXPECT_EQ(issued_game->status, 409) << issued_game->body;
+
+    const httplib::Params empty_game_fields = {
+        {"csrf_token", csrf_token},
+        {"short_name", "empty"},
+        {"display_name", "Empty Game"},
+        {"description", ""},
+    };
+    auto empty_game = client.Post(
+        "/collection/admin/games", session_headers, empty_game_fields);
+    ASSERT_NE(empty_game, nullptr);
+    ASSERT_EQ(empty_game->status, 303) << empty_game->body;
+    const httplib::Params delete_empty_game = {
+        {"csrf_token", csrf_token}, {"game_revision", "1"}};
+    auto empty_deleted = client.Post(
+        "/collection/admin/games/empty/delete",
+        session_headers,
+        delete_empty_game);
+    ASSERT_NE(empty_deleted, nullptr);
+    EXPECT_EQ(empty_deleted->status, 303) << empty_deleted->body;
+
+    const httplib::UploadFormDataItems invalid_loose_fields = {
+        {"csrf_token", csrf_token, "", ""},
+        {"game", "", "", ""},
+        {"game.route", "Forest", "", ""},
+        {"name", "Tampered loose card", "", ""},
+        {"rarity", "1", "", ""},
+        {"source_mode", "files", "", ""},
+        {"front", upload_bytes, "card.png", "image/png"},
+    };
+    auto invalid_loose = client.Post(
+        "/collection/cards", session_headers, invalid_loose_fields);
+    ASSERT_NE(invalid_loose, nullptr);
+    EXPECT_EQ(invalid_loose->status, 422) << invalid_loose->body;
+
+    const httplib::Params going_home_fields = {
+        {"csrf_token", csrf_token},
+        {"short_name", "gh"},
+        {"display_name", "Going Home"},
+        {"description", ""},
+    };
+    auto going_home = client.Post(
+        "/collection/admin/games", session_headers, going_home_fields);
+    ASSERT_NE(going_home, nullptr);
+    ASSERT_EQ(going_home->status, 303) << going_home->body;
+
     const httplib::UploadFormDataItems create_fields = {
         {"csrf_token", csrf_token, "", ""},
         {"game", "gh", "", ""},
+        {"game_revision", "1", "", ""},
         {"name", "Uploaded card", "", ""},
         {"rarity", "3", "", ""},
         {"source_mode", "files", "", ""},
@@ -366,6 +792,7 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
         {"name", "Edited upload", "", ""},
         {"rarity", "4", "", ""},
         {"revision", "1", "", ""},
+        {"game_revision", "1", "", ""},
         {"source_mode", "files", "", ""},
         {"front_action", "keep", "", ""},
         {"foil_action", "keep", "", ""},
@@ -380,7 +807,6 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
     EXPECT_NE(edited_page->body.find("Going Home"), std::string::npos);
     EXPECT_NE(edited_page->body.find("Edited upload"), std::string::npos);
 
-    const httplib::Params csrf_fields = {{"csrf_token", csrf_token}};
     auto deleted = client.Post(
         created_path + "/delete",
         session_headers,
@@ -495,6 +921,18 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
         "/collection/admin/games", player_headers);
     ASSERT_NE(player_games, nullptr);
     EXPECT_EQ(player_games->status, 403);
+    const httplib::Params player_game_fields = {
+        {"csrf_token", player_csrf},
+        {"short_name", "playergame"},
+        {"display_name", "Player Game"},
+        {"description", ""},
+    };
+    auto player_game_create = client.Post(
+        "/collection/admin/games",
+        player_headers,
+        player_game_fields);
+    ASSERT_NE(player_game_create, nullptr);
+    EXPECT_EQ(player_game_create->status, 403);
 
     auto promoted = client.Post(
         "/collection/admin/users/2/promote",
@@ -502,6 +940,12 @@ TEST(AppIntegrationTest, ServesPagesAndStaticAssets)
         csrf_fields);
     ASSERT_NE(promoted, nullptr);
     ASSERT_EQ(promoted->status, 303) << promoted->body;
+    auto creator_game_create = client.Post(
+        "/collection/admin/games",
+        player_headers,
+        player_game_fields);
+    ASSERT_NE(creator_game_create, nullptr);
+    EXPECT_EQ(creator_game_create->status, 403);
     auto creator_new = client.Get("/collection/cards/new", player_headers);
     ASSERT_NE(creator_new, nullptr);
     ASSERT_EQ(creator_new->status, 200) << creator_new->body;
diff --git a/tests/app_test.cpp b/tests/app_test.cpp
index d15b6fd..c18328f 100644
--- a/tests/app_test.cpp
+++ b/tests/app_test.cpp
@@ -14,7 +14,6 @@
 
 #include "data_fake.h"
 #include "public_id.h"
-#include "test_game.h"
 
 namespace
 {
@@ -48,21 +47,6 @@ std::unique_ptr<NonSecretRandom> deterministicRandom()
     return std::make_unique<NonSecretRandom>(1);
 }
 
-std::unique_ptr<GameRegistry> emptyGames()
-{
-    return std::make_unique<GameRegistry>();
-}
-
-std::unique_ptr<GameRegistry> testGames()
-{
-    auto games = std::make_unique<GameRegistry>();
-    if(!games->add(std::make_unique<TestGame>()))
-    {
-        throw std::runtime_error("Failed to construct test game registry");
-    }
-    return games;
-}
-
 Card makeCard(std::int64_t id, std::uint64_t number, std::string name)
 {
     return {
@@ -128,7 +112,6 @@ TEST(AppTest, BuildsNamedUrls)
     const App app(
         makeConfig("https://example.test/collection/"),
         emptyDataSource(),
-        emptyGames(),
         deterministicRandom());
 
     EXPECT_EQ(
@@ -146,6 +129,9 @@ TEST(AppTest, BuildsNamedUrls)
     EXPECT_EQ(
         app.urlFor("admin-games"),
         "https://example.test/collection/admin/games");
+    EXPECT_EQ(
+        app.urlFor("game-field-edit", {"a/b", "42"}),
+        "https://example.test/collection/admin/games/a%2fb/fields/42/edit");
 }
 
 /// Verify static relative paths and ordered query values are encoded.
@@ -154,7 +140,6 @@ TEST(AppTest, BuildsStaticAndQueryUrls)
     const App app(
         makeConfig("https://example.test/collection/"),
         emptyDataSource(),
-        emptyGames(),
         deterministicRandom());
 
     EXPECT_EQ(
@@ -172,7 +157,6 @@ TEST(AppTest, RejectsInvalidRoutes)
     const App app(
         makeConfig("https://example.test/"),
         emptyDataSource(),
-        emptyGames(),
         deterministicRandom());
 
     EXPECT_THROW(app.urlFor("missing"), std::invalid_argument);
@@ -192,7 +176,6 @@ TEST(AppTest, RendersCardIndex)
     App app(
         makeConfig("https://example.test/collection/"),
         std::move(data_source),
-        emptyGames(),
         deterministicRandom());
     App::Request request = authenticatedRequest();
     App::Response response;
@@ -221,7 +204,6 @@ TEST(AppTest, RendersCardCreationForm)
     App app(
         makeConfig("https://example.test/collection/"),
         std::make_unique<AuthenticatedDataSource>(),
-        emptyGames(),
         deterministicRandom());
     App::Request request = authenticatedRequest();
     App::Response response;
@@ -255,7 +237,6 @@ TEST(AppTest, RendersCardEditForm)
     App app(
         makeConfig("https://example.test/collection/"),
         std::move(data_source),
-        emptyGames(),
         deterministicRandom());
     auto public_id = formatPublicId(card.identity);
     ASSERT_TRUE(public_id);
@@ -294,11 +275,13 @@ TEST(AppTest, RendersCardView)
         },
         std::unordered_map<std::int64_t, std::vector<std::int64_t>>{
             {2, {7}},
-        });
+        },
+        std::vector<GameDefinitionSnapshot>{
+            {{"test", "Test Game", "", 1}, {}}},
+        std::unordered_map<std::int64_t, std::vector<GameFieldValue>>{});
     App app(
         makeConfig("https://example.test/collection/"),
         std::move(data_source),
-        testGames(),
         deterministicRandom());
     App::Request request = authenticatedRequest();
     request.path_params.emplace("id", "test-2");
@@ -325,7 +308,6 @@ TEST(AppTest, RejectsUnknownCardView)
     App app(
         makeConfig("https://example.test/"),
         std::make_unique<AuthenticatedDataSource>(),
-        emptyGames(),
         deterministicRandom());
     App::Request request = authenticatedRequest();
     request.path_params.emplace("id", "PKM-2");
diff --git a/tests/authentication_test.cpp b/tests/authentication_test.cpp
index 46abf2d..ed2d46d 100644
--- a/tests/authentication_test.cpp
+++ b/tests/authentication_test.cpp
@@ -11,7 +11,6 @@
 #include <mw/crypto_mock.hpp>
 
 #include "authentication.h"
-#include "game_registry.h"
 #include "startup.h"
 
 namespace
@@ -108,8 +107,7 @@ std::string hexToken(char low_nibble)
 TEST(AuthenticationServiceTest, CompletesSingleUseNonSlidingFlow)
 {
     TemporaryDatabase database;
-    GameRegistry games;
-    auto data_source = prepareDataSource(database.path(), games);
+    auto data_source = prepareDataSource(database.path());
     ASSERT_TRUE(data_source);
     auto base_url = mw::URL::fromStr("http://127.0.0.1/app/");
     ASSERT_TRUE(base_url);
@@ -165,8 +163,7 @@ TEST(AuthenticationServiceTest, CompletesSingleUseNonSlidingFlow)
 TEST(AuthenticationServiceTest, RateLimitsAndInvalidatesSenderFailure)
 {
     TemporaryDatabase database;
-    GameRegistry games;
-    auto data_source = prepareDataSource(database.path(), games);
+    auto data_source = prepareDataSource(database.path());
     ASSERT_TRUE(data_source);
     auto base_url = mw::URL::fromStr("http://127.0.0.1/");
     ASSERT_TRUE(base_url);
diff --git a/tests/card_form_logic_test.js b/tests/card_form_logic_test.js
index 8fd54ca..368e58b 100644
--- a/tests/card_form_logic_test.js
+++ b/tests/card_form_logic_test.js
@@ -66,3 +66,13 @@ test("source input state follows mode and replacements", function()
         foil_required: true,
     });
 });
+
+test("game selection enables only matching controls and series", function()
+{
+    assert.equal(logic.gameControlEnabled("gh", "gh"), true);
+    assert.equal(logic.gameControlEnabled("gh", "other"), false);
+    assert.equal(logic.gameControlEnabled("", "gh"), false);
+    assert.equal(logic.seriesChoiceEnabled("gh", "gh"), true);
+    assert.equal(logic.seriesChoiceEnabled("gh", "other"), false);
+    assert.equal(logic.seriesChoiceEnabled("", ""), false);
+});
diff --git a/tests/card_service_test.cpp b/tests/card_service_test.cpp
index 642a8e2..c7ae6f7 100644
--- a/tests/card_service_test.cpp
+++ b/tests/card_service_test.cpp
@@ -1,10 +1,8 @@
 #include "card_service.h"
 #include "data_sqlite.h"
-#include "game_registry.h"
 #include "multipart_reader.h"
 #include "series_service.h"
 #include "startup.h"
-#include "test_game.h"
 
 #include <chrono>
 #include <filesystem>
@@ -86,10 +84,9 @@ std::string readFile(const std::filesystem::path& path)
 }
 
 mw::E<std::unique_ptr<DataSourceInterface>> prepareTestDataSource(
-    const std::filesystem::path& database,
-    const GameRegistry& games)
+    const std::filesystem::path& database)
 {
-    auto data_source = prepareDataSource(database, games);
+    auto data_source = prepareDataSource(database);
     if(!data_source)
     {
         return std::unexpected(std::move(data_source.error()));
@@ -121,8 +118,7 @@ TEST(CardServiceTest, CreatesLooseCard)
     initializeImageMagick();
     TemporaryCardRoot temporary;
     const std::filesystem::path database = temporary.path() / "cards.sqlite3";
-    GameRegistry games;
-    auto data_source = prepareTestDataSource(database, games);
+    auto data_source = prepareTestDataSource(database);
     ASSERT_TRUE(data_source);
 
     const std::filesystem::path staging =
@@ -175,8 +171,7 @@ TEST(CardServiceTest, RejectsInvalidImage)
     initializeImageMagick();
     TemporaryCardRoot temporary;
     const std::filesystem::path database = temporary.path() / "cards.sqlite3";
-    GameRegistry games;
-    auto data_source = prepareTestDataSource(database, games);
+    auto data_source = prepareTestDataSource(database);
     ASSERT_TRUE(data_source);
 
     const std::filesystem::path staging =
@@ -216,8 +211,7 @@ TEST(CardServiceTest, CreatesOpaqueJpegFoilCard)
     initializeImageMagick();
     TemporaryCardRoot temporary;
     const std::filesystem::path database = temporary.path() / "cards.sqlite3";
-    GameRegistry games;
-    auto data_source = prepareTestDataSource(database, games);
+    auto data_source = prepareTestDataSource(database);
     ASSERT_TRUE(data_source);
 
     const std::filesystem::path staging =
@@ -256,28 +250,30 @@ TEST(CardServiceTest, CreatesOpaqueJpegFoilCard)
     EXPECT_TRUE(std::filesystem::is_regular_file(published / "foil.jpg"));
 }
 
-/// Verify compiled-game creation allocates a sequence and memberships.
-TEST(CardServiceTest, CreatesCompiledGameCard)
+/// Verify dynamic-game creation allocates a sequence and memberships.
+TEST(CardServiceTest, CreatesDynamicGameCard)
 {
     initializeImageMagick();
     TemporaryCardRoot temporary;
     const std::filesystem::path database = temporary.path() / "cards.sqlite3";
-    GameRegistry games;
-    ASSERT_TRUE(games.add(std::make_unique<TestGame>()));
-    const GameDefinition* game = games.find("test");
-    ASSERT_NE(game, nullptr);
-    auto data_source = prepareTestDataSource(database, games);
+    auto data_source = prepareTestDataSource(database);
     ASSERT_TRUE(data_source);
 
     auto series_transaction = (*data_source)->beginTransaction();
     ASSERT_TRUE(series_transaction);
+    ASSERT_TRUE((*series_transaction)->insertGame(
+        {"test", "Test Game", "", 1}));
+    auto hp_id = (*series_transaction)->insertGameField(
+        {0, "test", "hp", "HP", GameFieldType::INTEGER, 0, {}}, {});
+    auto attack_id = (*series_transaction)->insertGameField(
+        {0, "test", "attack", "Attack", GameFieldType::INTEGER, 1, {}},
+        {});
+    ASSERT_TRUE(hp_id);
+    ASSERT_TRUE(attack_id);
     auto series_id = (*series_transaction)->insertSeries(
         {0, "test", "Core", "Core cards"});
     ASSERT_TRUE(series_id);
     ASSERT_TRUE((*series_transaction)->commit());
-    auto metadata = game->validateMetadata({{"hp", "50"}, {"attack", "10"}});
-    ASSERT_TRUE(metadata);
-
     const std::filesystem::path staging =
         temporary.path() / ".staging/upload";
     const std::filesystem::path front = staging / "upload_front";
@@ -300,8 +296,9 @@ TEST(CardServiceTest, CreatesCompiledGameCard)
             std::nullopt,
             std::nullopt,
         },
-        *game,
-        **metadata,
+        "test",
+        1,
+        {{"hp", "50"}, {"attack", "10"}},
         {*series_id});
 
     ASSERT_TRUE(public_id) << public_id.error().msg();
@@ -312,13 +309,10 @@ TEST(CardServiceTest, CreatesCompiledGameCard)
     auto memberships = (*data_source)->getCardSeries((**stored).id);
     ASSERT_TRUE(memberships);
     EXPECT_EQ(*memberships, std::vector<std::int64_t>{*series_id});
-    auto display = (*data_source)->getGameDisplayFields(
-        *game, (**stored).id);
-    ASSERT_TRUE(display);
-    EXPECT_EQ(display->front().value, "50");
-
-    metadata = game->validateMetadata({{"hp", "80"}, {"attack", "40"}});
-    ASSERT_TRUE(metadata);
+    auto values = (*data_source)->getCardFieldValues((**stored).id);
+    ASSERT_TRUE(values);
+    ASSERT_TRUE(*values);
+    EXPECT_EQ(std::get<std::int64_t>((**values).values.front().value), 50);
     const std::filesystem::path edit_staging =
         temporary.path() / ".staging/edit";
     std::filesystem::create_directory(edit_staging);
@@ -338,30 +332,167 @@ TEST(CardServiceTest, CreatesCompiledGameCard)
             std::nullopt,
             std::nullopt,
         },
-        *game,
-        **metadata,
+        1,
+        {{"hp", "80"}, {"attack", "40"}},
         {});
     ASSERT_TRUE(updated) << updated.error().msg();
     stored = (*data_source)->getCard({"test", 1});
     ASSERT_TRUE(stored);
     ASSERT_TRUE(*stored);
     EXPECT_EQ((**stored).name, "Updated game card");
-    display = (*data_source)->getGameDisplayFields(*game, (**stored).id);
-    ASSERT_TRUE(display);
-    EXPECT_EQ(display->front().value, "80");
+    values = (*data_source)->getCardFieldValues((**stored).id);
+    ASSERT_TRUE(values);
+    ASSERT_TRUE(*values);
+    EXPECT_EQ(std::get<std::int64_t>((**values).values.front().value), 80);
     memberships = (*data_source)->getCardSeries((**stored).id);
     ASSERT_TRUE(memberships);
     EXPECT_TRUE(memberships->empty());
 }
 
+/// Verify generic metadata failures roll back every card side effect.
+TEST(CardServiceTest, RollsBackDynamicMetadataFailures)
+{
+    initializeImageMagick();
+    TemporaryCardRoot temporary;
+    const std::filesystem::path database = temporary.path() / "cards.sqlite3";
+    auto data_source = prepareTestDataSource(database);
+    ASSERT_TRUE(data_source);
+
+    auto setup = (*data_source)->beginTransaction();
+    ASSERT_TRUE(setup);
+    ASSERT_TRUE((*setup)->insertGame({"test", "Test Game", "", 1}));
+    auto hp_id = (*setup)->insertGameField(
+        {0, "test", "hp", "HP", GameFieldType::INTEGER, 0, {}}, {});
+    ASSERT_TRUE(hp_id);
+    auto series_id = (*setup)->insertSeries(
+        {0, "test", "Core", "Core cards"});
+    ASSERT_TRUE(series_id);
+    ASSERT_TRUE((*setup)->commit());
+
+    auto injection = mw::SQLite::connectFile(database.string());
+    ASSERT_TRUE(injection);
+    ASSERT_TRUE((*injection)->execute(
+        "CREATE TRIGGER fail_card_values BEFORE INSERT "
+        "ON card_field_values BEGIN "
+        "SELECT RAISE(ABORT, 'forced metadata failure'); END;"));
+
+    const std::filesystem::path staging =
+        temporary.path() / ".staging/upload";
+    const std::filesystem::path front = staging / "upload_front";
+    writePng(front);
+    NonSecretRandom random(1);
+    CardService service(
+        **data_source,
+        random,
+        ImageProcessor(75, 256),
+        AssetStore(temporary.path()));
+    auto failed_create = service.createGameCard(
+        1,
+        {
+            "Failed card",
+            std::nullopt,
+            std::nullopt,
+            2,
+            staging,
+            front,
+            std::nullopt,
+            std::nullopt,
+        },
+        "test",
+        1,
+        {{"hp", "50"}},
+        {*series_id});
+    ASSERT_FALSE(failed_create);
+    auto cards = (*data_source)->getCards();
+    ASSERT_TRUE(cards);
+    EXPECT_TRUE(cards->empty());
+    EXPECT_FALSE(std::filesystem::exists(
+        temporary.path() / "published/test-1"));
+    EXPECT_TRUE(std::filesystem::is_regular_file(staging / "front-art.avif"));
+    ASSERT_TRUE((*injection)->execute("DROP TRIGGER fail_card_values;"));
+
+    writePng(front);
+    auto public_id = service.createGameCard(
+        1,
+        {
+            "Committed card",
+            std::nullopt,
+            std::nullopt,
+            2,
+            staging,
+            front,
+            std::nullopt,
+            std::nullopt,
+        },
+        "test",
+        1,
+        {{"hp", "50"}},
+        {*series_id});
+    ASSERT_TRUE(public_id) << public_id.error().msg();
+    EXPECT_EQ(*public_id, "test-1");
+    auto stored = (*data_source)->getCard({"test", 1});
+    ASSERT_TRUE(stored);
+    ASSERT_TRUE(*stored);
+    const std::filesystem::path published =
+        temporary.path() / "published/test-1";
+    const std::string original_art = readFile(published / "front-art.avif");
+
+    ASSERT_TRUE((*injection)->execute(
+        "CREATE TRIGGER fail_card_values BEFORE INSERT "
+        "ON card_field_values BEGIN "
+        "SELECT RAISE(ABORT, 'forced metadata failure'); END;"));
+    const std::filesystem::path edit_staging =
+        temporary.path() / ".staging/edit";
+    std::filesystem::create_directory(edit_staging);
+    const std::filesystem::path replacement =
+        edit_staging / "upload_front";
+    writeJpeg(replacement);
+    auto failed_update = service.updateGameCard(
+        1,
+        {
+            **stored,
+            1,
+            "Failed update",
+            std::nullopt,
+            std::nullopt,
+            3,
+            edit_staging,
+            FrontAssetAction::REPLACE,
+            FoilAssetAction::KEEP,
+            replacement,
+            std::nullopt,
+            std::nullopt,
+        },
+        1,
+        {{"hp", "80"}},
+        {});
+    ASSERT_FALSE(failed_update);
+    stored = (*data_source)->getCard({"test", 1});
+    ASSERT_TRUE(stored);
+    ASSERT_TRUE(*stored);
+    EXPECT_EQ((**stored).name, "Committed card");
+    EXPECT_EQ((**stored).revision, 1);
+    auto values = (*data_source)->getCardFieldValues((**stored).id);
+    ASSERT_TRUE(values);
+    ASSERT_TRUE(*values);
+    ASSERT_EQ((**values).values.size(), 1);
+    EXPECT_EQ(std::get<std::int64_t>((**values).values.front().value), 50);
+    auto memberships = (*data_source)->getCardSeries((**stored).id);
+    ASSERT_TRUE(memberships);
+    EXPECT_EQ(*memberships, std::vector<std::int64_t>{*series_id});
+    EXPECT_EQ(readFile(published / "front-art.avif"), original_art);
+    EXPECT_FALSE(std::filesystem::exists(published / "front-art.jpg"));
+    EXPECT_TRUE(std::filesystem::is_regular_file(
+        edit_staging / "front-art.jpg"));
+}
+
 /// Verify metadata and artwork edits increment revisions and replace assets.
 TEST(CardServiceTest, UpdatesLooseCard)
 {
     initializeImageMagick();
     TemporaryCardRoot temporary;
     const std::filesystem::path database = temporary.path() / "cards.sqlite3";
-    GameRegistry games;
-    auto data_source = prepareTestDataSource(database, games);
+    auto data_source = prepareTestDataSource(database);
     ASSERT_TRUE(data_source);
 
     const std::filesystem::path create_staging =
@@ -490,8 +621,7 @@ TEST(AssetStoreTest, ReconcilesInterruptedTransitions)
     initializeImageMagick();
     TemporaryCardRoot temporary;
     const std::filesystem::path database = temporary.path() / "cards.sqlite3";
-    GameRegistry games;
-    auto data_source = prepareTestDataSource(database, games);
+    auto data_source = prepareTestDataSource(database);
     ASSERT_TRUE(data_source);
     const std::filesystem::path staging =
         temporary.path() / ".staging/upload";
@@ -591,15 +721,67 @@ TEST(MultipartReaderTest, StreamsExpectedFields)
     EXPECT_FALSE(upload->foil);
 }
 
+/// Verify multipart custom fields reject duplicates and excessive text.
+TEST(MultipartReaderTest, RejectsDuplicateAndOversizedGameFields)
+{
+    initializeImageMagick();
+    TemporaryCardRoot temporary;
+    const httplib::ContentReader duplicate_reader(
+        [](httplib::ContentReceiver)
+        {
+            return false;
+        },
+        [](httplib::FormDataHeader header,
+           httplib::ContentReceiver receiver)
+        {
+            httplib::FormData field;
+            field.name = "game.note";
+            return header(field) && receiver("first", 5) &&
+                   header(field) && receiver("second", 6);
+        });
+    MultipartReader duplicate_parser(temporary.path());
+    auto duplicate = duplicate_parser.read(duplicate_reader);
+    ASSERT_FALSE(duplicate);
+    const auto* duplicate_error = duplicate.error().as<mw::HTTPError>();
+    ASSERT_NE(duplicate_error, nullptr);
+    EXPECT_EQ(duplicate_error->code, 400);
+
+    const std::string chunk(1024 * 1024, 'x');
+    const httplib::ContentReader oversized_reader(
+        [](httplib::ContentReceiver)
+        {
+            return false;
+        },
+        [&chunk](httplib::FormDataHeader header,
+                 httplib::ContentReceiver receiver)
+        {
+            for(int index = 0; index < 9; ++index)
+            {
+                httplib::FormData field;
+                field.name = "game.value" + std::to_string(index);
+                if(!header(field) ||
+                   !receiver(chunk.data(), chunk.size()))
+                {
+                    return false;
+                }
+            }
+            return true;
+        });
+    MultipartReader oversized_parser(temporary.path());
+    auto oversized = oversized_parser.read(oversized_reader);
+    ASSERT_FALSE(oversized);
+    const auto* oversized_error = oversized.error().as<mw::HTTPError>();
+    ASSERT_NE(oversized_error, nullptr);
+    EXPECT_EQ(oversized_error->code, 413);
+}
+
 /// Verify creator authorship and rarity rules survive direct service calls.
 TEST(CardServiceTest, EnforcesCreatorOwnershipAndRarity)
 {
     initializeImageMagick();
     TemporaryCardRoot temporary;
     const std::filesystem::path database = temporary.path() / "cards.sqlite3";
-    GameRegistry games;
-    ASSERT_TRUE(games.add(std::make_unique<TestGame>()));
-    auto data_source = prepareTestDataSource(database, games);
+    auto data_source = prepareTestDataSource(database);
     ASSERT_TRUE(data_source);
     auto transaction = (*data_source)->beginTransaction();
     ASSERT_TRUE(transaction);
@@ -665,9 +847,9 @@ TEST(CardServiceTest, EnforcesCreatorOwnershipAndRarity)
         std::nullopt,
         true,
     }));
-    SeriesService series_service(**data_source, games);
+    SeriesService series_service(**data_source);
     auto forbidden_series = series_service.create(
-        *creator_id, "test", "Creator series", "Not allowed");
+        *creator_id, "gh", "Creator series", "Not allowed");
     ASSERT_FALSE(forbidden_series);
     const auto* http_error = forbidden_series.error().as<mw::HTTPError>();
     ASSERT_NE(http_error, nullptr);
diff --git a/tests/data_fake_test.cpp b/tests/data_fake_test.cpp
index f5e6e6b..2cb9233 100644
--- a/tests/data_fake_test.cpp
+++ b/tests/data_fake_test.cpp
@@ -56,21 +56,42 @@ TEST(DataSourceFakeTest, ReturnsCards)
     EXPECT_FALSE(*missing);
 }
 
-/// Verify series data, memberships, and game names are deterministic.
+/// Verify series data, memberships, and game definitions are deterministic.
 TEST(DataSourceFakeTest, ReturnsRelatedData)
 {
     DataSourceFake data_source(
         {makeCard(2, "test", 3, "Game card")},
         {{7, "test", "First series", "Description"}},
-        {{2, {7}}});
+        {{2, {7}}},
+        {{{"test", "Test Game", "", 1}, {}}});
 
     const auto memberships = data_source.getCardSeries(2);
     ASSERT_TRUE(memberships);
     EXPECT_EQ(*memberships, std::vector<std::int64_t>({7}));
 
-    const auto names = data_source.getPersistedGameNames();
-    ASSERT_TRUE(names);
-    EXPECT_EQ(*names, std::vector<std::string>({"test"}));
+    const auto games = data_source.getGames();
+    ASSERT_TRUE(games);
+    ASSERT_EQ(games->size(), 1);
+    EXPECT_EQ(games->front().short_name, "test");
+}
+
+/// Verify equal display names use the permanent short name as a tie-breaker.
+TEST(DataSourceFakeTest, SortsGamesDeterministically)
+{
+    DataSourceFake data_source(
+        {},
+        {},
+        {},
+        {
+            {{"z", "Same", "", 1}, {}},
+            {{"a", "Same", "", 1}, {}},
+        });
+
+    const auto games = data_source.getGames();
+    ASSERT_TRUE(games);
+    ASSERT_EQ(games->size(), 2);
+    EXPECT_EQ((*games)[0].short_name, "a");
+    EXPECT_EQ((*games)[1].short_name, "z");
 }
 
 /// Verify development data cannot accidentally be mutated.
diff --git a/tests/data_mock.h b/tests/data_mock.h
index c38f2f9..311ce88 100644
--- a/tests/data_mock.h
+++ b/tests/data_mock.h
@@ -28,6 +28,67 @@ public:
         (const std::string& game_short_name),
         (override));
 
+    /// Mock a transaction-consistent game definition read.
+    MOCK_METHOD((mw::E<std::optional<GameDefinitionSnapshot>>),
+                getGameDefinitionForUpdate,
+                (const std::string& short_name), (override));
+
+    /// Mock deletion-relevant game usage.
+    MOCK_METHOD((mw::E<GameUsage>), getGameUsage,
+                (const std::string& short_name), (override));
+
+    /// Mock game insertion.
+    MOCK_METHOD((mw::E<void>), insertGame, (const Game& game), (override));
+
+    /// Mock mutable game metadata replacement.
+    MOCK_METHOD((mw::E<bool>), updateGame,
+                (const std::string& short_name,
+                 const std::string& display_name,
+                 const std::string& description,
+                 std::int64_t expected_revision), (override));
+
+    /// Mock game deletion.
+    MOCK_METHOD((mw::E<void>), deleteGame,
+                (const std::string& short_name), (override));
+
+    /// Mock custom-field insertion.
+    MOCK_METHOD((mw::E<std::int64_t>), insertGameField,
+                (const GameField& field,
+                 const std::vector<GameChoice>& choices), (override));
+
+    /// Mock custom-field replacement.
+    MOCK_METHOD((mw::E<void>), updateGameField,
+                (std::int64_t field_id, const std::string& label,
+                 const std::vector<GameChoice>& choices), (override));
+
+    /// Mock custom-field deletion.
+    MOCK_METHOD((mw::E<void>), deleteGameField,
+                (std::int64_t field_id), (override));
+
+    /// Mock field usage count.
+    MOCK_METHOD((mw::E<std::int64_t>), countFieldUsage,
+                (std::int64_t field_id), (override));
+
+    /// Mock exact choice usage count.
+    MOCK_METHOD((mw::E<std::int64_t>), countChoiceUsage,
+                (std::int64_t field_id, const std::string& value),
+                (override));
+
+    /// Mock complete game-field ordering.
+    MOCK_METHOD((mw::E<void>), updateGameFieldOrder,
+                (const std::string& short_name,
+                 const std::vector<std::int64_t>& field_ids), (override));
+
+    /// Mock aggregate definition revision advancement.
+    MOCK_METHOD((mw::E<bool>), incrementGameRevision,
+                (const std::string& short_name,
+                 std::int64_t expected_revision), (override));
+
+    /// Mock transaction-local series ownership validation.
+    MOCK_METHOD((mw::E<bool>), seriesBelongToGame,
+                (const std::string& short_name,
+                 const std::vector<std::int64_t>& series_ids), (override));
+
     /// Mock the loose-card number collision query.
     MOCK_METHOD(
         (mw::E<bool>),
@@ -123,13 +184,12 @@ public:
     MOCK_METHOD((mw::E<std::int64_t>), incrementHolding,
                 (std::int64_t user_id, std::int64_t card_id), (override));
 
-    /// Mock insertion of a card and its dependent rows.
+    /// Mock insertion of a card and its dynamic dependent rows.
     MOCK_METHOD(
         (mw::E<std::int64_t>),
         insertCard,
         (const Card& card,
-         const GameDefinition* game,
-         const GameCardMetadata* metadata,
+         const std::vector<GameFieldValue>& field_values,
          const std::vector<std::int64_t>& series_ids),
         (override));
 
@@ -138,8 +198,7 @@ public:
         (mw::E<void>),
         updateCard,
         (const Card& card,
-         const GameDefinition* game,
-         const GameCardMetadata* metadata,
+         const std::vector<GameFieldValue>& field_values,
          const std::vector<std::int64_t>& series_ids),
         (override));
 
@@ -190,7 +249,7 @@ public:
     MOCK_METHOD(
         (mw::E<void>),
         migrateSchema0To1,
-        (const GameRegistry& games),
+        (),
         (override));
 
     /// Mock creation of a mutation transaction.
@@ -222,6 +281,20 @@ public:
         (const CardIdentity& identity),
         (const, override));
 
+    /// Mock retrieval of all database-defined games.
+    MOCK_METHOD((mw::E<std::vector<Game>>), getGames,
+                (), (const, override));
+
+    /// Mock retrieval of one complete game definition.
+    MOCK_METHOD((mw::E<std::optional<GameDefinitionSnapshot>>),
+                getGameDefinition, (const std::string& short_name),
+                (const, override));
+
+    /// Mock retrieval of a card's definition and custom values.
+    MOCK_METHOD((mw::E<std::optional<CardGameFields>>),
+                getCardFieldValues, (std::int64_t card_id),
+                (const, override));
+
     /// Mock retrieval of a user by internal identity.
     MOCK_METHOD((mw::E<std::optional<User>>), getUser,
                 (std::int64_t user_id), (const, override));
@@ -264,20 +337,6 @@ public:
     MOCK_METHOD((mw::E<void>), cleanupAuthentication,
                 (std::int64_t now), (override));
 
-    /// Mock retrieval of game-specific card display fields.
-    MOCK_METHOD(
-        (mw::E<std::vector<DisplayField>>),
-        getGameDisplayFields,
-        (const GameDefinition& game, std::int64_t card_id),
-        (const, override));
-
-    /// Mock retrieval of current game-owned edit values.
-    MOCK_METHOD(
-        (mw::E<FormFields>),
-        getGameFormValues,
-        (const GameDefinition& game, std::int64_t card_id),
-        (const, override));
-
     /// Mock retrieval of all series.
     MOCK_METHOD(
         (mw::E<std::vector<Series>>),
@@ -299,13 +358,6 @@ public:
         (std::int64_t card_id),
         (const, override));
 
-    /// Mock retrieval of all persisted game short names.
-    MOCK_METHOD(
-        (mw::E<std::vector<std::string>>),
-        getPersistedGameNames,
-        (),
-        (const, override));
-
     /// Mock mutation of the stored schema version.
     MOCK_METHOD(
         (mw::E<void>),
diff --git a/tests/data_sqlite_test.cpp b/tests/data_sqlite_test.cpp
index f177ddd..eca1044 100644
--- a/tests/data_sqlite_test.cpp
+++ b/tests/data_sqlite_test.cpp
@@ -1,7 +1,5 @@
 #include "data_sqlite.h"
-#include "game_registry.h"
 #include "startup.h"
-#include "test_game.h"
 
 #include <chrono>
 #include <cstdint>
@@ -84,16 +82,14 @@ TEST(DataSourceSQLiteTest, RejectsReadsBeforeMigration)
 
     EXPECT_FALSE(data_source->getSeries());
     EXPECT_FALSE(data_source->getSeries(1));
-    EXPECT_FALSE(data_source->getPersistedGameNames());
+    EXPECT_FALSE(data_source->getGames());
 }
 
 /// Verify startup prepares a fresh database for its first card-index read.
 TEST(DataSourceSQLiteTest, PreparesFreshDatabaseForStartup)
 {
     TemporaryDatabase database;
-    GameRegistry games;
-
-    auto data_source = prepareDataSource(database.path(), games);
+    auto data_source = prepareDataSource(database.path());
     ASSERT_TRUE(data_source);
 
     auto version = (*data_source)->getSchemaVersion();
@@ -197,8 +193,7 @@ TEST(DataSourceSQLiteTest, RejectsInvalidCards)
 TEST(DataSourceSQLiteTest, InsertsLooseCard)
 {
     TemporaryDatabase database;
-    GameRegistry games;
-    auto data_source = prepareDataSource(database.path(), games);
+    auto data_source = prepareDataSource(database.path());
     ASSERT_TRUE(data_source);
     auto transaction = (*data_source)->beginTransaction();
     ASSERT_TRUE(transaction);
@@ -207,7 +202,7 @@ TEST(DataSourceSQLiteTest, InsertsLooseCard)
     ASSERT_TRUE(exists_before);
     EXPECT_FALSE(*exists_before);
     auto card_id = (*transaction)->insertCard(
-        makeLooseCard(42, "New card"), nullptr, nullptr, {});
+        makeLooseCard(42, "New card"), {}, {});
     ASSERT_TRUE(card_id);
     EXPECT_GT(*card_id, 0);
     auto exists_after = (*transaction)->looseNumberExists(42);
@@ -227,13 +222,12 @@ TEST(DataSourceSQLiteTest, InsertsLooseCard)
 TEST(DataSourceSQLiteTest, UpdatesLooseCard)
 {
     TemporaryDatabase database;
-    GameRegistry games;
-    auto data_source = prepareDataSource(database.path(), games);
+    auto data_source = prepareDataSource(database.path());
     ASSERT_TRUE(data_source);
     auto transaction = (*data_source)->beginTransaction();
     ASSERT_TRUE(transaction);
     auto card_id = (*transaction)->insertCard(
-        makeLooseCard(42, "Original"), nullptr, nullptr, {});
+        makeLooseCard(42, "Original"), {}, {});
     ASSERT_TRUE(card_id);
     ASSERT_TRUE((*transaction)->commit());
 
@@ -247,7 +241,7 @@ TEST(DataSourceSQLiteTest, UpdatesLooseCard)
     (**card).foil_extension = "webp";
     (**card).revision = 2;
     ASSERT_TRUE((*transaction)->updateCard(
-        **card, nullptr, nullptr, {}));
+        **card, {}, {}));
     ASSERT_TRUE((*transaction)->commit());
 
     auto stored = (*data_source)->getCard({std::nullopt, 42});
@@ -265,14 +259,15 @@ TEST(DataSourceSQLiteTest, UpdatesLooseCard)
 TEST(DataSourceSQLiteTest, MutatesSeries)
 {
     TemporaryDatabase database;
-    GameRegistry games;
-    auto data_source = prepareDataSource(database.path(), games);
+    auto data_source = prepareDataSource(database.path());
     ASSERT_TRUE(data_source);
 
     auto transaction = (*data_source)->beginTransaction();
     ASSERT_TRUE(transaction);
+    ASSERT_TRUE((*transaction)->insertGame(
+        {"gh", "Going Home", "", 1}));
     auto series_id = (*transaction)->insertSeries(
-        {0, "test", "First series", "Description"});
+        {0, "gh", "First series", "Description"});
     ASSERT_TRUE(series_id);
     ASSERT_TRUE((*transaction)->commit());
 
@@ -303,98 +298,95 @@ TEST(DataSourceSQLiteTest, MutatesSeries)
     EXPECT_FALSE(*series);
 }
 
-/// Verify compiled-game schema, numbering, metadata, and memberships.
-TEST(DataSourceSQLiteTest, PersistsCompiledGameCards)
+/// Verify dynamic fields, numbering, values, and memberships persist.
+TEST(DataSourceSQLiteTest, PersistsDynamicGameCards)
 {
     TemporaryDatabase database;
-    GameRegistry games;
-    ASSERT_TRUE(games.add(std::make_unique<TestGame>()));
-    const GameDefinition* game = games.find("test");
-    ASSERT_NE(game, nullptr);
-    auto data_source = prepareDataSource(database.path(), games);
+    auto data_source = prepareDataSource(database.path());
     ASSERT_TRUE(data_source);
 
-    auto series_transaction = (*data_source)->beginTransaction();
-    ASSERT_TRUE(series_transaction);
-    auto series_id = (*series_transaction)->insertSeries(
-        {0, "test", "Core set", "The first set."});
-    ASSERT_TRUE(series_id);
-    ASSERT_TRUE((*series_transaction)->commit());
-
-    auto metadata = game->validateMetadata({{"hp", "60"}, {"attack", "20"}});
-    ASSERT_TRUE(metadata);
     auto transaction = (*data_source)->beginTransaction();
     ASSERT_TRUE(transaction);
+    ASSERT_TRUE((*transaction)->insertGame(
+        {"test", "Test Game", "Description", 1}));
+    auto hp_id = (*transaction)->insertGameField(
+        {0, "test", "hp", "HP", GameFieldType::INTEGER, 0, {}}, {});
+    auto note_id = (*transaction)->insertGameField(
+        {0, "test", "note", "Note", GameFieldType::STRING, 1, {}}, {});
+    auto route_id = (*transaction)->insertGameField(
+        {0, "test", "route", "Route", GameFieldType::CHOICE, 2, {}},
+        {{"Forest", 0}, {"Coast", 1}});
+    ASSERT_TRUE(hp_id);
+    ASSERT_TRUE(note_id);
+    ASSERT_TRUE(route_id);
+    auto series_id = (*transaction)->insertSeries(
+        {0, "test", "Core set", "The first set."});
+    ASSERT_TRUE(series_id);
     auto number = (*transaction)->allocateGameNumber("test");
     ASSERT_TRUE(number);
     Card card = {
-        0,
-        {"test", *number},
-        "Compiled card",
-        std::nullopt,
-        std::nullopt,
-        2,
-        "avif",
-        std::nullopt,
-        "avif",
-        1,
-        1,
-    };
+        0, {"test", *number}, "Dynamic card", std::nullopt,
+        std::nullopt, 2, "avif", std::nullopt, "avif", 1, 1};
     auto card_id = (*transaction)->insertCard(
-        card, game, metadata->get(), {*series_id});
+        card,
+        {{*hp_id, GameFieldType::INTEGER, std::int64_t{60}},
+         {*note_id, GameFieldType::STRING, std::string("Near home")},
+         {*route_id, GameFieldType::CHOICE, std::string("Forest")}},
+        {*series_id});
     ASSERT_TRUE(card_id);
     ASSERT_TRUE((*transaction)->commit());
 
     auto stored = (*data_source)->getCard({"test", 1});
     ASSERT_TRUE(stored);
     ASSERT_TRUE(*stored);
-    EXPECT_EQ((**stored).id, *card_id);
-    auto memberships = (*data_source)->getCardSeries(*card_id);
-    ASSERT_TRUE(memberships);
-    EXPECT_EQ(*memberships, std::vector<std::int64_t>{*series_id});
-    auto display = (*data_source)->getGameDisplayFields(*game, *card_id);
-    ASSERT_TRUE(display);
-    ASSERT_EQ(display->size(), 2);
-    EXPECT_EQ((*display)[0].value, "60");
-    EXPECT_EQ((*display)[1].value, "20");
-    auto form_values = (*data_source)->getGameFormValues(
-        *game, *card_id);
-    ASSERT_TRUE(form_values);
-    EXPECT_EQ(form_values->at("hp"), "60");
-    EXPECT_EQ(form_values->at("attack"), "20");
-
-    metadata = game->validateMetadata({{"hp", "70"}, {"attack", "30"}});
-    ASSERT_TRUE(metadata);
+    auto values = (*data_source)->getCardFieldValues(*card_id);
+    ASSERT_TRUE(values);
+    ASSERT_TRUE(*values);
+    ASSERT_EQ((**values).definition.fields.size(), 3);
+    ASSERT_EQ((**values).values.size(), 3);
+    EXPECT_EQ(std::get<std::int64_t>((**values).values[0].value), 60);
+    EXPECT_EQ(std::get<std::string>((**values).values[1].value), "Near home");
+    EXPECT_EQ(std::get<std::string>((**values).values[2].value), "Forest");
+
     Card updated = **stored;
     updated.revision = 2;
     transaction = (*data_source)->beginTransaction();
     ASSERT_TRUE(transaction);
     ASSERT_TRUE((*transaction)->updateCard(
-        updated, game, metadata->get(), {}));
+        updated,
+        {{*hp_id, GameFieldType::INTEGER, std::int64_t{70}}},
+        {}));
+    ASSERT_TRUE((*transaction)->commit());
+    values = (*data_source)->getCardFieldValues(*card_id);
+    ASSERT_TRUE(values);
+    ASSERT_TRUE(*values);
+    ASSERT_EQ((**values).values.size(), 1);
+    EXPECT_EQ(std::get<std::int64_t>((**values).values[0].value), 70);
+
+    transaction = (*data_source)->beginTransaction();
+    ASSERT_TRUE(transaction);
+    auto later_id = (*transaction)->insertGameField(
+        {0, "test", "later", "Later", GameFieldType::STRING, 3, {}},
+        {});
+    ASSERT_TRUE(later_id);
     ASSERT_TRUE((*transaction)->commit());
-    memberships = (*data_source)->getCardSeries(*card_id);
-    ASSERT_TRUE(memberships);
-    EXPECT_TRUE(memberships->empty());
-    display = (*data_source)->getGameDisplayFields(*game, *card_id);
-    ASSERT_TRUE(display);
-    EXPECT_EQ((*display)[0].value, "70");
-    form_values = (*data_source)->getGameFormValues(*game, *card_id);
-    ASSERT_TRUE(form_values);
-    EXPECT_EQ(form_values->at("hp"), "70");
-    EXPECT_EQ(form_values->at("attack"), "30");
+    values = (*data_source)->getCardFieldValues(*card_id);
+    ASSERT_TRUE(values);
+    ASSERT_TRUE(*values);
+    EXPECT_EQ((**values).definition.fields.size(), 4);
+    EXPECT_EQ((**values).values.size(), 1);
 }
 
 /// Verify destroying an uncommitted transaction rolls card insertion back.
 TEST(DataSourceSQLiteTest, RollsBackLooseCard)
 {
     TemporaryDatabase database;
-    GameRegistry games;
-    auto data_source = prepareDataSource(database.path(), games);
+    auto data_source = prepareDataSource(database.path());
     ASSERT_TRUE(data_source);
     auto transaction = (*data_source)->beginTransaction();
     ASSERT_TRUE(transaction);
     ASSERT_TRUE((*transaction)->insertCard(
-        makeLooseCard(91, "Temporary card"), nullptr, nullptr, {}));
+        makeLooseCard(91, "Temporary card"), {}, {}));
 
     transaction->reset();
 
@@ -407,15 +399,14 @@ TEST(DataSourceSQLiteTest, RollsBackLooseCard)
 TEST(DataSourceSQLiteTest, AllocatesGameNumbers)
 {
     TemporaryDatabase database;
-    GameRegistry games;
-    auto data_source = prepareDataSource(database.path(), games);
+    auto data_source = prepareDataSource(database.path());
     ASSERT_TRUE(data_source);
     auto transaction = (*data_source)->beginTransaction();
     ASSERT_TRUE(transaction);
-    ASSERT_TRUE((*transaction)->ensureGameSequence("pkm"));
-
-    auto first = (*transaction)->allocateGameNumber("pkm");
-    auto second = (*transaction)->allocateGameNumber("pkm");
+    ASSERT_TRUE((*transaction)->insertGame(
+        {"gh", "Going Home", "", 1}));
+    auto first = (*transaction)->allocateGameNumber("gh");
+    auto second = (*transaction)->allocateGameNumber("gh");
     ASSERT_TRUE(first);
     ASSERT_TRUE(second);
     EXPECT_EQ(*first, 1);
@@ -424,7 +415,7 @@ TEST(DataSourceSQLiteTest, AllocatesGameNumbers)
 
     auto next_transaction = (*data_source)->beginTransaction();
     ASSERT_TRUE(next_transaction);
-    auto third = (*next_transaction)->allocateGameNumber("pkm");
+    auto third = (*next_transaction)->allocateGameNumber("gh");
     ASSERT_TRUE(third);
     EXPECT_EQ(*third, 3);
 }
@@ -433,13 +424,12 @@ TEST(DataSourceSQLiteTest, AllocatesGameNumbers)
 TEST(DataSourceSQLiteTest, RejectsDuplicateLooseNumber)
 {
     TemporaryDatabase database;
-    GameRegistry games;
-    auto data_source = prepareDataSource(database.path(), games);
+    auto data_source = prepareDataSource(database.path());
     ASSERT_TRUE(data_source);
     auto first_transaction = (*data_source)->beginTransaction();
     ASSERT_TRUE(first_transaction);
     ASSERT_TRUE((*first_transaction)->insertCard(
-        makeLooseCard(8, "First"), nullptr, nullptr, {}));
+        makeLooseCard(8, "First"), {}, {}));
     ASSERT_TRUE((*first_transaction)->commit());
 
     auto second_transaction = (*data_source)->beginTransaction();
@@ -448,16 +438,15 @@ TEST(DataSourceSQLiteTest, RejectsDuplicateLooseNumber)
     ASSERT_TRUE(exists);
     EXPECT_TRUE(*exists);
     EXPECT_FALSE((*second_transaction)->insertCard(
-        makeLooseCard(8, "Duplicate"), nullptr, nullptr, {}));
+        makeLooseCard(8, "Duplicate"), {}, {}));
 }
 
 /// Verify administrator metadata is immutable and exactly one row is kept.
 TEST(DataSourceSQLiteTest, ReconcilesImmutableAdministrator)
 {
     TemporaryDatabase database;
-    GameRegistry games;
     auto data_source = prepareDataSource(
-        database.path(), games, "Admin@Example.com", "admin@example.com",
+        database.path(), "Admin@Example.com", "admin@example.com",
         100, 0);
     ASSERT_TRUE(data_source) << data_source.error().msg();
 
@@ -494,8 +483,7 @@ TEST(DataSourceSQLiteTest, ReconcilesImmutableAdministrator)
 TEST(DataSourceSQLiteTest, PersistsMvpAccountAndCollectionState)
 {
     TemporaryDatabase database;
-    GameRegistry games;
-    auto data_source = prepareDataSource(database.path(), games);
+    auto data_source = prepareDataSource(database.path());
     ASSERT_TRUE(data_source) << data_source.error().msg();
     auto administrator = (*data_source)->getUserByEmailKey(
         "admin@example.com");
@@ -597,7 +585,7 @@ TEST(DataSourceSQLiteTest, PersistsMvpAccountAndCollectionState)
     ASSERT_TRUE(transaction);
     Card card = makeLooseCard(123, "Collectible");
     card.creator_user_id = (**administrator).id;
-    auto card_id = (*transaction)->insertCard(card, nullptr, nullptr, {});
+    auto card_id = (*transaction)->insertCard(card, {}, {});
     ASSERT_TRUE(card_id) << card_id.error().msg();
     auto quantity = (*transaction)->incrementHolding(*player_id, *card_id);
     ASSERT_TRUE(quantity);
@@ -638,3 +626,237 @@ TEST(DataSourceSQLiteTest, RejectsObsoletePrototypeSchema)
     EXPECT_NE(version.error().msg().find("delete and recreate"),
               std::string::npos);
 }
+
+/// Verify fresh initialization and reopening do not create any games.
+TEST(DataSourceSQLiteTest, StartsAndReopensWithoutGames)
+{
+    TemporaryDatabase database;
+    auto data_source = prepareDataSource(database.path());
+    ASSERT_TRUE(data_source) << data_source.error().msg();
+
+    auto games = (*data_source)->getGames();
+    ASSERT_TRUE(games);
+    EXPECT_TRUE(games->empty());
+    data_source->reset();
+
+    data_source = prepareDataSource(database.path());
+    ASSERT_TRUE(data_source) << data_source.error().msg();
+    games = (*data_source)->getGames();
+    ASSERT_TRUE(games);
+    EXPECT_TRUE(games->empty());
+}
+
+/// Verify failed initialization rolls back newly attempted schema objects.
+TEST(DataSourceSQLiteTest, RollsBackFailedInitialization)
+{
+    TemporaryDatabase database;
+    auto connection = mw::SQLite::connectFile(database.path().string());
+    ASSERT_TRUE(connection);
+    ASSERT_TRUE((*connection)->execute(
+        "CREATE TABLE games(existing_marker TEXT);"));
+    connection->reset();
+
+    auto data_source = DataSourceSQLite::fromFile(database.path());
+    ASSERT_TRUE(data_source);
+    EXPECT_FALSE((*data_source)->migrateToLatest());
+    data_source->reset();
+
+    connection = mw::SQLite::connectFile(database.path().string());
+    ASSERT_TRUE(connection);
+    auto users = (*connection)->evalToValue<int>(
+        "SELECT EXISTS(SELECT 1 FROM sqlite_schema "
+        "WHERE type = 'table' AND name = 'users');");
+    ASSERT_TRUE(users);
+    EXPECT_EQ(*users, 0);
+    auto marker = (*connection)->evalToValue<int>(
+        "SELECT EXISTS(SELECT 1 FROM sqlite_schema "
+        "WHERE type = 'table' AND name = 'games');");
+    ASSERT_TRUE(marker);
+    EXPECT_EQ(*marker, 1);
+}
+
+/// Verify future schema versions are rejected without modifying their data.
+TEST(DataSourceSQLiteTest, RejectsFutureSchemaWithoutModification)
+{
+    TemporaryDatabase database;
+    auto connection = mw::SQLite::connectFile(database.path().string());
+    ASSERT_TRUE(connection);
+    ASSERT_TRUE((*connection)->execute(
+        "CREATE TABLE future_marker(value TEXT);"));
+    ASSERT_TRUE((*connection)->execute(
+        "INSERT INTO future_marker VALUES ('kept');"));
+    ASSERT_TRUE((*connection)->execute("PRAGMA user_version = 2;"));
+    connection->reset();
+
+    auto data_source = DataSourceSQLite::fromFile(database.path());
+    ASSERT_TRUE(data_source);
+    EXPECT_FALSE((*data_source)->migrateToLatest());
+    data_source->reset();
+
+    connection = mw::SQLite::connectFile(database.path().string());
+    ASSERT_TRUE(connection);
+    auto value = (*connection)->evalToValue<std::string>(
+        "SELECT value FROM future_marker;");
+    ASSERT_TRUE(value);
+    EXPECT_EQ(*value, "kept");
+    auto version = (*connection)->evalToValue<std::int64_t>(
+        "PRAGMA user_version;");
+    ASSERT_TRUE(version);
+    EXPECT_EQ(*version, 2);
+}
+
+/// Verify startup rejects a version-1 game missing its number sequence.
+TEST(DataSourceSQLiteTest, RejectsMissingGameSequence)
+{
+    TemporaryDatabase database;
+    auto data_source = prepareDataSource(database.path());
+    ASSERT_TRUE(data_source) << data_source.error().msg();
+    auto transaction = (*data_source)->beginTransaction();
+    ASSERT_TRUE(transaction);
+    ASSERT_TRUE((*transaction)->insertGame(
+        {"test", "Test Game", "", 1}));
+    ASSERT_TRUE((*transaction)->commit());
+    data_source->reset();
+
+    auto connection = mw::SQLite::connectFile(database.path().string());
+    ASSERT_TRUE(connection);
+    ASSERT_TRUE((*connection)->execute(
+        "DELETE FROM game_sequences WHERE game_short_name = 'test';"));
+    connection->reset();
+
+    auto reopened = prepareDataSource(database.path());
+    ASSERT_FALSE(reopened);
+    EXPECT_NE(reopened.error().msg().find("missing its card-number sequence"),
+              std::string::npos);
+}
+
+/// Verify database constraints reject cross-game and mistyped field values.
+TEST(DataSourceSQLiteTest, EnforcesDynamicFieldInvariants)
+{
+    TemporaryDatabase database;
+    auto data_source = prepareDataSource(database.path());
+    ASSERT_TRUE(data_source) << data_source.error().msg();
+    auto transaction = (*data_source)->beginTransaction();
+    ASSERT_TRUE(transaction);
+    ASSERT_TRUE((*transaction)->insertGame(
+        {"one", "One", "", 1}));
+    ASSERT_TRUE((*transaction)->insertGame(
+        {"two", "Two", "", 1}));
+    auto integer_id = (*transaction)->insertGameField(
+        {0, "one", "score", "Score", GameFieldType::INTEGER, 0, {}},
+        {});
+    auto first_choice_id = (*transaction)->insertGameField(
+        {0, "one", "route", "Route", GameFieldType::CHOICE, 1, {}},
+        {{"Shared", 0}});
+    auto second_choice_id = (*transaction)->insertGameField(
+        {0, "one", "color", "Color", GameFieldType::CHOICE, 2, {}},
+        {{"Other", 0}});
+    ASSERT_TRUE(integer_id);
+    ASSERT_TRUE(first_choice_id);
+    ASSERT_TRUE(second_choice_id);
+    ASSERT_TRUE((*transaction)->commit());
+
+    transaction = (*data_source)->beginTransaction();
+    ASSERT_TRUE(transaction);
+    auto one_number = (*transaction)->allocateGameNumber("one");
+    ASSERT_TRUE(one_number);
+    Card one_card = makeLooseCard(*one_number, "One card");
+    one_card.identity.game_short_name = "one";
+    EXPECT_FALSE((*transaction)->insertCard(
+        one_card,
+        {{*integer_id, GameFieldType::STRING, std::string("5")}},
+        {}));
+    transaction->reset();
+
+    transaction = (*data_source)->beginTransaction();
+    ASSERT_TRUE(transaction);
+    auto two_number = (*transaction)->allocateGameNumber("two");
+    ASSERT_TRUE(two_number);
+    Card two_card = makeLooseCard(*two_number, "Two card");
+    two_card.identity.game_short_name = "two";
+    EXPECT_FALSE((*transaction)->insertCard(
+        two_card,
+        {{*integer_id, GameFieldType::INTEGER, std::int64_t{5}}},
+        {}));
+    transaction->reset();
+
+    transaction = (*data_source)->beginTransaction();
+    ASSERT_TRUE(transaction);
+    one_number = (*transaction)->allocateGameNumber("one");
+    ASSERT_TRUE(one_number);
+    one_card.identity.card_number = *one_number;
+    EXPECT_FALSE((*transaction)->insertCard(
+        one_card,
+        {{*first_choice_id,
+          GameFieldType::CHOICE,
+          std::string("Other")}},
+        {}));
+}
+
+/// Verify definition identities are immutable and card values cascade.
+TEST(DataSourceSQLiteTest, PreservesDynamicIdentitiesAndCascadesValues)
+{
+    TemporaryDatabase database;
+    auto data_source = prepareDataSource(database.path());
+    ASSERT_TRUE(data_source) << data_source.error().msg();
+    auto transaction = (*data_source)->beginTransaction();
+    ASSERT_TRUE(transaction);
+    ASSERT_TRUE((*transaction)->insertGame(
+        {"fixed", "Fixed", "", 1}));
+    auto field_id = (*transaction)->insertGameField(
+        {0, "fixed", "score", "Score", GameFieldType::INTEGER, 0, {}},
+        {});
+    auto choice_id = (*transaction)->insertGameField(
+        {0, "fixed", "route", "Route", GameFieldType::CHOICE, 1, {}},
+        {{"Home", 0}});
+    ASSERT_TRUE(field_id);
+    ASSERT_TRUE(choice_id);
+    auto number = (*transaction)->allocateGameNumber("fixed");
+    ASSERT_TRUE(number);
+    Card card = makeLooseCard(*number, "Fixed card");
+    card.identity.game_short_name = "fixed";
+    auto card_id = (*transaction)->insertCard(
+        card,
+        {{*field_id, GameFieldType::INTEGER, std::int64_t{9}}},
+        {});
+    ASSERT_TRUE(card_id);
+    ASSERT_TRUE((*transaction)->commit());
+
+    transaction = (*data_source)->beginTransaction();
+    ASSERT_TRUE(transaction);
+    auto stored = (*transaction)->getCardForUpdate(*card_id);
+    ASSERT_TRUE(stored);
+    ASSERT_TRUE(*stored);
+    (**stored).identity.card_number = 99;
+    EXPECT_FALSE((*transaction)->updateCard(
+        **stored,
+        {{*field_id, GameFieldType::INTEGER, std::int64_t{10}}},
+        {}));
+    transaction->reset();
+
+    transaction = (*data_source)->beginTransaction();
+    ASSERT_TRUE(transaction);
+    ASSERT_TRUE((*transaction)->deleteCard(*card_id));
+    ASSERT_TRUE((*transaction)->commit());
+    data_source->reset();
+
+    auto connection = mw::SQLite::connectFile(database.path().string());
+    ASSERT_TRUE(connection);
+    ASSERT_TRUE((*connection)->execute("PRAGMA foreign_keys = ON;"));
+    EXPECT_FALSE((*connection)->execute(
+        "UPDATE games SET short_name = 'changed' "
+        "WHERE short_name = 'fixed';"));
+    EXPECT_FALSE((*connection)->execute(
+        "UPDATE game_fields SET key = 'changed' "
+        "WHERE id = 1;"));
+    EXPECT_FALSE((*connection)->execute(
+        "INSERT INTO game_field_choices(field_id, value, position) "
+        "VALUES (1, 'Invalid', 0);"));
+    EXPECT_FALSE((*connection)->execute(
+        "UPDATE game_field_choices SET value = 'Changed' "
+        "WHERE field_id = 2 AND value = 'Home';"));
+    auto value_count = (*connection)->evalToValue<std::int64_t>(
+        "SELECT COUNT(*) FROM card_field_values;");
+    ASSERT_TRUE(value_count);
+    EXPECT_EQ(*value_count, 0);
+}
diff --git a/tests/game_field_form_logic_test.js b/tests/game_field_form_logic_test.js
new file mode 100644
index 0000000..f665e89
--- /dev/null
+++ b/tests/game_field_form_logic_test.js
@@ -0,0 +1,13 @@
+const test = require("node:test");
+const assert = require("node:assert/strict");
+const logic = require("../static/game_field_form.js");
+
+test("choice movement stays within the ordered list", function()
+{
+    assert.equal(logic.choiceMoveIndex(1, -1, 3), 0);
+    assert.equal(logic.choiceMoveIndex(1, 1, 3), 2);
+    assert.equal(logic.choiceMoveIndex(0, -1, 3), 0);
+    assert.equal(logic.choiceMoveIndex(2, 1, 3), 2);
+    assert.equal(logic.choiceMoveIndex(-1, 1, 3), -1);
+    assert.equal(logic.choiceMoveIndex(0, 1, 0), 0);
+});
diff --git a/tests/game_field_test.cpp b/tests/game_field_test.cpp
new file mode 100644
index 0000000..8b08b69
--- /dev/null
+++ b/tests/game_field_test.cpp
@@ -0,0 +1,108 @@
+#include <cstdint>
+#include <limits>
+#include <string>
+#include <variant>
+
+#include <gtest/gtest.h>
+
+#include "game_field.h"
+
+namespace
+{
+
+GameDefinitionSnapshot definition()
+{
+    return {
+        {"test", "Test", "", 1},
+        {
+            {1, "test", "score", "Score", GameFieldType::INTEGER, 0, {}},
+            {2, "test", "note", "Note", GameFieldType::STRING, 1, {}},
+            {3, "test", "route", "Route", GameFieldType::CHOICE, 2,
+             {{"Home", 0}, {"Forest", 1}, {"A&B+ C", 2}}},
+        },
+    };
+}
+
+TEST(GameFieldTest, DecodesAllSupportedTypesAndUnsetValues)
+{
+    const auto decoded = decodeGameFields(definition(), {
+        {"score", "-7"}, {"note", "Near\nthe station"},
+        {"route", "Forest"},
+    });
+    ASSERT_TRUE(decoded) << decoded.error().msg();
+    ASSERT_EQ(decoded->size(), 3);
+    EXPECT_EQ(std::get<std::int64_t>((*decoded)[0].value), -7);
+    EXPECT_EQ(
+        std::get<std::string>((*decoded)[1].value), "Near\nthe station");
+    EXPECT_EQ(std::get<std::string>((*decoded)[2].value), "Forest");
+
+    const auto empty = decodeGameFields(definition(), {
+        {"score", ""}, {"note", ""}, {"route", ""},
+    });
+    ASSERT_TRUE(empty);
+    EXPECT_TRUE(empty->empty());
+}
+
+TEST(GameFieldTest, AcceptsFullSignedIntegerRange)
+{
+    auto smallest = decodeGameFields(definition(), {{
+        "score", std::to_string(std::numeric_limits<std::int64_t>::min())}});
+    auto largest = decodeGameFields(definition(), {{
+        "score", std::to_string(std::numeric_limits<std::int64_t>::max())}});
+    ASSERT_TRUE(smallest);
+    ASSERT_TRUE(largest);
+    EXPECT_EQ(
+        std::get<std::int64_t>(smallest->front().value),
+        std::numeric_limits<std::int64_t>::min());
+    EXPECT_EQ(
+        std::get<std::int64_t>(largest->front().value),
+        std::numeric_limits<std::int64_t>::max());
+
+    auto zero = decodeGameFields(definition(), {{"score", "0"}});
+    auto leading_zero = decodeGameFields(
+        definition(), {{"score", "007"}});
+    ASSERT_TRUE(zero);
+    ASSERT_TRUE(leading_zero);
+    EXPECT_EQ(std::get<std::int64_t>(zero->front().value), 0);
+    EXPECT_EQ(std::get<std::int64_t>(leading_zero->front().value), 7);
+}
+
+TEST(GameFieldTest, RejectsMalformedIntegersAndUnknownChoices)
+{
+    for(const std::string& value : {
+            "+1", " 1", "1 ", "1.0", "1e2", "1x",
+            "9223372036854775808", "-9223372036854775809"})
+    {
+        EXPECT_FALSE(decodeGameFields(definition(), {{"score", value}}))
+            << value;
+    }
+    EXPECT_FALSE(decodeGameFields(definition(), {{"route", "home"}}));
+    EXPECT_FALSE(decodeGameFields(definition(), {{"route", "Home "}}));
+    EXPECT_FALSE(decodeGameFields(definition(), {{"route", "Unknown"}}));
+    auto punctuation = decodeGameFields(
+        definition(), {{"route", "A&B+ C"}});
+    ASSERT_TRUE(punctuation);
+    EXPECT_EQ(
+        std::get<std::string>(punctuation->front().value), "A&B+ C");
+}
+
+TEST(GameFieldTest, RejectsUnknownKeysAndInvalidText)
+{
+    EXPECT_FALSE(decodeGameFields(definition(), {{"other", "value"}}));
+    EXPECT_FALSE(decodeGameFields(
+        definition(), {{"note", std::string("bad\0text", 8)}}));
+    EXPECT_FALSE(decodeGameFields(
+        definition(), {{"note", std::string("\xc3", 1)}}));
+    const auto whitespace = decodeGameFields(
+        definition(), {{"note", "  \n"}});
+    ASSERT_TRUE(whitespace);
+    EXPECT_EQ(std::get<std::string>(whitespace->front().value), "  \n");
+
+    const std::string large_text(2 * 1024 * 1024, 'x');
+    auto large = decodeGameFields(
+        definition(), {{"note", large_text}});
+    ASSERT_TRUE(large);
+    EXPECT_EQ(std::get<std::string>(large->front().value), large_text);
+}
+
+} // namespace
diff --git a/tests/game_service_test.cpp b/tests/game_service_test.cpp
new file mode 100644
index 0000000..189fb4a
--- /dev/null
+++ b/tests/game_service_test.cpp
@@ -0,0 +1,301 @@
+#include "game_service.h"
+
+#include <chrono>
+#include <cstdint>
+#include <filesystem>
+#include <memory>
+#include <optional>
+#include <string>
+#include <vector>
+
+#include <gtest/gtest.h>
+
+#include "startup.h"
+
+namespace
+{
+
+class TemporaryDatabase
+{
+public:
+    /// Allocate a unique database path for one test.
+    TemporaryDatabase()
+            : path_(
+                std::filesystem::path(testing::TempDir()) /
+                ("game_service_" + std::to_string(
+                    std::chrono::steady_clock::now()
+                        .time_since_epoch().count()) + ".sqlite3"))
+    {}
+
+    /// Remove the database and SQLite sidecars.
+    ~TemporaryDatabase()
+    {
+        std::error_code error;
+        std::filesystem::remove(path_, error);
+        std::filesystem::remove(path_.string() + "-shm", error);
+        std::filesystem::remove(path_.string() + "-wal", error);
+    }
+
+    /// Return the allocated path.
+    const std::filesystem::path& path() const
+    {
+        return path_;
+    }
+
+private:
+    std::filesystem::path path_;
+};
+
+struct ServiceFixture
+{
+    std::unique_ptr<DataSourceInterface> data_source;
+    std::int64_t administrator_id;
+    std::int64_t player_id;
+};
+
+mw::E<ServiceFixture> prepareFixture(const std::filesystem::path& path)
+{
+    auto data_source = prepareDataSource(path);
+    if(!data_source)
+    {
+        return std::unexpected(std::move(data_source.error()));
+    }
+    auto transaction = (*data_source)->beginTransaction();
+    if(!transaction)
+    {
+        return std::unexpected(std::move(transaction.error()));
+    }
+    auto named = (*transaction)->updateUsername(
+        1, "Administrator", "administrator");
+    if(!named)
+    {
+        return std::unexpected(std::move(named.error()));
+    }
+    User player = {
+        0,
+        "player@example.com",
+        "player@example.com",
+        std::nullopt,
+        UserRole::PLAYER,
+        1,
+        0,
+        0};
+    auto player_id = (*transaction)->insertUser(player);
+    if(!player_id)
+    {
+        return std::unexpected(std::move(player_id.error()));
+    }
+    named = (*transaction)->updateUsername(
+        *player_id, "Player", "player");
+    if(!named)
+    {
+        return std::unexpected(std::move(named.error()));
+    }
+    auto committed = (*transaction)->commit();
+    if(!committed)
+    {
+        return std::unexpected(std::move(committed.error()));
+    }
+    return ServiceFixture{std::move(*data_source), 1, *player_id};
+}
+
+const mw::HTTPError* httpError(const mw::Error& error)
+{
+    return error.as<mw::HTTPError>();
+}
+
+} // namespace
+
+/// Verify complete game definitions mutate atomically and advance revisions.
+TEST(GameServiceTest, MutatesDefinitionsAndRejectsStaleRevisions)
+{
+    TemporaryDatabase database;
+    auto fixture = prepareFixture(database.path());
+    ASSERT_TRUE(fixture) << fixture.error().msg();
+    GameService service(*fixture->data_source);
+
+    auto created = service.createGame(
+        fixture->administrator_id, "demo", " Demo Game ", "A **game**.");
+    ASSERT_TRUE(created) << created.error().msg();
+    EXPECT_EQ(*created, "demo");
+
+    auto integer_id = service.createField(
+        fixture->administrator_id,
+        "demo",
+        1,
+        "score",
+        " Score ",
+        GameFieldType::INTEGER,
+        {});
+    ASSERT_TRUE(integer_id) << integer_id.error().msg();
+    auto string_id = service.createField(
+        fixture->administrator_id,
+        "demo",
+        2,
+        "note",
+        "Note",
+        GameFieldType::STRING,
+        {});
+    ASSERT_TRUE(string_id) << string_id.error().msg();
+    auto choice_id = service.createField(
+        fixture->administrator_id,
+        "demo",
+        3,
+        "route",
+        "Route",
+        GameFieldType::CHOICE,
+        {"Forest", "Coast"});
+    ASSERT_TRUE(choice_id) << choice_id.error().msg();
+
+    auto duplicate_choices = service.createField(
+        fixture->administrator_id,
+        "demo",
+        4,
+        "duplicate",
+        "Duplicate",
+        GameFieldType::CHOICE,
+        {"Same", "Same"});
+    ASSERT_FALSE(duplicate_choices);
+    ASSERT_NE(httpError(duplicate_choices.error()), nullptr);
+    EXPECT_EQ(httpError(duplicate_choices.error())->code, 409);
+
+    auto stale = service.updateGame(
+        fixture->administrator_id, "demo", 3, "Stale", "");
+    ASSERT_FALSE(stale);
+    ASSERT_NE(httpError(stale.error()), nullptr);
+    EXPECT_EQ(httpError(stale.error())->code, 409);
+
+    ASSERT_TRUE(service.reorderFields(
+        fixture->administrator_id,
+        "demo",
+        4,
+        {*choice_id, *string_id, *integer_id}));
+    ASSERT_TRUE(service.updateField(
+        fixture->administrator_id,
+        "demo",
+        *choice_id,
+        5,
+        "Path",
+        {"Coast", "Forest", "Town"}));
+    ASSERT_TRUE(service.updateGame(
+        fixture->administrator_id,
+        "demo",
+        6,
+        "Renamed Game",
+        "New description"));
+
+    auto definition = fixture->data_source->getGameDefinition("demo");
+    ASSERT_TRUE(definition);
+    ASSERT_TRUE(*definition);
+    EXPECT_EQ((**definition).game.display_name, "Renamed Game");
+    EXPECT_EQ((**definition).game.revision, 7);
+    ASSERT_EQ((**definition).fields.size(), 3);
+    EXPECT_EQ((**definition).fields[0].id, *choice_id);
+    EXPECT_EQ((**definition).fields[0].label, "Path");
+    ASSERT_EQ((**definition).fields[0].choices.size(), 3);
+    EXPECT_EQ((**definition).fields[0].choices[0].value, "Coast");
+    EXPECT_EQ((**definition).fields[0].choices[2].value, "Town");
+}
+
+/// Verify definition authorization, identity validation, and usage conflicts.
+TEST(GameServiceTest, EnforcesAuthorizationAndDeletionRules)
+{
+    TemporaryDatabase database;
+    auto fixture = prepareFixture(database.path());
+    ASSERT_TRUE(fixture) << fixture.error().msg();
+    GameService service(*fixture->data_source);
+
+    auto forbidden = service.createGame(
+        fixture->player_id, "player", "Player Game", "");
+    ASSERT_FALSE(forbidden);
+    ASSERT_NE(httpError(forbidden.error()), nullptr);
+    EXPECT_EQ(httpError(forbidden.error())->code, 403);
+
+    auto invalid = service.createGame(
+        fixture->administrator_id, "d\xc3\xa9mo", "Invalid", "");
+    ASSERT_FALSE(invalid);
+    const auto* validation =
+        invalid.error().as<GameDefinitionValidationError>();
+    ASSERT_NE(validation, nullptr);
+    EXPECT_EQ(validation->field_name, "short_name");
+
+    ASSERT_TRUE(service.createGame(
+        fixture->administrator_id, "used", "Used Game", ""));
+    auto choice_id = service.createField(
+        fixture->administrator_id,
+        "used",
+        1,
+        "route",
+        "Route",
+        GameFieldType::CHOICE,
+        {"Forest", "Coast"});
+    ASSERT_TRUE(choice_id) << choice_id.error().msg();
+
+    auto transaction = fixture->data_source->beginTransaction();
+    ASSERT_TRUE(transaction);
+    auto number = (*transaction)->allocateGameNumber("used");
+    ASSERT_TRUE(number);
+    Card card = {
+        0,
+        {"used", *number},
+        "Used card",
+        std::nullopt,
+        std::nullopt,
+        0,
+        "avif",
+        std::nullopt,
+        "avif",
+        1,
+        fixture->administrator_id};
+    auto card_id = (*transaction)->insertCard(
+        card,
+        {{*choice_id, GameFieldType::CHOICE, std::string("Forest")}},
+        {});
+    ASSERT_TRUE(card_id) << card_id.error().msg();
+    ASSERT_TRUE((*transaction)->commit());
+
+    auto used_choice = service.updateField(
+        fixture->administrator_id,
+        "used",
+        *choice_id,
+        2,
+        "Route",
+        {"Coast"});
+    ASSERT_FALSE(used_choice);
+    ASSERT_NE(httpError(used_choice.error()), nullptr);
+    EXPECT_EQ(httpError(used_choice.error())->code, 409);
+    EXPECT_NE(httpError(used_choice.error())->msg.find("1 card"),
+              std::string::npos);
+
+    auto used_field = service.removeField(
+        fixture->administrator_id, "used", *choice_id, 2);
+    ASSERT_FALSE(used_field);
+    ASSERT_NE(httpError(used_field.error()), nullptr);
+    EXPECT_EQ(httpError(used_field.error())->code, 409);
+
+    auto used_game = service.removeGame(
+        fixture->administrator_id, "used", 2);
+    ASSERT_FALSE(used_game);
+    ASSERT_NE(httpError(used_game.error()), nullptr);
+    EXPECT_EQ(httpError(used_game.error())->code, 409);
+
+    transaction = fixture->data_source->beginTransaction();
+    ASSERT_TRUE(transaction);
+    ASSERT_TRUE((*transaction)->deleteCard(*card_id));
+    ASSERT_TRUE((*transaction)->commit());
+    ASSERT_TRUE(service.removeField(
+        fixture->administrator_id, "used", *choice_id, 2));
+    auto issued_game = service.removeGame(
+        fixture->administrator_id, "used", 3);
+    ASSERT_FALSE(issued_game);
+    ASSERT_NE(httpError(issued_game.error()), nullptr);
+    EXPECT_EQ(httpError(issued_game.error())->code, 409);
+
+    ASSERT_TRUE(service.createGame(
+        fixture->administrator_id, "empty", "Empty Game", ""));
+    ASSERT_TRUE(service.removeGame(
+        fixture->administrator_id, "empty", 1));
+    auto removed = fixture->data_source->getGameDefinition("empty");
+    ASSERT_TRUE(removed);
+    EXPECT_FALSE(*removed);
+}
diff --git a/tests/test_game.h b/tests/test_game.h
deleted file mode 100644
index c1fb7ee..0000000
--- a/tests/test_game.h
+++ /dev/null
@@ -1,222 +0,0 @@
-#pragma once
-
-#include <cstdint>
-#include <memory>
-#include <string>
-#include <string_view>
-#include <tuple>
-#include <utility>
-#include <vector>
-
-#include <mw/utils.hpp>
-
-#include "game_definition.h"
-
-/// Strongly typed metadata used by the compiled-game test fixture.
-class TestGameMetadata final : public GameCardMetadata
-{
-public:
-    /// Construct test metadata values.
-    TestGameMetadata(std::int64_t hp, std::int64_t attack)
-            : hp(hp), attack(attack)
-    {}
-
-    /// Nonnegative hit-point value.
-    std::int64_t hp;
-
-    /// Nonnegative attack value.
-    std::int64_t attack;
-};
-
-/// Compiled game fixture proving extension schema and metadata hooks.
-class TestGame final : public GameDefinition
-{
-public:
-    /// Return the fixture's canonical short name.
-    std::string_view shortName() const override
-    {
-        return "test";
-    }
-
-    /// Return the fixture's human-readable name.
-    std::string_view displayName() const override
-    {
-        return "Test Game";
-    }
-
-    /// Return the fixture game description.
-    std::string_view description() const override
-    {
-        return "Compiled extension test game.";
-    }
-
-    /// Create the fixture extension table.
-    mw::E<void> createSchema(mw::SQLite& database) const override
-    {
-        return database.execute(
-            "CREATE TABLE test_cards ("
-            "card_id INTEGER PRIMARY KEY "
-            "REFERENCES cards(id) ON DELETE CASCADE, "
-            "hp INTEGER NOT NULL CHECK(hp >= 0), "
-            "attack INTEGER NOT NULL CHECK(attack >= 0));");
-    }
-
-    /// Validate the fixture's posted integer fields.
-    mw::E<std::unique_ptr<GameCardMetadata>> validateMetadata(
-        const FormFields& fields) const override
-    {
-        const auto hp_position = fields.find("hp");
-        const auto attack_position = fields.find("attack");
-        if(hp_position == fields.end() || attack_position == fields.end())
-        {
-            return std::unexpected(mw::httpError(
-                422, "HP and attack are required"));
-        }
-        auto hp = mw::strToNumber<std::int64_t>(hp_position->second);
-        auto attack = mw::strToNumber<std::int64_t>(
-            attack_position->second);
-        if(!hp || !attack || *hp < 0 || *attack < 0)
-        {
-            return std::unexpected(mw::httpError(
-                422, "HP and attack must be nonnegative integers"));
-        }
-        return std::unique_ptr<GameCardMetadata>(
-            new TestGameMetadata(*hp, *attack));
-    }
-
-    /// Return the fixture's numeric metadata controls.
-    std::vector<GameFormField> formFields() const override
-    {
-        return {
-            {"hp", "HP", "number", true, "0"},
-            {"attack", "Attack", "number", true, "0"},
-        };
-    }
-
-    /// Return fixture metadata as editable form values.
-    mw::E<FormFields> formValues(
-        mw::SQLite& database,
-        std::int64_t card_id) const override
-    {
-        auto statement = database.statementFromStr(
-            "SELECT hp, attack FROM test_cards WHERE card_id = ?;");
-        if(!statement)
-        {
-            return std::unexpected(std::move(statement.error()));
-        }
-        auto bind = statement->bind<std::int64_t>(card_id);
-        if(!bind)
-        {
-            return std::unexpected(std::move(bind.error()));
-        }
-        auto rows = database.eval<std::int64_t, std::int64_t>(
-            std::move(*statement));
-        if(!rows)
-        {
-            return std::unexpected(std::move(rows.error()));
-        }
-        if(rows->size() != 1)
-        {
-            return std::unexpected(mw::runtimeError(
-                "Test Game metadata is missing"));
-        }
-        const auto& [hp, attack] = rows->front();
-        return FormFields{
-            {"hp", std::to_string(hp)},
-            {"attack", std::to_string(attack)},
-        };
-    }
-
-    /// Insert fixture metadata for a common card ID.
-    mw::E<void> insertMetadata(
-        mw::SQLite& database,
-        std::int64_t card_id,
-        const GameCardMetadata& metadata) const override
-    {
-        const auto* values = dynamic_cast<const TestGameMetadata*>(&metadata);
-        if(values == nullptr)
-        {
-            return std::unexpected(mw::runtimeError(
-                "Test Game received metadata of the wrong type"));
-        }
-        auto statement = database.statementFromStr(
-            "INSERT INTO test_cards (card_id, hp, attack) "
-            "VALUES (?, ?, ?);");
-        if(!statement)
-        {
-            return std::unexpected(std::move(statement.error()));
-        }
-        auto bind = statement->bind<std::int64_t,
-                                    std::int64_t,
-                                    std::int64_t>(
-            card_id, values->hp, values->attack);
-        if(!bind)
-        {
-            return std::unexpected(std::move(bind.error()));
-        }
-        return database.execute(std::move(*statement));
-    }
-
-    /// Replace fixture metadata for a common card ID.
-    mw::E<void> updateMetadata(
-        mw::SQLite& database,
-        std::int64_t card_id,
-        const GameCardMetadata& metadata) const override
-    {
-        const auto* values = dynamic_cast<const TestGameMetadata*>(&metadata);
-        if(values == nullptr)
-        {
-            return std::unexpected(mw::runtimeError(
-                "Test Game received metadata of the wrong type"));
-        }
-        auto statement = database.statementFromStr(
-            "UPDATE test_cards SET hp = ?, attack = ? WHERE card_id = ?;");
-        if(!statement)
-        {
-            return std::unexpected(std::move(statement.error()));
-        }
-        auto bind = statement->bind<std::int64_t,
-                                    std::int64_t,
-                                    std::int64_t>(
-            values->hp, values->attack, card_id);
-        if(!bind)
-        {
-            return std::unexpected(std::move(bind.error()));
-        }
-        return database.execute(std::move(*statement));
-    }
-
-    /// Return fixture metadata formatted for card display.
-    mw::E<std::vector<DisplayField>> displayFields(
-        mw::SQLite& database,
-        std::int64_t card_id) const override
-    {
-        auto statement = database.statementFromStr(
-            "SELECT hp, attack FROM test_cards WHERE card_id = ?;");
-        if(!statement)
-        {
-            return std::unexpected(std::move(statement.error()));
-        }
-        auto bind = statement->bind<std::int64_t>(card_id);
-        if(!bind)
-        {
-            return std::unexpected(std::move(bind.error()));
-        }
-        auto rows = database.eval<std::int64_t, std::int64_t>(
-            std::move(*statement));
-        if(!rows)
-        {
-            return std::unexpected(std::move(rows.error()));
-        }
-        if(rows->size() != 1)
-        {
-            return std::unexpected(mw::runtimeError(
-                "Test Game metadata is missing"));
-        }
-        const auto& [hp, attack] = rows->front();
-        return std::vector<DisplayField>{
-            {"HP", std::to_string(hp)},
-            {"Attack", std::to_string(attack)},
-        };
-    }
-};
diff --git a/tests/user_collection_test.cpp b/tests/user_collection_test.cpp
index b05ac26..bce76c9 100644
--- a/tests/user_collection_test.cpp
+++ b/tests/user_collection_test.cpp
@@ -13,7 +13,6 @@
 
 #include "collection.h"
 #include "data_sqlite.h"
-#include "game_registry.h"
 #include "startup.h"
 #include "user_service.h"
 
@@ -120,7 +119,7 @@ mw::E<std::int64_t> insertPoolCard(
         "jpg",
         1,
         creator_user_id};
-    auto id = (*transaction)->insertCard(card, nullptr, nullptr, {});
+    auto id = (*transaction)->insertCard(card, {}, {});
     if(!id)
     {
         return std::unexpected(std::move(id.error()));
@@ -138,8 +137,7 @@ mw::E<std::int64_t> insertPoolCard(
 TEST(UserServiceTest, OnboardsAndPermanentlyPromotesPlayer)
 {
     TemporaryDatabase database;
-    GameRegistry games;
-    auto data_source = prepareDataSource(database.path(), games);
+    auto data_source = prepareDataSource(database.path());
     ASSERT_TRUE(data_source);
     auto administrator = (*data_source)->getUserByEmailKey(
         "admin@example.com");
@@ -164,8 +162,7 @@ TEST(UserServiceTest, OnboardsAndPermanentlyPromotesPlayer)
 TEST(CollectionServiceTest, AccruesCapsAndPullsAtomically)
 {
     TemporaryDatabase database;
-    GameRegistry games;
-    auto data_source = prepareDataSource(database.path(), games);
+    auto data_source = prepareDataSource(database.path());
     ASSERT_TRUE(data_source);
     auto administrator = (*data_source)->getUserByEmailKey(
         "admin@example.com");
@@ -205,8 +202,7 @@ TEST(CollectionServiceTest, AccruesCapsAndPullsAtomically)
 TEST(CollectionServiceTest, EmptyPoolPreservesRefreshedPull)
 {
     TemporaryDatabase database;
-    GameRegistry games;
-    auto data_source = prepareDataSource(database.path(), games);
+    auto data_source = prepareDataSource(database.path());
     ASSERT_TRUE(data_source);
     auto player_id = insertPlayer(**data_source, "empty@example.com");
     ASSERT_TRUE(player_id);